diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..561cd5e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..5fd301b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,44 @@ +name: Bug Report +description: Something isn't working as expected +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug. Please fill out the form below. + - type: textarea + id: description + attributes: + label: What happened? + description: A clear description of the bug. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: Code snippet that demonstrates the issue. + render: javascript + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + - type: input + id: version + attributes: + label: Package version + placeholder: "e.g. 2.0.0" + validations: + required: true + - type: input + id: node + attributes: + label: Node.js version + placeholder: "e.g. 22.0.0" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..89d418b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,23 @@ +name: Feature Request +description: Suggest a new feature or improvement +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem statement + description: What problem does this feature solve? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: Describe the solution you'd like to see. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any alternative solutions or features you've considered? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..2ff926c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## Summary + + + +## Type of change + +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that causes existing functionality to change) +- [ ] Documentation update + +## Checklist + +- [ ] My code passes `npm run lint` +- [ ] I have added tests that cover my changes +- [ ] All existing tests pass (`npm test`) +- [ ] I have updated the README if needed +- [ ] I have updated CHANGELOG.md + +## Related issues + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f1b9b72 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" + commit-message: + prefix: "chore(deps)" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "ci" diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml new file mode 100644 index 0000000..ad68c8f --- /dev/null +++ b/.github/workflows/java.yml @@ -0,0 +1,42 @@ +name: Java CI + +on: + push: + branches: [ master, test, prod-ready-riced-x100 ] + pull_request: + branches: [ master, test ] + +defaults: + run: + working-directory: packages/java + +jobs: + test: + name: JDK ${{ matrix.java-version }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + java-version: [ '25' ] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ matrix.java-version }} + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build + run: gradle build --no-daemon + + - name: Test + run: gradle test --no-daemon + env: + CI: true diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index c6db314..35a84fb 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -1,31 +1,43 @@ -# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions - name: Node.js CI on: push: - branches: [ master,test ] + branches: [ master, test ] pull_request: - branches: [ master,test ] + branches: [ master, test ] -jobs: - build: +defaults: + run: + working-directory: packages/js +jobs: + test: + name: Node ${{ matrix.node-version }} runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - node-version: [16.x, 18.x, 20.x, 22.x, 24.x] + node-version: [18.x, 20.x, 22.x, 24.x] steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - run: npm ci - - run: npm run build --if-present - - run: npm test - env: - CI: true + - name: Checkout + uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + cache-dependency-path: packages/js/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Test + run: npm test + env: + CI: true diff --git a/.gitignore b/.gitignore index 55bfa6a..dc9402a 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,8 @@ vendor temp tmp TODO.md +dist/ +*.tsbuildinfo +reports/ +bun.lock +bun.lockb diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1472edf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,66 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- **Monorepo restructure**: project is now a polyglot monorepo with two + language packages under `packages/`. The npm package source moved to + `packages/js/` (entry now resolves via `packages/js/package.json`, + unchanged for consumers). +- **Java module (`packages/java/`)** — full Java port with 100% feature + parity against the JavaScript module. Targets JDK 25, built with + Gradle Kotlin DSL, tested with JUnit 5 + AssertJ (66 tests). + - Core: `ConditionallyExecute`, `Context`, `Branch`, `Handler`, + `Middleware`, `Next`, `ConditionallyExecuteError`, `AggregateException` + - Plugins: `TimeoutPlugin`, `RetryPlugin`, `AuditLogPlugin`, + `CollectErrorsPlugin`, `DryRunPlugin`, `MultiThreadedPlugin` + (virtual-thread quorum), `GrpcConsensusPlugin` + `GrpcNodeServer` + - Async model: `CompletableFuture` everywhere; sync escape hatch + via `executeSync()`. +- **Shared gRPC schema** (`proto/conditionally_execute.proto`) — single + source of truth used by both the JS and Java `GrpcConsensusPlugin`, + enabling cross-runtime quorum (Java coordinator → JS nodes, etc.). +- **Java CI workflow** (`.github/workflows/java.yml`) running Gradle + build + tests on Temurin JDK 25. +- **Root README** redesigned as a monorepo overview pointing at per-language docs. +- `ConditionallyExecuteOptions` interface with `initialCondition` and `collectErrors` options (JS) +- `collectErrors` mode: collects all handler errors into an `AggregateError` instead of short-circuiting (JS) +- Full JSDoc documentation on all public members (JS) +- `.prettierrc` code style configuration +- `.editorconfig` for consistent editor settings +- GitHub issue templates (bug report, feature request) +- GitHub PR template +- `dependabot.yml` for automated dependency updates +- `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` +- `bench.js` — performance benchmark vs native `if` + +### Changed +- **Breaking**: `execute()` is now `async` and returns `Promise` + (previously synchronous — any code that did not `await` execute() was already + silently ignoring async handlers) +- Condition logic: `this.True` (public, boolean-typed `true` literal) replaced + with `this._condition` (private boolean). Last `.condition()` call wins. +- `onTrue()` and `onFalse()` now throw `TypeError` immediately for non-function arguments +- CI updated: `actions/checkout@v2` → `v4`, `setup-node@v1` → `v4` (with npm cache) +- CI: removed no-op `npm run build --if-present`, added lint and typecheck steps +- Node.js CI workflow now runs from `packages/js/` working directory (monorepo restructure) +- README: fixed typo in install command (`conditionaly-execute` → `conditionally-execute`) +- README: added full API reference, async examples, default condition documentation +- Node.js support floor raised from 16 (EOL) to 18 (LTS) + +### Fixed +- Multi-condition bug: calling `.condition(false).condition(true)` previously + ignored the second call; it now correctly uses the last-provided value +- Promise-returning handlers were silently dropped; all handlers now properly awaited + +## [1.0.0] — Initial release + +- `ConditionallyExecute` class with `.condition()`, `.onTrue()`, `.onFalse()`, `.execute()` +- Fluent builder API with arbitrary method ordering +- Basic Mocha test suite +- GitHub Actions CI (Node 16–24) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0c979f0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,32 @@ +# Code of Conduct + +## Our Pledge + +We pledge to make participation in this project a harassment-free experience +for everyone, regardless of age, body size, disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy toward other community members +- Not making fun of people for using `if` statements + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainer. All complaints will be reviewed and +investigated and will result in a response that is deemed necessary and +appropriate to the circumstances. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9ce6102 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,74 @@ +# Contributing to conditionally-execute + +Thank you for considering contributing to conditionally-execute — the enterprise-grade +solution for developers who find `if` statements too concise. + +## Development setup + +```bash +git clone https://github.com/bopke/conditionally-execute +cd conditionally-execute +npm install +``` + +## Available scripts + +| Script | Description | +|--------|-------------| +| `npm run build` | Compile TypeScript source to `dist/` | +| `npm run typecheck` | Type-check without emitting files | +| `npm run lint` | Lint source and tests | +| `npm run lint:fix` | Lint and auto-fix where possible | +| `npm test` | Run the test suite | +| `npm run bench` | Run the performance benchmark | + +## Project structure + +``` +conditionally-execute/ +├── src/ +│ └── index.ts # TypeScript source +├── dist/ # Compiled output (gitignored, built by CI) +├── .github/ +│ ├── ISSUE_TEMPLATE/ # Bug report and feature request templates +│ ├── workflows/ # GitHub Actions CI configuration +│ └── dependabot.yml # Automated dependency updates +├── test.js # Mocha test suite +├── bench.js # Performance benchmark +├── tsconfig.json # TypeScript compiler configuration +├── .eslintrc.js # ESLint configuration +├── .prettierrc # Prettier code style configuration +└── .editorconfig # Editor settings +``` + +## Submitting changes + +1. Fork the repository +2. Create a branch: `git checkout -b my-feature` +3. Make your changes +4. Ensure tests pass: `npm test` +5. Ensure linting passes: `npm run lint` +6. Update `CHANGELOG.md` under `[Unreleased]` +7. Open a Pull Request — fill out the PR template + +## Commit message convention + +``` +type(scope): short description + +longer description if needed +``` + +Types: `feat`, `fix`, `docs`, `chore`, `test`, `ci`, `refactor` + +Example: `fix(condition): last condition() call now overwrites previous value` + +## Code style + +This project uses Prettier for formatting and ESLint for linting. +Both run automatically if you use an editor that supports `.editorconfig` +and `.prettierrc`. You can also run `npm run lint:fix` before committing. + +## Questions? + +Open an issue or start a Discussion on GitHub. diff --git a/README.md b/README.md index 32cb218..4e398ae 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,125 @@ +
+ # conditionally-execute -> Lets you abandon "if" keyword -## Install +**Enterprise-grade if-statement replacement — now in two languages** -Install with npm: +[![Node.js CI](https://github.com/bopke/conditionally-execute/actions/workflows/nodejs.yml/badge.svg)](https://github.com/bopke/conditionally-execute/actions/workflows/nodejs.yml) +[![Java CI](https://github.com/bopke/conditionally-execute/actions/workflows/java.yml/badge.svg)](https://github.com/bopke/conditionally-execute/actions/workflows/java.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) -```bash -npm install conditionaly-execute -``` +> Lets you abandon `if` keyword. In any language. Across the wire. -## Usage +
+ +--- + +## Repository layout + +This is a polyglot monorepo. The same conditional-execution model is +implemented in two languages, sharing a single gRPC schema so the +distributed-consensus plugin can interop across runtimes. -```javascript -const ConditionallyExecute = require('conditionally-execute'); +``` +. +├── packages/ +│ ├── js/ # JavaScript / Node.js implementation +│ └── java/ # Java (JDK 25) implementation +├── proto/ # Shared gRPC schema for both modules +└── .github/workflows/ ``` -It's extremely easy to start using conditionally-execute, with its simple, straightforward and intelligible design. +| Package | Language | Status | Docs | +|---|---|---|---| +| [`packages/js`](packages/js) | JavaScript (Node ≥ 18) | published on npm | [README](packages/js/README.md) | +| [`packages/java`](packages/java) | Java (JDK 25) | new in this release | [README](packages/java/README.md) | -Just take a look on that piece of code: -```javascript -function thatsTrue(){ - console.log("True!"); -} -function thatsNotTrue(){ - console.log("False!"); -} +Both modules expose the same API surface: + +- Fluent builder (`condition`, `onTrue`, `onFalse`, `onError`, `use`) +- Middleware system (`.use(middleware)`) for cross-cutting concerns +- Sync and async execution (`execute` / `executeSync`) +- Plugin family: `TimeoutPlugin`, `RetryPlugin`, `AuditLogPlugin`, + `CollectErrorsPlugin`, `DryRunPlugin`, `MultiThreadedPlugin`, + `GrpcConsensusPlugin` + +The `GrpcConsensusPlugin` in either language can coordinate with nodes +running on the other — they share `proto/conditionally_execute.proto`. + +--- -new ConditionallyExecute().condition(1===1).onTrue(thatsTrue).onFalse(thatsNotTrue).execute(); +## Quick start + +### JavaScript + +```bash +npm install conditionally-execute ``` -The above code will, obviously, print out "True!". -It doesn't matter how we order method calls, as long as execute() method is the last one of our chain. Method calls from the above example can be as well ordered like this: ```javascript -new ConditionallyExecute().onFalse(thatsNotTrue).condition(1===1).onTrue(thatsTrue).execute(); +const ConditionallyExecute = require('conditionally-execute'); + +await new ConditionallyExecute() + .condition(user.isAdmin) + .onTrue(() => grantAccess()) + .onFalse(() => denyAccess()) + .execute(); ``` -### Usage with existing code -Obviously, this library does not collide with any existing `if` statements. It's also ultra-easy to refactor your existing code to make it make a good use of conditionally-execute. Just look on that example: +### Java -old, ugly iffed code: -```javascript -if(condition){ - console.log("yes"); -}else{ - console.log("no"); +```kotlin +// build.gradle.kts +dependencies { + implementation("com.bopke:conditionally-execute:2.0.0") } ``` -new, beautiful conditionally-executed code: -```javascript -new ConditionallyExecute().condition(condition).onTrue(()=>{console.log("yes");}).onFalse(()=>{console.log("no");}).execute(); + +```java +import com.bopke.conditionallyexecute.ConditionallyExecute; +import com.bopke.conditionallyexecute.Handler; + +new ConditionallyExecute() + .condition(user.isAdmin()) + .onTrue(Handler.sync(this::grantAccess)) + .onFalse(Handler.sync(this::denyAccess)) + .execute() + .join(); ``` + +See per-language READMEs for the full API, plugin catalogue, and +performance numbers. + +--- + +## Local development + +```bash +# Install JS deps + run JS tests +npm install +npm test + +# Build + test Java module (requires JDK 25 toolchain or foojay auto-provisioning) +cd packages/java && gradle test +``` + +The JS workspace is wired via npm workspaces (`packages/js`). The Java +module is a standalone Gradle project; the foojay-resolver plugin will +auto-provision JDK 25 on first run if a compatible JDK is not already +installed. + +--- + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). All contributions welcome — +please open the PR against `master` and respect the +[Code of Conduct](CODE_OF_CONDUCT.md). + +--- + +## License + +MIT © [Michał Kubik](https://github.com/bopke) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..12d08ae --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|--------------------| +| 2.x | ✅ Yes | +| 1.x | ❌ No (EOL) | + +## Reporting a Vulnerability + +If you discover a security vulnerability in conditionally-execute, please +**do not** open a public GitHub issue. + +Instead, please report it privately via GitHub's +[private vulnerability reporting](https://github.com/bopke/conditionally-execute/security/advisories/new) +feature. + +We will acknowledge receipt within 48 hours and aim to provide a fix or +mitigation within 7 days for confirmed vulnerabilities. + +> Note: This library executes user-supplied callback functions. Callers are +> responsible for ensuring that functions passed to `onTrue()` and `onFalse()` +> do not introduce security vulnerabilities in their own application. diff --git a/index.js b/index.js deleted file mode 100644 index 8c8df09..0000000 --- a/index.js +++ /dev/null @@ -1,36 +0,0 @@ -'use strict'; - -class ConditionallyExecute { - constructor() { - this._onTrue = []; - this._onFalse = []; - this.True = true; - } - - condition(condition) { - (!!this.True) ? this.True = condition : null; - return this; - } - - execute() { - (!!this.True) ? this._onTrue.forEach((func) => { - func(); - }) : - this._onFalse.forEach((func) => { - func(); - }); - - } - - onTrue(func) { - this._onTrue.push(func); - return this; - } - - onFalse(func) { - this._onFalse.push(func); - return this; - } -} - -module.exports = ConditionallyExecute; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index fdc275c..0000000 --- a/package-lock.json +++ /dev/null @@ -1,875 +0,0 @@ -{ - "name": "conditionally-execute", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "version": "1.0.0", - "license": "MIT", - "devDependencies": { - "mocha": "^10.8.2" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", - "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/braces/node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/diff": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", - "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "bin": { - "flat": "cli.js" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "bin": { - "he": "bin/he" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mocha": { - "version": "10.8.2", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", - "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.3", - "browser-stdout": "^1.3.1", - "chokidar": "^3.5.3", - "debug": "^4.3.5", - "diff": "^5.2.0", - "escape-string-regexp": "^4.0.0", - "find-up": "^5.0.0", - "glob": "^8.1.0", - "he": "^1.2.0", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^5.1.6", - "ms": "^2.1.3", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", - "supports-color": "^8.1.1", - "workerpool": "^6.5.1", - "yargs": "^16.2.0", - "yargs-parser": "^20.2.9", - "yargs-unparser": "^2.0.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha.js" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/workerpool": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", - "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, - "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/package.json b/package.json index 81848a5..8b8c95d 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,19 @@ { - "name": "conditionally-execute", - "description": "Lets you abandon \"if\" keyword", - "version": "1.0.0", - "homepage": "https://github.com/bopke/conditionally-execute", - "author": "Michał Kubik (https://github.com/bopke)", + "name": "conditionally-execute-monorepo", + "version": "2.0.0", + "description": "Monorepo for conditionally-execute (JavaScript + Java).", + "private": true, "license": "MIT", - "contributors": [ - "Michał Kubik (https://github.com/bopke)" - ], "repository": "bopke/conditionally-execute", - "bugs": { - "url": "https://github.com/bopke/conditionally-execute/issues" - }, - "files": [ - "index.js" + "homepage": "https://github.com/bopke/conditionally-execute", + "workspaces": [ + "packages/js" ], - "main": "index.js", - "devDependencies": { - "mocha": "^10.8.2" - }, "scripts": { - "test": "mocha" - }, - "keywords": [ - "if", - "execution", - "condition", - "conditional execution" - ] + "test": "npm test --workspace=packages/js", + "test:js": "npm test --workspace=packages/js", + "test:java": "cd packages/java && ./gradlew test", + "lint": "npm run lint --workspace=packages/js", + "bench": "npm run bench --workspace=packages/js" + } } diff --git a/packages/java/.gitignore b/packages/java/.gitignore new file mode 100644 index 0000000..7ba582d --- /dev/null +++ b/packages/java/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +.idea/ +*.iml +out/ +bin/ diff --git a/packages/java/README.md b/packages/java/README.md new file mode 100644 index 0000000..511c729 --- /dev/null +++ b/packages/java/README.md @@ -0,0 +1,133 @@ +# conditionally-execute (Java) + +Java port of [conditionally-execute](https://github.com/bopke/conditionally-execute) — a composable conditional-execution library with middleware, plugins, and distributed-consensus primitives. + +100% feature parity with the JavaScript module living in [`../js`](../js/). + +## Requirements + +- **JDK 25** (LTS, Sept 2025). The `foojay-resolver-convention` Gradle plugin auto-provisions a matching toolchain on first build if your local JDK is older. +- **Gradle 8.10+** (or just `gradle` on your PATH — the build is wrapper-less by default; run `gradle wrapper` once to commit a wrapper jar if desired) + +## Quick start + +```kotlin +// build.gradle.kts +dependencies { + implementation("com.bopke:conditionally-execute:2.0.0") +} +``` + +```java +import com.bopke.conditionallyexecute.ConditionallyExecute; +import com.bopke.conditionallyexecute.Handler; +import com.bopke.conditionallyexecute.plugins.TimeoutPlugin; +import com.bopke.conditionallyexecute.plugins.RetryPlugin; + +new ConditionallyExecute() + .use(TimeoutPlugin.of(5000)) + .use(RetryPlugin.of(3, RetryPlugin.Backoff.EXPONENTIAL)) + .condition(isHealthy()) + .onTrue(Handler.sync(this::deployToProduction)) + .execute() + .join(); +``` + +## API + +The Java API mirrors the JavaScript API one-to-one with idiomatic Java translations: + +| JavaScript | Java | +|---|---| +| `Promise` | `CompletableFuture` | +| `Error` / `TypeError` | `RuntimeException` / `IllegalArgumentException` | +| `AggregateError` | `AggregateException` (custom, with `errors()` accessor) | +| `Middleware: (ctx, next) => Promise` | `Middleware: BiFunction>` | +| Closure-captured handlers | `Handler` functional interface, plus `Handler.sync(Runnable)` / `Handler.async(Supplier<...>)` factories | +| `worker_threads` (MultiThreadedPlugin) | Virtual threads (JEP 444) | +| `@grpc/grpc-js` | `io.grpc:grpc-netty-shaded` | + +### Core + +```java +new ConditionallyExecute() + .condition(boolean | Object | String) // last call wins; String tries registry first + .onTrue(Handler | Runnable) + .onFalse(Handler | Runnable) + .onError(Consumer) // or onErrorAsync(Function>) + .use(Middleware) + .execute(); // CompletableFuture, runs handlers concurrently + // OR + .executeSync(); // void, sequential, no Promise overhead + +ConditionallyExecute.register("name", () -> evaluate()); +ConditionallyExecute.unregister("name"); +ConditionallyExecute.clearRegistry(); +``` + +### Plugins + +All plugins live in `com.bopke.conditionallyexecute.plugins`: + +- **`TimeoutPlugin.of(long ms)`** — wraps the chain in a deadline, throws `TimeoutError` +- **`RetryPlugin.of(int n)` / `RetryPlugin.of(int n, Backoff)`** — per-handler retry; `Backoff` is `NONE`, `LINEAR`, `EXPONENTIAL` +- **`AuditLogPlugin.of()` / `AuditLogPlugin.of(Consumer logger)`** — structured execution log +- **`CollectErrorsPlugin.of()`** — run all handlers, aggregate failures into `AggregateException` +- **`DryRunPlugin.of()` / `DryRunPlugin.of(Consumer logger)`** — log without executing +- **`MultiThreadedPlugin.of(Options)`** — virtual-thread quorum vote +- **`GrpcConsensusPlugin.of(Options)`** — distributed quorum over gRPC; pair with `GrpcNodeServer.startAsync(port, handlers)` + +The `GrpcConsensusPlugin` interops with the JavaScript implementation +through the shared `proto/conditionally_execute.proto` — a Java coordinator +can call JS nodes, and vice versa. + +## Testing + +```bash +gradle test +``` + +The test suite mirrors the JavaScript suite (66 tests in JUnit 5 + AssertJ): + +- `CoreTest` — basic / extended / coercion / async / sync / validation / middleware / registry / TimeoutPlugin / RetryPlugin / DryRunPlugin / AuditLogPlugin / CollectErrorsPlugin +- `MultiThreadedTest` — virtual-thread quorum tests +- `GrpcConsensusTest` — boots real gRPC servers on ports 52100–52102 + +## Project structure + +``` +packages/java/ +├── build.gradle.kts +├── settings.gradle.kts +├── gradle.properties +└── src/ + ├── main/java/com/bopke/conditionallyexecute/ + │ ├── ConditionallyExecute.java + │ ├── Context.java + │ ├── Branch.java + │ ├── Handler.java + │ ├── Middleware.java + │ ├── Next.java + │ ├── ConditionallyExecuteError.java + │ ├── AggregateException.java + │ └── plugins/ + │ ├── TimeoutPlugin.java + TimeoutError.java + │ ├── RetryPlugin.java + │ ├── AuditLogPlugin.java + │ ├── CollectErrorsPlugin.java + │ ├── DryRunPlugin.java + │ ├── MultiThreadedPlugin.java + │ ├── GrpcConsensusPlugin.java + QuorumError.java + NodeResult.java + │ └── GrpcNodeServer.java + └── test/java/com/bopke/conditionallyexecute/ + ├── CoreTest.java + ├── MultiThreadedTest.java + └── GrpcConsensusTest.java +``` + +The gRPC proto is **not** in this module — it lives at `proto/conditionally_execute.proto` +at the repository root and is staged into the Java build at compile time. + +## License + +MIT © [Michał Kubik](https://github.com/bopke) diff --git a/packages/java/build.gradle.kts b/packages/java/build.gradle.kts new file mode 100644 index 0000000..58c270e --- /dev/null +++ b/packages/java/build.gradle.kts @@ -0,0 +1,126 @@ +import com.google.protobuf.gradle.id +import java.time.Duration + +plugins { + `java-library` + id("com.google.protobuf") version "0.9.4" +} + +group = "com.bopke" +version = "2.0.0" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } + withSourcesJar() + withJavadocJar() +} + +repositories { + mavenCentral() +} + +dependencies { + api("io.grpc:grpc-stub:1.66.0") + api("io.grpc:grpc-protobuf:1.66.0") + api("com.google.protobuf:protobuf-java:4.28.2") + implementation("io.grpc:grpc-netty-shaded:1.66.0") + compileOnly("javax.annotation:javax.annotation-api:1.3.2") + + testImplementation(platform("org.junit:junit-bom:5.11.3")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.assertj:assertj-core:3.26.3") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +// Stage the shared proto into a build directory with Java options appended. +// The canonical proto at ../../proto/conditionally_execute.proto is read-only +// (shared with the JS module) so we copy + augment it here. +val stagedProtoDir = layout.buildDirectory.dir("staged-proto") + +val stageProto by tasks.registering { + val source = file("../../proto/conditionally_execute.proto") + inputs.file(source) + outputs.dir(stagedProtoDir) + doLast { + val outDir = stagedProtoDir.get().asFile + outDir.mkdirs() + val target = outDir.resolve("conditionally_execute.proto") + val original = source.readText() + // Inject Java options so generated classes land in our package and as + // separate files. Idempotent if options already present. + val javaOptions = """ + |option java_package = "com.bopke.conditionallyexecute.proto"; + |option java_multiple_files = true; + |option java_outer_classname = "ConditionallyExecuteProto"; + |""".trimMargin() + val augmented = if (original.contains("java_package")) + original + else + original.replace( + Regex("^package conditionally_execute;", RegexOption.MULTILINE), + "package conditionally_execute;\n\n$javaOptions" + ) + target.writeText(augmented) + } +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.28.2" + } + plugins { + id("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:1.66.0" + } + } + generateProtoTasks { + all().forEach { + it.plugins { + id("grpc") + } + it.dependsOn(stageProto) + } + } +} + +sourceSets { + main { + proto { + srcDir(stagedProtoDir) + } + } +} + +// processResources picks up the staged proto dir (it lives under build/ and +// the protobuf plugin maps proto srcDirs into the resources source set). +// Tell Gradle about the implicit dependency, and exclude .proto files from +// the runtime jar — they're not needed at runtime, the generated Java is. +tasks.processResources { + dependsOn(stageProto) + exclude("**/*.proto") +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "failed", "skipped") + showStandardStreams = false + } + // Allow long-running gRPC tests + timeout = Duration.ofMinutes(2) +} + +tasks.compileJava { + options.compilerArgs.addAll(listOf( + "-Xlint:all", + // Generated proto sources and a couple of intentional patterns + // (e.g. unchecked casts in lambda dispatch) trigger warnings under + // -Xlint:all; suppress at the compiler level rather than littering + // @SuppressWarnings everywhere. + "-Xlint:-processing", + "-Xlint:-serial", + )) + options.encoding = "UTF-8" +} diff --git a/packages/java/gradle.properties b/packages/java/gradle.properties new file mode 100644 index 0000000..d9e791e --- /dev/null +++ b/packages/java/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.jvmargs=-Xmx2g diff --git a/packages/java/gradle/wrapper/gradle-wrapper.properties b/packages/java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df97d72 --- /dev/null +++ b/packages/java/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/packages/java/settings.gradle.kts b/packages/java/settings.gradle.kts new file mode 100644 index 0000000..3ea1676 --- /dev/null +++ b/packages/java/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +rootProject.name = "conditionally-execute" diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/AggregateException.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/AggregateException.java new file mode 100644 index 0000000..c5760a5 --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/AggregateException.java @@ -0,0 +1,47 @@ +package com.bopke.conditionallyexecute; + +import java.util.List; +import java.util.Objects; + +/** + * Aggregates multiple handler failures into a single exception. Java equivalent + * of the JavaScript {@code AggregateError}. The message has the format + * {@code "{n} handler(s) failed"} to match the JS plugin output. + */ +public class AggregateException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final List errors; + + /** + * Construct from a list of throwables. + * + * @param errors all collected failures; must be non-null + */ + public AggregateException(List errors) { + super(Objects.requireNonNull(errors, "errors").size() + " handler(s) failed"); + this.errors = List.copyOf(errors); + for (Throwable t : this.errors) { + addSuppressed(t); + } + } + + /** + * Construct with a custom message. + * + * @param errors all collected failures + * @param message message override + */ + public AggregateException(List errors, String message) { + super(message); + this.errors = List.copyOf(Objects.requireNonNull(errors, "errors")); + for (Throwable t : this.errors) { + addSuppressed(t); + } + } + + /** @return immutable list of all collected errors */ + public List errors() { + return errors; + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/Branch.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/Branch.java new file mode 100644 index 0000000..91989b3 --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/Branch.java @@ -0,0 +1,33 @@ +package com.bopke.conditionallyexecute; + +/** + * Identifies which branch is active for a given {@link ConditionallyExecute} + * execution. + */ +public enum Branch { + /** Truthy branch (handlers registered via {@code onTrue}). */ + ON_TRUE, + /** Falsy branch (handlers registered via {@code onFalse}). */ + ON_FALSE; + + /** + * Returns the JavaScript-compatible string for this branch — {@code "onTrue"} + * or {@code "onFalse"} — so plugins like the audit log can emit messages with + * identical wording to the JS module. + * + * @return JS-compatible branch name + */ + public String toJsString() { + return this == ON_TRUE ? "onTrue" : "onFalse"; + } + + /** + * Convert a boolean condition to the corresponding branch. + * + * @param condition the resolved condition + * @return {@link #ON_TRUE} when {@code condition} is true, otherwise {@link #ON_FALSE} + */ + public static Branch of(boolean condition) { + return condition ? ON_TRUE : ON_FALSE; + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/ConditionallyExecute.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/ConditionallyExecute.java new file mode 100644 index 0000000..d912fab --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/ConditionallyExecute.java @@ -0,0 +1,404 @@ +package com.bopke.conditionallyexecute; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * ConditionallyExecute — composable conditional execution. + * + *

The core provides {@link #condition(boolean)}, {@link #onTrue(Handler)}, + * {@link #onFalse(Handler)}, {@link #onError(Consumer)}, + * {@link #use(Middleware)}, {@link #execute()}, and {@link #executeSync()}. + * Everything else — timeouts, retries, dry runs, audit logs, distributed + * consensus — lives in {@code com.bopke.conditionallyexecute.plugins} + * and is composed via {@link #use(Middleware)}.

+ * + *

This is the Java port of the + * JavaScript + * conditionally-execute library, intentionally preserving the same API + * shape (builder + middleware) with idiomatic Java equivalents (functional + * interfaces, {@link CompletableFuture}, virtual threads where appropriate).

+ * + *

Example

+ *
{@code
+ * import com.bopke.conditionallyexecute.plugins.TimeoutPlugin;
+ * import com.bopke.conditionallyexecute.plugins.RetryPlugin;
+ * import com.bopke.conditionallyexecute.plugins.RetryPlugin.Backoff;
+ *
+ * new ConditionallyExecute()
+ *     .use(TimeoutPlugin.of(5000))
+ *     .use(RetryPlugin.of(3, Backoff.EXPONENTIAL))
+ *     .condition(isHealthy)
+ *     .onTrue(Handler.sync(this::deployToProduction))
+ *     .execute()
+ *     .join();
+ * }
+ */ +public final class ConditionallyExecute { + + // ----------------------------------------------------------------------- + // Named-condition registry (static) + // ----------------------------------------------------------------------- + + private static final ConcurrentMap> REGISTRY = new ConcurrentHashMap<>(); + + /** + * Register a named condition for reuse across instances. Mirrors the JS + * {@code ConditionallyExecute.register(name, fn)} static method. + * + * @param name unique identifier (non-null) + * @param fn evaluator that produces an Object whose truthiness is checked + * at execution time (non-null) + * @throws IllegalArgumentException if either argument is null + */ + public static void register(String name, Supplier fn) { + if (name == null) { + throw new IllegalArgumentException( + "register() expects a string name, got null"); + } + if (fn == null) { + throw new IllegalArgumentException( + "register() expects a function evaluator, got null"); + } + REGISTRY.put(name, fn); + } + + /** + * Remove a named condition from the registry. No-op if absent. + * + * @param name the name to remove + */ + public static void unregister(String name) { + if (name != null) { + REGISTRY.remove(name); + } + } + + /** Clear every entry from the named-condition registry. */ + public static void clearRegistry() { + REGISTRY.clear(); + } + + // ----------------------------------------------------------------------- + // Instance state + // ----------------------------------------------------------------------- + + private boolean condition = true; + private final List onTrue = new ArrayList<>(); + private final List onFalse = new ArrayList<>(); + private final List middlewares = new ArrayList<>(); + private Function> errorHandler; + + /** Create an empty builder; condition defaults to {@code true}. */ + public ConditionallyExecute() { + // default state initialized above + } + + // ----------------------------------------------------------------------- + // Builder API + // ----------------------------------------------------------------------- + + /** + * Set the condition value directly. Last call wins. + * + * @param value the boolean condition + * @return this, for chaining + */ + public ConditionallyExecute condition(boolean value) { + this.condition = value; + return this; + } + + /** + * Set the condition from an arbitrary value. Mirrors JavaScript coercion + * semantics: + *
    + *
  • If {@code value} is a {@link String} and matches a registered name, + * the registry evaluator is invoked and its result coerced.
  • + *
  • Otherwise the value is coerced to boolean using JS-equivalent rules + * ({@code null} → false; empty string → false; non-empty string → + * true; {@link Number} with value 0 or NaN → false; {@link Boolean} + * passed through; any other non-null reference → true).
  • + *
+ * + * @param value the value to coerce + * @return this, for chaining + */ + public ConditionallyExecute condition(Object value) { + if (value instanceof String name && REGISTRY.containsKey(name)) { + Supplier fn = REGISTRY.get(name); + this.condition = coerceToBoolean(fn.get()); + } else { + this.condition = coerceToBoolean(value); + } + return this; + } + + /** + * Set the condition by name. Tries the registry first; if no entry exists, + * falls back to coercing the name string (any non-empty string is truthy, + * matching {@code Boolean("anyString") === true} in JS). + * + * @param name registry name or arbitrary string + * @return this, for chaining + */ + public ConditionallyExecute condition(String name) { + return condition((Object) name); + } + + /** + * Append a handler to the truthy branch. + * + * @param handler non-null handler + * @return this, for chaining + * @throws IllegalArgumentException when handler is null + */ + public ConditionallyExecute onTrue(Handler handler) { + if (handler == null) { + throw new IllegalArgumentException( + "onTrue() expects a function, got null"); + } + onTrue.add(handler); + return this; + } + + /** + * Append a synchronous {@link Runnable} as a truthy-branch handler. + * + * @param runnable non-null runnable + * @return this, for chaining + */ + public ConditionallyExecute onTrue(Runnable runnable) { + if (runnable == null) { + throw new IllegalArgumentException( + "onTrue() expects a function, got null"); + } + return onTrue(Handler.sync(runnable)); + } + + /** + * Append a handler to the falsy branch. + * + * @param handler non-null handler + * @return this, for chaining + * @throws IllegalArgumentException when handler is null + */ + public ConditionallyExecute onFalse(Handler handler) { + if (handler == null) { + throw new IllegalArgumentException( + "onFalse() expects a function, got null"); + } + onFalse.add(handler); + return this; + } + + /** + * Append a synchronous {@link Runnable} as a falsy-branch handler. + * + * @param runnable non-null runnable + * @return this, for chaining + */ + public ConditionallyExecute onFalse(Runnable runnable) { + if (runnable == null) { + throw new IllegalArgumentException( + "onFalse() expects a function, got null"); + } + return onFalse(Handler.sync(runnable)); + } + + /** + * Register an error handler. When set, exceptions thrown during + * {@link #execute()} are forwarded to this consumer instead of propagating. + * + * @param fn non-null error consumer + * @return this, for chaining + * @throws IllegalArgumentException when fn is null + */ + public ConditionallyExecute onError(Consumer fn) { + if (fn == null) { + throw new IllegalArgumentException( + "onError() expects a function, got null"); + } + this.errorHandler = err -> { + fn.accept(err); + return CompletableFuture.completedFuture(null); + }; + return this; + } + + /** + * Register an asynchronous error handler. When set, exceptions thrown + * during {@link #execute()} are forwarded to this function and the returned + * future is awaited. + * + * @param fn non-null async error handler + * @return this, for chaining + * @throws IllegalArgumentException when fn is null + */ + public ConditionallyExecute onErrorAsync(Function> fn) { + if (fn == null) { + throw new IllegalArgumentException( + "onError() expects a function, got null"); + } + this.errorHandler = fn; + return this; + } + + /** + * Install a middleware. Middlewares run in registration order, with the + * first registered being the outermost wrapper. + * + * @param middleware non-null middleware + * @return this, for chaining + * @throws IllegalArgumentException when middleware is null + */ + public ConditionallyExecute use(Middleware middleware) { + if (middleware == null) { + throw new IllegalArgumentException( + "use() expects a function middleware, got null"); + } + middlewares.add(middleware); + return this; + } + + // ----------------------------------------------------------------------- + // Execution + // ----------------------------------------------------------------------- + + /** + * Execute the middleware chain and active-branch handlers asynchronously. + * All active-branch handlers run concurrently via + * {@link CompletableFuture#allOf(CompletableFuture[])}. + * + * @return a future that completes when execution (including any error + * handler) has finished + */ + public CompletableFuture execute() { + Branch initialBranch = Branch.of(condition); + List active = new ArrayList<>(condition ? onTrue : onFalse); + Context ctx = new Context(condition, initialBranch, active, onTrue, onFalse); + + CompletableFuture chain = dispatch(ctx, 0); + + if (errorHandler == null) { + return chain; + } + + return chain.handle((unused, err) -> { + if (err == null) { + return CompletableFuture.completedFuture(null); + } + Throwable unwrapped = unwrap(err); + try { + CompletableFuture handled = errorHandler.apply(unwrapped); + return handled != null ? handled : CompletableFuture.completedFuture(null); + } catch (Throwable t) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(t); + return failed; + } + }).thenCompose(f -> f); + } + + private CompletableFuture dispatch(Context ctx, int i) { + if (i < middlewares.size()) { + Middleware mw = middlewares.get(i); + try { + CompletableFuture r = mw.apply(ctx, () -> dispatch(ctx, i + 1)); + return r != null ? r : CompletableFuture.completedFuture(null); + } catch (Throwable t) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(t); + return failed; + } + } + return runHandlers(ctx.handlers()); + } + + private static CompletableFuture runHandlers(List handlers) { + if (handlers.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + CompletableFuture[] futures = new CompletableFuture[handlers.size()]; + for (int i = 0; i < handlers.size(); i++) { + CompletableFuture f; + try { + f = handlers.get(i).run(); + if (f == null) { + f = CompletableFuture.completedFuture(null); + } + } catch (Throwable t) { + f = new CompletableFuture<>(); + f.completeExceptionally(t); + } + futures[i] = f; + } + return CompletableFuture.allOf(futures); + } + + /** + * Execute active-branch handlers sequentially, in registration order, with + * no middleware support. Use when handlers are synchronous and overhead + * matters. The {@link Handler#run()} future is invoked but not awaited — + * sync handlers complete immediately so this works as expected for the + * intended use case. + */ + public void executeSync() { + List handlers = condition ? onTrue : onFalse; + for (Handler h : handlers) { + CompletableFuture f = h.run(); + if (f != null && f.isCompletedExceptionally()) { + // Surface synchronous exceptions immediately, unwrapping the + // CompletionException wrapper that join() would otherwise add. + try { + f.join(); + } catch (CompletionException ce) { + Throwable cause = ce.getCause() != null ? ce.getCause() : ce; + if (cause instanceof RuntimeException re) throw re; + if (cause instanceof Error e) throw e; + throw new RuntimeException(cause); + } + } + } + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** + * Coerce an arbitrary value to boolean using JavaScript semantics: + * {@code null} → false; {@link Boolean} pass-through; + * {@link Number} 0 or NaN → false; non-empty {@link String} → true; + * empty {@link String} → false; any other non-null object → true. + */ + static boolean coerceToBoolean(Object value) { + return switch (value) { + case null -> false; + case Boolean b -> b; + case String s -> !s.isEmpty(); + case Number n -> { + double d = n.doubleValue(); + yield d != 0.0 && !Double.isNaN(d); + } + default -> true; + }; + } + + private static Throwable unwrap(Throwable t) { + Objects.requireNonNull(t, "throwable"); + Throwable cur = t; + while (cur instanceof CompletionException && cur.getCause() != null) { + cur = cur.getCause(); + } + return cur; + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/ConditionallyExecuteError.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/ConditionallyExecuteError.java new file mode 100644 index 0000000..d0bd848 --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/ConditionallyExecuteError.java @@ -0,0 +1,28 @@ +package com.bopke.conditionallyexecute; + +/** + * Base type for errors thrown by the library or its plugins. Equivalent to + * the JavaScript {@code ConditionallyExecuteError} class. + */ +public class ConditionallyExecuteError extends RuntimeException { + private static final long serialVersionUID = 1L; + + /** + * Construct with a message. + * + * @param message human-readable error message + */ + public ConditionallyExecuteError(String message) { + super(message); + } + + /** + * Construct with a message and underlying cause. + * + * @param message human-readable error message + * @param cause underlying throwable + */ + public ConditionallyExecuteError(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/Context.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/Context.java new file mode 100644 index 0000000..f41cff8 --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/Context.java @@ -0,0 +1,102 @@ +package com.bopke.conditionallyexecute; + +import java.util.List; +import java.util.Objects; + +/** + * Execution context passed through the middleware chain. + * + *

This is the Java equivalent of the JavaScript {@code ExecutionContext} + * object. Like its JS counterpart, the fields {@link #condition}, + * {@link #branch}, and {@link #handlers} are mutable so middleware can override + * the resolved branch (e.g. {@link com.bopke.conditionallyexecute.plugins.MultiThreadedPlugin} + * replaces the condition with the consensus result).

+ * + *

The two snapshots {@link #onTrueHandlers()} and {@link #onFalseHandlers()} + * are immutable — they correspond to the JS {@code _onTrue} and {@code _onFalse} + * arrays and are used by middleware that needs to swap the active handler list + * after re-evaluating the condition.

+ */ +public final class Context { + private boolean condition; + private Branch branch; + private List handlers; + private final List onTrueHandlers; + private final List onFalseHandlers; + + /** + * Create a new context. + * + * @param condition resolved condition value + * @param branch active branch + * @param handlers active handler list (will be mutated through the chain) + * @param onTrueHandlers full list of onTrue handlers (immutable snapshot) + * @param onFalseHandlers full list of onFalse handlers (immutable snapshot) + */ + public Context( + boolean condition, + Branch branch, + List handlers, + List onTrueHandlers, + List onFalseHandlers) { + this.condition = condition; + this.branch = Objects.requireNonNull(branch, "branch"); + this.handlers = Objects.requireNonNull(handlers, "handlers"); + this.onTrueHandlers = List.copyOf(onTrueHandlers); + this.onFalseHandlers = List.copyOf(onFalseHandlers); + } + + /** @return the current condition value */ + public boolean condition() { + return condition; + } + + /** + * Override the condition value. Middleware uses this to inject results from + * consensus, feature flags, or other late-binding decisions. + * + * @param condition new value + */ + public void setCondition(boolean condition) { + this.condition = condition; + } + + /** @return the active branch */ + public Branch branch() { + return branch; + } + + /** + * Override the active branch. + * + * @param branch new branch + */ + public void setBranch(Branch branch) { + this.branch = Objects.requireNonNull(branch, "branch"); + } + + /** @return the active handlers list (mutable reference) */ + public List handlers() { + return handlers; + } + + /** + * Replace the active handler list. Plugins use this to wrap each handler + * (retry, error-collection) or to swap branches mid-chain. + * + * @param handlers new handler list + */ + public void setHandlers(List handlers) { + this.handlers = Objects.requireNonNull(handlers, "handlers"); + } + + /** @return immutable snapshot of all registered {@code onTrue} handlers */ + public List onTrueHandlers() { + return onTrueHandlers; + } + + /** @return immutable snapshot of all registered {@code onFalse} handlers */ + public List onFalseHandlers() { + return onFalseHandlers; + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/Handler.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/Handler.java new file mode 100644 index 0000000..40267a4 --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/Handler.java @@ -0,0 +1,53 @@ +package com.bopke.conditionallyexecute; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +/** + * A handler invoked when a branch is selected. + * + *

Handlers return a {@link CompletableFuture} representing their (possibly + * asynchronous) completion. Use {@link #sync(Runnable)} or {@link #async(Supplier)} + * to wrap synchronous or asynchronous code respectively.

+ * + *

This is the Java equivalent of the JavaScript signature + * {@code () => void | Promise}.

+ */ +@FunctionalInterface +public interface Handler { + /** + * Run the handler. + * + * @return a future that completes when the handler is done; never {@code null}. + */ + CompletableFuture run(); + + /** + * Wrap a {@link Runnable} as a Handler that completes synchronously. + * + * @param r the runnable to invoke + * @return a Handler that runs {@code r} and returns an already-completed future + */ + static Handler sync(Runnable r) { + return () -> { + try { + r.run(); + return CompletableFuture.completedFuture(null); + } catch (Throwable t) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(t); + return failed; + } + }; + } + + /** + * Wrap a {@link Supplier} of {@link CompletableFuture} as a Handler. + * + * @param s the supplier + * @return a Handler delegating to {@code s} + */ + static Handler async(Supplier> s) { + return s::get; + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/Middleware.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/Middleware.java new file mode 100644 index 0000000..33eb2d6 --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/Middleware.java @@ -0,0 +1,27 @@ +package com.bopke.conditionallyexecute; + +import java.util.concurrent.CompletableFuture; + +/** + * Middleware that wraps the handler execution chain. + * + *

A middleware receives the mutable {@link Context} and a {@link Next} + * continuation. It may inspect or mutate the context, optionally call + * {@code next.proceed()} (short-circuit by not calling it), and run additional + * logic before or after the downstream chain completes.

+ * + *

Composes in registration order — the first registered middleware runs + * outermost.

+ */ +@FunctionalInterface +public interface Middleware { + /** + * Apply this middleware. + * + * @param ctx mutable execution context + * @param next continuation + * @return a future that completes when this middleware (and any downstream + * work it awaited) has finished + */ + CompletableFuture apply(Context ctx, Next next); +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/Next.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/Next.java new file mode 100644 index 0000000..92969bd --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/Next.java @@ -0,0 +1,20 @@ +package com.bopke.conditionallyexecute; + +import java.util.concurrent.CompletableFuture; + +/** + * Continuation passed to a {@link Middleware}. Calling {@link #proceed()} + * invokes the next middleware in the chain (or the handler dispatcher when + * the current middleware is the last one). + * + *

Equivalent to the JavaScript {@code () => Promise} callback.

+ */ +@FunctionalInterface +public interface Next { + /** + * Continue execution downstream. + * + * @return a future that completes when the rest of the chain has finished + */ + CompletableFuture proceed(); +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/AuditLogPlugin.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/AuditLogPlugin.java new file mode 100644 index 0000000..0ae026a --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/AuditLogPlugin.java @@ -0,0 +1,51 @@ +package com.bopke.conditionallyexecute.plugins; + +import com.bopke.conditionallyexecute.Middleware; + +import java.time.Instant; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Emits a structured log entry after the downstream chain completes. Defaults + * to {@link System#out} but accepts any {@link Consumer} as a sink. + * + *

Output format matches the JS module exactly: + * {@code [{ISO}] ConditionallyExecute: condition={cond} branch={branch} handlers={n} duration={ms}ms}.

+ */ +public final class AuditLogPlugin { + + private AuditLogPlugin() {} + + /** + * @return audit middleware that writes to {@code System.out} + */ + public static Middleware of() { + return of(System.out::println); + } + + /** + * @param logger non-null log sink + * @return audit middleware that writes formatted entries to {@code logger} + */ + public static Middleware of(Consumer logger) { + Objects.requireNonNull(logger, "logger"); + return (ctx, next) -> { + long startNanos = System.nanoTime(); + return next.proceed().whenComplete((v, err) -> { + double durationMs = (System.nanoTime() - startNanos) / 1_000_000.0; + String timestamp = Instant.now().toString(); + String message = String.format( + Locale.ROOT, + "[%s] ConditionallyExecute: condition=%s branch=%s handlers=%d duration=%.2fms", + timestamp, + ctx.condition(), + ctx.branch().toJsString(), + ctx.handlers().size(), + durationMs); + logger.accept(message); + }); + }; + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/CollectErrorsPlugin.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/CollectErrorsPlugin.java new file mode 100644 index 0000000..6a31445 --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/CollectErrorsPlugin.java @@ -0,0 +1,82 @@ +package com.bopke.conditionallyexecute.plugins; + +import com.bopke.conditionallyexecute.AggregateException; +import com.bopke.conditionallyexecute.Handler; +import com.bopke.conditionallyexecute.Middleware; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +/** + * Runs every handler even if some fail, then collects all failures into a + * single {@link AggregateException}. Without this plugin + * {@link java.util.concurrent.CompletableFuture#allOf} short-circuits on the + * first failure. + * + *
{@code
+ * new ConditionallyExecute()
+ *     .use(CollectErrorsPlugin.of())
+ *     .condition(true)
+ *     .onTrue(handlerA)   // fails — captured
+ *     .onTrue(handlerB)   // still runs
+ *     .onTrue(handlerC)   // still runs
+ *     .execute()
+ *     .join();           // throws AggregateException
+ * }
+ */ +public final class CollectErrorsPlugin { + + private CollectErrorsPlugin() {} + + /** + * @return middleware that collects per-handler failures + */ + public static Middleware of() { + return (ctx, next) -> { + List errors = Collections.synchronizedList(new ArrayList<>()); + + List wrapped = new ArrayList<>(ctx.handlers().size()); + for (Handler h : ctx.handlers()) { + wrapped.add(() -> { + CompletableFuture inner; + try { + inner = h.run(); + if (inner == null) { + inner = CompletableFuture.completedFuture(null); + } + } catch (Throwable t) { + errors.add(t); + return CompletableFuture.completedFuture(null); + } + return inner.handle((v, err) -> { + if (err != null) { + errors.add(unwrap(err)); + } + return null; + }); + }); + } + ctx.setHandlers(wrapped); + + return next.proceed().thenCompose(v -> { + if (errors.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new AggregateException(new ArrayList<>(errors))); + return failed; + }); + }; + } + + private static Throwable unwrap(Throwable t) { + Throwable cur = t; + while (cur instanceof CompletionException && cur.getCause() != null) { + cur = cur.getCause(); + } + return cur; + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/DryRunPlugin.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/DryRunPlugin.java new file mode 100644 index 0000000..098e62f --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/DryRunPlugin.java @@ -0,0 +1,44 @@ +package com.bopke.conditionallyexecute.plugins; + +import com.bopke.conditionallyexecute.Middleware; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +/** + * Logs what would have run and short-circuits the chain — {@code next.proceed()} + * is never called, so handlers and downstream middleware are skipped. + * + *

Compose last (innermost) so upstream middleware still executes normally.

+ * + *

Log format matches the JS module: + * {@code [DryRun] ConditionallyExecute: would execute {n} handler(s) on branch {branch}}.

+ */ +public final class DryRunPlugin { + + private DryRunPlugin() {} + + /** + * @return dry-run middleware that writes to {@code System.out} + */ + public static Middleware of() { + return of(System.out::println); + } + + /** + * @param logger non-null log sink + * @return dry-run middleware that writes to {@code logger} + */ + public static Middleware of(Consumer logger) { + Objects.requireNonNull(logger, "logger"); + return (ctx, next) -> { + logger.accept(String.format( + "[DryRun] ConditionallyExecute: would execute %d handler(s) on branch %s", + ctx.handlers().size(), + ctx.branch().toJsString())); + // intentionally does not call next.proceed() + return CompletableFuture.completedFuture(null); + }; + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/GrpcConsensusPlugin.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/GrpcConsensusPlugin.java new file mode 100644 index 0000000..4d4bba8 --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/GrpcConsensusPlugin.java @@ -0,0 +1,269 @@ +package com.bopke.conditionallyexecute.plugins; + +import com.bopke.conditionallyexecute.Middleware; +import com.bopke.conditionallyexecute.proto.ConditionallyExecuteNodeGrpc; +import com.bopke.conditionallyexecute.proto.ExecuteRequest; +import com.bopke.conditionallyexecute.proto.ExecuteResponse; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.stub.StreamObserver; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * Enterprise-grade distributed execution with quorum agreement over gRPC. + * + *

Architecture: + *

    + *
  • Each "node" is a gRPC server running the {@code ConditionallyExecuteNode} + * service (see {@link GrpcNodeServer}).
  • + *
  • The coordinator fans out {@code Execute} RPCs to all configured nodes + * simultaneously.
  • + *
  • Each node runs its locally-registered handler and reports back.
  • + *
  • If fewer than {@code quorum} nodes confirm success → {@link QuorumError}.
  • + *
+ * + *
{@code
+ * var n1 = GrpcNodeServer.startAsync(50051, Map.of("deploy", deployHandler)).join();
+ * var n2 = GrpcNodeServer.startAsync(50052, Map.of("deploy", deployHandler)).join();
+ * var n3 = GrpcNodeServer.startAsync(50053, Map.of("deploy", deployHandler)).join();
+ *
+ * new ConditionallyExecute()
+ *     .use(GrpcConsensusPlugin.of(GrpcConsensusPlugin.options()
+ *         .nodes(List.of("localhost:50051", "localhost:50052", "localhost:50053"))
+ *         .handlerName("deploy")
+ *         .quorum(2)
+ *         .build()))
+ *     .condition(isReadyForDeploy)
+ *     .onTrue(() -> log.info("coordinator: quorum reached"))
+ *     .execute();
+ * }
+ */ +public final class GrpcConsensusPlugin { + + private GrpcConsensusPlugin() {} + + /** Configuration for {@link #of(Options)}. */ + public static final class Options { + final List nodes; + final String handlerName; + final int quorum; + final long timeoutMs; + final boolean verbose; + + private Options(Builder b) { + this.nodes = List.copyOf(b.nodes); + this.handlerName = b.handlerName; + this.quorum = b.quorum > 0 ? b.quorum : (this.nodes.size() / 2 + 1); + this.timeoutMs = b.timeoutMs; + this.verbose = b.verbose; + } + + /** @return a fresh builder */ + public static Builder builder() { + return new Builder(); + } + + /** Mutable builder for {@link Options}. */ + public static final class Builder { + private List nodes = List.of(); + private String handlerName; + private int quorum = -1; + private long timeoutMs = 5000L; + private boolean verbose = false; + + /** + * @param nodes gRPC node addresses (host:port) + * @return this + */ + public Builder nodes(List nodes) { + this.nodes = nodes != null ? nodes : List.of(); + return this; + } + + /** + * @param handlerName name of the handler each node should invoke + * @return this + */ + public Builder handlerName(String handlerName) { + this.handlerName = handlerName; + return this; + } + + /** + * @param quorum minimum successful nodes required (default: majority) + * @return this + */ + public Builder quorum(int quorum) { + this.quorum = quorum; + return this; + } + + /** + * @param timeoutMs per-node RPC deadline + * @return this + */ + public Builder timeout(long timeoutMs) { + this.timeoutMs = timeoutMs; + return this; + } + + /** + * @param verbose log per-node results + * @return this + */ + public Builder verbose(boolean verbose) { + this.verbose = verbose; + return this; + } + + /** @return built options */ + public Options build() { + if (nodes == null || nodes.isEmpty()) { + throw new IllegalArgumentException( + "GrpcConsensusPlugin: nodes must be a non-empty array of gRPC addresses"); + } + if (handlerName == null || handlerName.isEmpty()) { + throw new IllegalArgumentException( + "GrpcConsensusPlugin: handlerName is required"); + } + int effectiveQuorum = quorum > 0 ? quorum : (nodes.size() / 2 + 1); + if (effectiveQuorum > nodes.size()) { + throw new IllegalArgumentException( + "GrpcConsensusPlugin: quorum (" + effectiveQuorum + + ") cannot exceed node count (" + nodes.size() + ")"); + } + return new Options(this); + } + } + } + + /** Shortcut for {@code Options.builder()}. */ + public static Options.Builder options() { + return Options.builder(); + } + + /** + * Create the middleware. + * + * @param options config (use {@link #options()} to build) + * @return the middleware + */ + public static Middleware of(Options options) { + Objects.requireNonNull(options, "options"); + + return (ctx, next) -> { + String requestId = UUID.randomUUID().toString(); + ExecuteRequest request = ExecuteRequest.newBuilder() + .setRequestId(requestId) + .setCondition(ctx.condition()) + .setHandlerName(options.handlerName) + .build(); + + List> calls = new ArrayList<>(options.nodes.size()); + for (String addr : options.nodes) { + calls.add(callNode(addr, request, options.timeoutMs)); + } + + CompletableFuture[] arr = calls.toArray(new CompletableFuture[0]); + return CompletableFuture.allOf(arr).thenCompose(v -> { + List results = new ArrayList<>(calls.size()); + int succeeded = 0; + for (CompletableFuture c : calls) { + NodeResult r = c.join(); + results.add(r); + if (r.success()) succeeded++; + } + + if (options.verbose || System.getenv("CE_GRPC_DEBUG") != null) { + for (NodeResult r : results) { + String icon = r.success() ? "OK " : "ERR"; + String detail; + if (r.rpcError() != null) { + detail = "RPC error: " + r.rpcError().getMessage(); + } else if (r.response() != null) { + detail = String.format("branch=%s duration=%.2fms", + r.response().getBranch(), r.response().getDurationMs()); + } else { + detail = "no response"; + } + System.out.println(String.format( + "[GrpcConsensusPlugin] %s %s: %s", + icon, r.address(), detail)); + } + System.out.println(String.format( + "[GrpcConsensusPlugin] quorum: %d/%d (required %d) → %s", + succeeded, options.nodes.size(), options.quorum, + succeeded >= options.quorum ? "PASSED" : "FAILED")); + } + + if (succeeded < options.quorum) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new QuorumError( + succeeded, options.quorum, options.nodes.size(), results)); + return failed; + } + + return next.proceed(); + }); + }; + } + + private static CompletableFuture callNode( + String address, ExecuteRequest request, long deadlineMs) { + CompletableFuture result = new CompletableFuture<>(); + ManagedChannel channel; + try { + channel = ManagedChannelBuilder.forTarget(address) + .usePlaintext() + .build(); + } catch (Throwable t) { + result.complete(new NodeResult(address, false, t, null)); + return result; + } + + ConditionallyExecuteNodeGrpc.ConditionallyExecuteNodeStub stub = + ConditionallyExecuteNodeGrpc.newStub(channel) + .withDeadlineAfter(deadlineMs, TimeUnit.MILLISECONDS); + + try { + stub.execute(request, new StreamObserver<>() { + @Override + public void onNext(ExecuteResponse value) { + boolean success = value.getError().isEmpty() && value.getExecuted(); + result.complete(new NodeResult(address, success, null, value)); + } + + @Override + public void onError(Throwable t) { + result.complete(new NodeResult(address, false, t, null)); + shutdownChannel(channel); + } + + @Override + public void onCompleted() { + shutdownChannel(channel); + } + }); + } catch (Throwable t) { + result.complete(new NodeResult(address, false, t, null)); + shutdownChannel(channel); + } + + return result; + } + + private static void shutdownChannel(ManagedChannel channel) { + try { + channel.shutdownNow().awaitTermination(2, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/GrpcNodeServer.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/GrpcNodeServer.java new file mode 100644 index 0000000..ea6de7f --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/GrpcNodeServer.java @@ -0,0 +1,249 @@ +package com.bopke.conditionallyexecute.plugins; + +import com.bopke.conditionallyexecute.Handler; +import com.bopke.conditionallyexecute.proto.ConditionallyExecuteNodeGrpc; +import com.bopke.conditionallyexecute.proto.ExecuteRequest; +import com.bopke.conditionallyexecute.proto.ExecuteResponse; +import com.bopke.conditionallyexecute.proto.HealthRequest; +import com.bopke.conditionallyexecute.proto.HealthResponse; + +import io.grpc.Server; +import io.grpc.ServerBuilder; +import io.grpc.stub.StreamObserver; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * Reference gRPC server implementing the {@code ConditionallyExecuteNode} + * service from {@code conditionally_execute.proto}. Mirrors the JS + * {@code startGrpcNode()} helper — primarily used by tests, but also fine for + * production node processes. + * + *

Behavior: + *

    + *
  • If the requested handler isn't registered → {@code executed=false}, error + * contains "Handler '{name}' not registered on {nodeId}".
  • + *
  • If {@code condition=false} → {@code executed=false}, branch={@code "onFalse"}, + * empty error (matches the JS node behavior of skipping execution).
  • + *
  • If {@code condition=true} and the handler exists → it runs; the response + * reports {@code executed=true}, branch={@code "onTrue"}, duration. + * Thrown exceptions surface as {@code error} (still {@code executed=false}).
  • + *
+ */ +public final class GrpcNodeServer { + + /** Builder for {@link GrpcNodeServer}. */ + public static final class Builder { + private final int port; + private final Map handlers; + private String nodeId; + + Builder(int port, Map handlers) { + this.port = port; + this.handlers = Map.copyOf(handlers); + } + + /** + * @param nodeId override the default {@code "node-{port}"} id + * @return this + */ + public Builder nodeId(String nodeId) { + this.nodeId = nodeId; + return this; + } + + /** + * Start the server and bind to the configured port. + * + * @return future that completes with the running server + */ + public CompletableFuture startAsync() { + CompletableFuture result = new CompletableFuture<>(); + try { + String id = nodeId != null ? nodeId : "node-" + port; + GrpcNodeServer server = new GrpcNodeServer(port, id, handlers); + server.start(); + result.complete(server); + } catch (IOException e) { + result.completeExceptionally(e); + } + return result; + } + + /** + * Start the server synchronously. + * + * @return the running server + * @throws IOException on bind failure + */ + public GrpcNodeServer start() throws IOException { + String id = nodeId != null ? nodeId : "node-" + port; + GrpcNodeServer server = new GrpcNodeServer(port, id, handlers); + server.start(); + return server; + } + } + + /** + * Create a builder for a node bound to {@code port} with the given handler + * map (name → handler). + * + * @param port listen port + * @param handlers name-to-handler map (defensively copied) + * @return the builder + */ + public static Builder builder(int port, Map handlers) { + return new Builder(port, Objects.requireNonNull(handlers, "handlers")); + } + + /** + * Convenience: start a node directly (matches the JS + * {@code startGrpcNode(port, handlers)} call signature). + * + * @param port listen port + * @param handlers name-to-handler map + * @return future of the running server + */ + public static CompletableFuture startAsync(int port, Map handlers) { + return builder(port, handlers).startAsync(); + } + + private final int port; + private final String nodeId; + private final Map handlers; + private Server server; + + private GrpcNodeServer(int port, String nodeId, Map handlers) { + this.port = port; + this.nodeId = nodeId; + this.handlers = handlers; + } + + private void start() throws IOException { + this.server = ServerBuilder.forPort(port) + .addService(new NodeService()) + .build() + .start(); + } + + /** @return the node identifier (defaults to {@code "node-{port}"}) */ + public String nodeId() { + return nodeId; + } + + /** @return the listen port */ + public int port() { + return port; + } + + /** @return immutable handler map */ + public Map handlers() { + return Collections.unmodifiableMap(handlers); + } + + /** + * Initiate graceful shutdown and wait for termination. + * + * @return future that completes when the server has stopped + */ + public CompletableFuture close() { + CompletableFuture done = new CompletableFuture<>(); + if (server == null) { + done.complete(null); + return done; + } + Thread.startVirtualThread(() -> { + try { + server.shutdown().awaitTermination(5, TimeUnit.SECONDS); + done.complete(null); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + done.completeExceptionally(e); + } + }); + return done; + } + + private final class NodeService extends ConditionallyExecuteNodeGrpc.ConditionallyExecuteNodeImplBase { + + @Override + public void execute(ExecuteRequest request, StreamObserver obs) { + String handlerName = request.getHandlerName(); + boolean condition = request.getCondition(); + Handler handler = handlers.get(handlerName); + + if (handler == null) { + obs.onNext(ExecuteResponse.newBuilder() + .setNodeId(nodeId) + .setExecuted(false) + .setBranch(condition ? "onTrue" : "onFalse") + .setDurationMs(0.0) + .setError("Handler '" + handlerName + "' not registered on " + nodeId) + .build()); + obs.onCompleted(); + return; + } + + if (!condition) { + obs.onNext(ExecuteResponse.newBuilder() + .setNodeId(nodeId) + .setExecuted(false) + .setBranch("onFalse") + .setDurationMs(0.0) + .setError("") + .build()); + obs.onCompleted(); + return; + } + + long start = System.nanoTime(); + CompletableFuture exec; + try { + exec = handler.run(); + if (exec == null) { + exec = CompletableFuture.completedFuture(null); + } + } catch (Throwable t) { + exec = new CompletableFuture<>(); + exec.completeExceptionally(t); + } + + exec.whenComplete((v, err) -> { + double durationMs = (System.nanoTime() - start) / 1_000_000.0; + ExecuteResponse.Builder b = ExecuteResponse.newBuilder() + .setNodeId(nodeId) + .setBranch("onTrue") + .setDurationMs(durationMs); + if (err == null) { + b.setExecuted(true).setError(""); + } else { + Throwable cause = err; + while (cause.getCause() != null && cause instanceof java.util.concurrent.CompletionException) { + cause = cause.getCause(); + } + String msg = cause.getMessage(); + b.setExecuted(false).setError(msg != null ? msg : cause.toString()); + } + obs.onNext(b.build()); + obs.onCompleted(); + }); + } + + @Override + public void health(HealthRequest request, StreamObserver obs) { + HealthResponse.Builder b = HealthResponse.newBuilder() + .setNodeId(nodeId) + .setStatus("ok"); + for (String name : handlers.keySet()) { + b.addHandlers(name); + } + obs.onNext(b.build()); + obs.onCompleted(); + } + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/MultiThreadedPlugin.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/MultiThreadedPlugin.java new file mode 100644 index 0000000..7ec4e4f --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/MultiThreadedPlugin.java @@ -0,0 +1,220 @@ +package com.bopke.conditionallyexecute.plugins; + +import com.bopke.conditionallyexecute.Branch; +import com.bopke.conditionallyexecute.Middleware; + +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Distributed consensus for your if-statements. + * + *

Spawns N virtual threads acting as independent consensus nodes. Each + * "node" receives the condition value and casts a vote. The majority vote + * determines which branch executes. Communication is in-process via shared + * concurrent primitives — same architecture as the JS module's + * {@code worker_threads} implementation, just using JEP 444 virtual threads + * instead.

+ * + *
{@code
+ * new ConditionallyExecute()
+ *     .use(MultiThreadedPlugin.of(MultiThreadedPlugin.options().nodes(5).build()))
+ *     .condition(userIsAdmin)
+ *     .onTrue(() -> grantAccess())
+ *     .onFalse(() -> denyAccess())
+ *     .execute();
+ * }
+ */ +public final class MultiThreadedPlugin { + + private static final ScheduledExecutorService SCHEDULER = + Executors.newScheduledThreadPool(1, r -> { + Thread t = new Thread(r, "conditionally-execute-mt-timeout"); + t.setDaemon(true); + return t; + }); + + private MultiThreadedPlugin() {} + + /** Configuration for {@link #of(Options)}. */ + public static final class Options { + final int nodes; + final long timeoutMs; + final boolean jitter; + final boolean verbose; + + private Options(Builder b) { + this.nodes = b.nodes; + this.timeoutMs = b.timeoutMs; + this.jitter = b.jitter; + this.verbose = b.verbose; + } + + /** @return a fresh builder */ + public static Builder builder() { + return new Builder(); + } + + /** Mutable builder for {@link Options}. */ + public static final class Builder { + private int nodes = 3; + private long timeoutMs = 2000L; + private boolean jitter = false; + private boolean verbose = false; + + /** + * @param nodes consensus node count (must be odd, ≥ 3) + * @return this + */ + public Builder nodes(int nodes) { + this.nodes = nodes; + return this; + } + + /** + * @param timeoutMs maximum ms to wait for all votes + * @return this + */ + public Builder timeout(long timeoutMs) { + this.timeoutMs = timeoutMs; + return this; + } + + /** + * @param jitter add random latency per node (chaos testing) + * @return this + */ + public Builder jitter(boolean jitter) { + this.jitter = jitter; + return this; + } + + /** + * @param verbose log vote results to stdout + * @return this + */ + public Builder verbose(boolean verbose) { + this.verbose = verbose; + return this; + } + + /** @return built options */ + public Options build() { + return new Options(this); + } + } + } + + /** Shortcut for {@code Options.builder()}. */ + public static Options.Builder options() { + return Options.builder(); + } + + /** + * Create the consensus middleware. + * + * @param options config (use {@link #options()} to build) + * @return the middleware + * @throws IllegalArgumentException if nodes is even or {@literal < 3} + */ + public static Middleware of(Options options) { + Objects.requireNonNull(options, "options"); + if (options.nodes < 3) { + throw new IllegalArgumentException( + "MultiThreadedPlugin: nodes must be an integer ≥ 3"); + } + if (options.nodes % 2 == 0) { + throw new IllegalArgumentException( + "MultiThreadedPlugin: nodes must be odd to guarantee a clear majority"); + } + + return (ctx, next) -> { + CompletableFuture votesFuture = + collectVotes(ctx.condition(), options.nodes, options.timeoutMs, options.jitter); + + return votesFuture.thenCompose(votes -> { + int trueVotes = 0; + for (boolean v : votes) if (v) trueVotes++; + int falseVotes = options.nodes - trueVotes; + boolean consensus = trueVotes > falseVotes; + + if (options.verbose || System.getenv("CE_CONSENSUS_DEBUG") != null) { + System.out.println(String.format( + "[MultiThreadedPlugin] %d nodes voted: %d true / %d false → consensus=%s", + options.nodes, trueVotes, falseVotes, consensus)); + } + + ctx.setCondition(consensus); + ctx.setBranch(Branch.of(consensus)); + ctx.setHandlers(new java.util.ArrayList<>( + consensus ? ctx.onTrueHandlers() : ctx.onFalseHandlers())); + + return next.proceed(); + }); + }; + } + + /** + * Spawn N virtual-thread nodes and collect their votes. + * + * @param condition the input condition + * @param nodeCount how many nodes + * @param timeoutMs vote-collection timeout + * @param jitter whether to simulate latency + * @return future of the vote array + */ + public static CompletableFuture collectVotes( + boolean condition, int nodeCount, long timeoutMs, boolean jitter) { + + CompletableFuture result = new CompletableFuture<>(); + ConcurrentLinkedQueue votes = new ConcurrentLinkedQueue<>(); + AtomicInteger remaining = new AtomicInteger(nodeCount); + AtomicBoolean settled = new AtomicBoolean(false); + + var timeoutTask = SCHEDULER.schedule(() -> { + if (settled.compareAndSet(false, true)) { + result.completeExceptionally(new RuntimeException( + "MultiThreadedPlugin: vote collection timed out after " + timeoutMs + "ms")); + } + }, timeoutMs, TimeUnit.MILLISECONDS); + + for (int i = 0; i < nodeCount; i++) { + Thread.startVirtualThread(() -> { + try { + boolean vote = condition; // deterministic evaluation + if (jitter) { + long delay = ThreadLocalRandom.current().nextInt(50); + if (delay > 0) { + Thread.sleep(delay); + } + } + votes.add(vote); + if (remaining.decrementAndGet() == 0 + && settled.compareAndSet(false, true)) { + timeoutTask.cancel(false); + boolean[] arr = new boolean[nodeCount]; + int idx = 0; + for (Boolean b : votes) { + arr[idx++] = b != null && b; + } + result.complete(arr); + } + } catch (Throwable t) { + if (settled.compareAndSet(false, true)) { + timeoutTask.cancel(false); + result.completeExceptionally(t); + } + } + }); + } + + return result; + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/NodeResult.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/NodeResult.java new file mode 100644 index 0000000..e6a2219 --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/NodeResult.java @@ -0,0 +1,20 @@ +package com.bopke.conditionallyexecute.plugins; + +import com.bopke.conditionallyexecute.proto.ExecuteResponse; + +/** + * Result of an Execute RPC call to a single gRPC node. Exposed on + * {@link QuorumError#nodeResults()} so callers can inspect what each node did + * after a failed consensus. + * + * @param address the {@code host:port} the call targeted + * @param success whether the node executed cleanly ({@code executed=true} and empty error) + * @param rpcError the transport-level failure, or {@code null} on success + * @param response the protobuf response, or {@code null} when {@code rpcError != null} + */ +public record NodeResult( + String address, + boolean success, + Throwable rpcError, + ExecuteResponse response) { +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/QuorumError.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/QuorumError.java new file mode 100644 index 0000000..3d0294b --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/QuorumError.java @@ -0,0 +1,55 @@ +package com.bopke.conditionallyexecute.plugins; + +import com.bopke.conditionallyexecute.ConditionallyExecuteError; + +import java.util.List; + +/** + * Thrown by {@link GrpcConsensusPlugin} when the number of successful node + * responses is below the configured quorum. Message format matches the JS + * module: {@code "Quorum not reached: {reached}/{total} nodes succeeded (required {required})"}. + */ +public class QuorumError extends ConditionallyExecuteError { + private static final long serialVersionUID = 1L; + + private final int reached; + private final int required; + private final int total; + private final List nodeResults; + + /** + * @param reached number of nodes that succeeded + * @param required the required quorum + * @param total total nodes contacted + * @param nodeResults per-node detail + */ + public QuorumError(int reached, int required, int total, List nodeResults) { + super(String.format( + "Quorum not reached: %d/%d nodes succeeded (required %d)", + reached, total, required)); + this.reached = reached; + this.required = required; + this.total = total; + this.nodeResults = List.copyOf(nodeResults); + } + + /** @return number of nodes that returned success */ + public int reached() { + return reached; + } + + /** @return the required quorum (minimum successes) */ + public int required() { + return required; + } + + /** @return total nodes the coordinator tried to contact */ + public int total() { + return total; + } + + /** @return immutable per-node result list */ + public List nodeResults() { + return nodeResults; + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/RetryPlugin.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/RetryPlugin.java new file mode 100644 index 0000000..fd74e28 --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/RetryPlugin.java @@ -0,0 +1,155 @@ +package com.bopke.conditionallyexecute.plugins; + +import com.bopke.conditionallyexecute.Handler; +import com.bopke.conditionallyexecute.Middleware; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Retries each handler individually on failure. Wraps every handler in + * {@code ctx.handlers} with retry logic before forwarding the chain. + * + *

Each handler is retried independently up to {@code n} times — so total + * attempts = {@code n + 1}.

+ * + *
{@code
+ * new ConditionallyExecute()
+ *     .use(RetryPlugin.of(3, Backoff.EXPONENTIAL))
+ *     .condition(isHealthy)
+ *     .onTrue(flakyNetworkCall)
+ *     .execute();
+ * }
+ */ +public final class RetryPlugin { + + /** Backoff strategy applied between retries. */ + public enum Backoff { + /** No delay between attempts. */ + NONE, + /** Delay = attempt × 100ms (0, 100, 200, …). */ + LINEAR, + /** Delay = 2^attempt × 100ms (100, 200, 400, …). */ + EXPONENTIAL + } + + private static final ScheduledExecutorService SCHEDULER = + Executors.newScheduledThreadPool(1, r -> { + Thread t = new Thread(r, "conditionally-execute-retry"); + t.setDaemon(true); + return t; + }); + + private RetryPlugin() {} + + /** + * Create a retry middleware with no backoff. + * + * @param n maximum retries (must be {@code >= 0}) + * @return the middleware + * @throws IllegalArgumentException if {@code n < 0} + */ + public static Middleware of(int n) { + return of(n, Backoff.NONE); + } + + /** + * Create a retry middleware. + * + * @param n maximum retries (must be {@code >= 0}) + * @param backoff backoff strategy + * @return the middleware + * @throws IllegalArgumentException if {@code n < 0} or backoff is null + */ + public static Middleware of(int n, Backoff backoff) { + if (n < 0) { + throw new IllegalArgumentException( + "RetryPlugin: n must be a non-negative integer, got " + n); + } + Objects.requireNonNull(backoff, + "RetryPlugin: backoff must be 'none', 'linear', or 'exponential', got null"); + + return (ctx, next) -> { + List wrapped = new ArrayList<>(ctx.handlers().size()); + for (Handler h : ctx.handlers()) { + wrapped.add(wrapWithRetry(h, n, backoff)); + } + ctx.setHandlers(wrapped); + return next.proceed(); + }; + } + + private static Handler wrapWithRetry(Handler fn, int maxRetries, Backoff backoff) { + return () -> attempt(fn, 0, maxRetries, backoff, null); + } + + private static CompletableFuture attempt( + Handler fn, int attempt, int maxRetries, Backoff backoff, Throwable lastError) { + if (attempt > maxRetries) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(lastError); + return failed; + } + + CompletableFuture tryThis; + try { + tryThis = fn.run(); + if (tryThis == null) { + tryThis = CompletableFuture.completedFuture(null); + } + } catch (Throwable t) { + tryThis = new CompletableFuture<>(); + tryThis.completeExceptionally(t); + } + + return tryThis.handle((v, err) -> { + if (err == null) { + return CompletableFuture.completedFuture(null); + } + Throwable cause = unwrap(err); + if (attempt >= maxRetries) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(cause); + return failed; + } + long delayMs = backoffDelay(attempt, backoff); + if (delayMs <= 0) { + return attempt(fn, attempt + 1, maxRetries, backoff, cause); + } + CompletableFuture delayed = new CompletableFuture<>(); + SCHEDULER.schedule(() -> { + attempt(fn, attempt + 1, maxRetries, backoff, cause) + .whenComplete((v2, err2) -> { + if (err2 != null) { + delayed.completeExceptionally(unwrap(err2)); + } else { + delayed.complete(null); + } + }); + }, delayMs, TimeUnit.MILLISECONDS); + return delayed; + }).thenCompose(f -> f); + } + + private static long backoffDelay(int attempt, Backoff backoff) { + return switch (backoff) { + case NONE -> 0L; + case LINEAR -> attempt * 100L; + case EXPONENTIAL -> (1L << attempt) * 100L; + }; + } + + private static Throwable unwrap(Throwable t) { + Throwable cur = t; + while (cur instanceof CompletionException && cur.getCause() != null) { + cur = cur.getCause(); + } + return cur; + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/TimeoutError.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/TimeoutError.java new file mode 100644 index 0000000..2ec3a93 --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/TimeoutError.java @@ -0,0 +1,27 @@ +package com.bopke.conditionallyexecute.plugins; + +import com.bopke.conditionallyexecute.ConditionallyExecuteError; + +/** + * Thrown when {@link TimeoutPlugin} cancels execution because it exceeded the + * configured deadline. Message format matches the JS module: + * {@code "Handler execution timed out after {ms}ms"}. + */ +public class TimeoutError extends ConditionallyExecuteError { + private static final long serialVersionUID = 1L; + + private final long ms; + + /** + * @param ms the timeout in milliseconds that was exceeded + */ + public TimeoutError(long ms) { + super("Handler execution timed out after " + ms + "ms"); + this.ms = ms; + } + + /** @return the timeout (in ms) that was exceeded */ + public long getMs() { + return ms; + } +} diff --git a/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/TimeoutPlugin.java b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/TimeoutPlugin.java new file mode 100644 index 0000000..572aa78 --- /dev/null +++ b/packages/java/src/main/java/com/bopke/conditionallyexecute/plugins/TimeoutPlugin.java @@ -0,0 +1,80 @@ +package com.bopke.conditionallyexecute.plugins; + +import com.bopke.conditionallyexecute.Middleware; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Aborts the downstream chain if it exceeds the configured deadline. Wraps the + * remaining chain in a race against a scheduled timeout; if the timeout fires + * first, a {@link TimeoutError} is thrown. + * + *

Compose before other middleware so the deadline covers everything + * downstream.

+ * + *
{@code
+ * new ConditionallyExecute()
+ *     .use(TimeoutPlugin.of(3000))
+ *     .condition(isReady)
+ *     .onTrue(slowHandler)
+ *     .execute();
+ * }
+ */ +public final class TimeoutPlugin { + + private static final ScheduledExecutorService SCHEDULER = + Executors.newScheduledThreadPool(1, r -> { + Thread t = new Thread(r, "conditionally-execute-timeout"); + t.setDaemon(true); + return t; + }); + + private TimeoutPlugin() {} + + /** + * Create the middleware. + * + * @param ms maximum allowed execution time in milliseconds (must be {@code > 0}) + * @return the middleware + * @throws IllegalArgumentException if {@code ms <= 0} + */ + public static Middleware of(long ms) { + if (ms <= 0) { + throw new IllegalArgumentException( + "TimeoutPlugin: ms must be a positive number, got " + ms); + } + return (ctx, next) -> { + CompletableFuture result = new CompletableFuture<>(); + var task = SCHEDULER.schedule( + () -> result.completeExceptionally(new TimeoutError(ms)), + ms, TimeUnit.MILLISECONDS); + + CompletableFuture downstream; + try { + downstream = next.proceed(); + if (downstream == null) { + downstream = CompletableFuture.completedFuture(null); + } + } catch (Throwable t) { + task.cancel(false); + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(t); + return failed; + } + + downstream.whenComplete((v, err) -> { + task.cancel(false); + if (err != null) { + result.completeExceptionally(err); + } else { + result.complete(null); + } + }); + + return result; + }; + } +} diff --git a/packages/java/src/test/java/com/bopke/conditionallyexecute/CoreTest.java b/packages/java/src/test/java/com/bopke/conditionallyexecute/CoreTest.java new file mode 100644 index 0000000..036a2cb --- /dev/null +++ b/packages/java/src/test/java/com/bopke/conditionallyexecute/CoreTest.java @@ -0,0 +1,954 @@ +package com.bopke.conditionallyexecute; + +import com.bopke.conditionallyexecute.plugins.AuditLogPlugin; +import com.bopke.conditionallyexecute.plugins.CollectErrorsPlugin; +import com.bopke.conditionallyexecute.plugins.DryRunPlugin; +import com.bopke.conditionallyexecute.plugins.RetryPlugin; +import com.bopke.conditionallyexecute.plugins.TimeoutError; +import com.bopke.conditionallyexecute.plugins.TimeoutPlugin; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Mirror of {@code packages/js/test/core.js} — 1:1 mapping where possible. + */ +class CoreTest { + + // --------------------------------------------------------------------- + // Basic functionality + // --------------------------------------------------------------------- + + @Nested + @DisplayName("basic functionality") + class BasicFunctionality { + + @Test + @DisplayName("should execute onFalse when condition is falsy") + void executesOnFalseWhenFalsy() { + AtomicBoolean wasTrue = new AtomicBoolean(false); + AtomicBoolean wasFalse = new AtomicBoolean(false); + + new ConditionallyExecute() + .onTrue(Handler.sync(() -> wasTrue.set(true))) + .onFalse(Handler.sync(() -> wasFalse.set(true))) + .condition(1 == 2) + .execute().join(); + + assertThat(wasFalse).isTrue(); + assertThat(wasTrue).isFalse(); + } + + @Test + @DisplayName("should execute onTrue when condition is truthy") + void executesOnTrueWhenTruthy() { + AtomicBoolean wasTrue = new AtomicBoolean(false); + AtomicBoolean wasFalse = new AtomicBoolean(false); + + new ConditionallyExecute() + .onTrue(Handler.sync(() -> wasTrue.set(true))) + .onFalse(Handler.sync(() -> wasFalse.set(true))) + .condition(1 == 1) + .execute().join(); + + assertThat(wasFalse).isFalse(); + assertThat(wasTrue).isTrue(); + } + + @Test + @DisplayName("should execute onTrue when no condition is set (default true)") + void executesOnTrueByDefault() { + AtomicBoolean wasTrue = new AtomicBoolean(false); + AtomicBoolean wasFalse = new AtomicBoolean(false); + + new ConditionallyExecute() + .onTrue(Handler.sync(() -> wasTrue.set(true))) + .onFalse(Handler.sync(() -> wasFalse.set(true))) + .execute().join(); + + assertThat(wasFalse).isFalse(); + assertThat(wasTrue).isTrue(); + } + } + + // --------------------------------------------------------------------- + // Extended functionality + // --------------------------------------------------------------------- + + @Nested + @DisplayName("extended functionality") + class ExtendedFunctionality { + + @Test + @DisplayName("should execute all onFalse functions when condition is falsy") + void runsAllOnFalse() { + AtomicBoolean wasTrue = new AtomicBoolean(false); + AtomicBoolean wasFalse = new AtomicBoolean(false); + AtomicBoolean wasSecondFalse = new AtomicBoolean(false); + + new ConditionallyExecute() + .onTrue(Handler.sync(() -> wasTrue.set(true))) + .onFalse(Handler.sync(() -> wasFalse.set(true))) + .onFalse(Handler.sync(() -> wasSecondFalse.set(true))) + .condition(1 == 2) + .execute().join(); + + assertThat(wasFalse).isTrue(); + assertThat(wasSecondFalse).isTrue(); + assertThat(wasTrue).isFalse(); + } + + @Test + @DisplayName("should execute all onTrue functions when condition is truthy") + void runsAllOnTrue() { + AtomicBoolean wasTrue = new AtomicBoolean(false); + AtomicBoolean wasSecondTrue = new AtomicBoolean(false); + AtomicBoolean wasFalse = new AtomicBoolean(false); + + new ConditionallyExecute() + .onTrue(Handler.sync(() -> wasTrue.set(true))) + .onTrue(Handler.sync(() -> wasSecondTrue.set(true))) + .onFalse(Handler.sync(() -> wasFalse.set(true))) + .execute().join(); + + assertThat(wasFalse).isFalse(); + assertThat(wasTrue).isTrue(); + assertThat(wasSecondTrue).isTrue(); + } + + @Test + @DisplayName("should execute all onTrue functions when no condition is set") + void runsAllOnTrueByDefault() { + AtomicBoolean wasTrue = new AtomicBoolean(false); + AtomicBoolean wasSecondTrue = new AtomicBoolean(false); + AtomicBoolean wasFalse = new AtomicBoolean(false); + + new ConditionallyExecute() + .onTrue(Handler.sync(() -> wasTrue.set(true))) + .onTrue(Handler.sync(() -> wasSecondTrue.set(true))) + .onFalse(Handler.sync(() -> wasFalse.set(true))) + .execute().join(); + + assertThat(wasFalse).isFalse(); + assertThat(wasTrue).isTrue(); + assertThat(wasSecondTrue).isTrue(); + } + } + + // --------------------------------------------------------------------- + // condition() semantics + // --------------------------------------------------------------------- + + @Nested + @DisplayName("condition() semantics") + class ConditionSemantics { + + @Test + @DisplayName("should coerce non-boolean truthy values to true") + void coercesTruthy() { + AtomicReference branch = new AtomicReference<>(null); + + new ConditionallyExecute() + .condition((Object) "non-empty string") + .onTrue(Handler.sync(() -> branch.set("true"))) + .onFalse(Handler.sync(() -> branch.set("false"))) + .execute().join(); + + assertThat(branch.get()).isEqualTo("true"); + } + + @Test + @DisplayName("should coerce non-boolean falsy values to false") + void coercesFalsy() { + Object[] falsy = { 0, "", null, Double.NaN }; + for (Object f : falsy) { + AtomicReference branch = new AtomicReference<>(null); + new ConditionallyExecute() + .condition(f) + .onTrue(Handler.sync(() -> branch.set("true"))) + .onFalse(Handler.sync(() -> branch.set("false"))) + .execute().join(); + assertThat(branch.get()) + .as("Expected false for condition(%s)", String.valueOf(f)) + .isEqualTo("false"); + } + } + + @Test + @DisplayName("should use last condition() call when called multiple times") + void lastCallWins() { + AtomicReference branch = new AtomicReference<>(null); + + new ConditionallyExecute() + .condition(false) + .condition(true) + .onTrue(Handler.sync(() -> branch.set("true"))) + .onFalse(Handler.sync(() -> branch.set("false"))) + .execute().join(); + + assertThat(branch.get()).isEqualTo("true"); + } + } + + // --------------------------------------------------------------------- + // Async handlers + // --------------------------------------------------------------------- + + @Nested + @DisplayName("async handlers") + class AsyncHandlers { + + @Test + @DisplayName("should await async onTrue handlers") + void awaitsAsyncOnTrue() { + AtomicBoolean result = new AtomicBoolean(false); + + new ConditionallyExecute() + .condition(true) + .onTrue(() -> CompletableFuture.runAsync(() -> { + sleep(10); + result.set(true); + })) + .execute().join(); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("should await async onFalse handlers") + void awaitsAsyncOnFalse() { + AtomicBoolean result = new AtomicBoolean(false); + + new ConditionallyExecute() + .condition(false) + .onFalse(() -> CompletableFuture.runAsync(() -> { + sleep(10); + result.set(true); + })) + .execute().join(); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("should run multiple async handlers concurrently") + void runsAsyncConcurrently() { + List order = Collections.synchronizedList(new ArrayList<>()); + + new ConditionallyExecute() + .condition(true) + .onTrue(() -> CompletableFuture.runAsync(() -> { + sleep(20); + order.add("slow"); + })) + .onTrue(() -> CompletableFuture.runAsync(() -> { + sleep(5); + order.add("fast"); + })) + .execute().join(); + + assertThat(order).hasSize(2); + assertThat(order).contains("slow", "fast"); + } + + @Test + @DisplayName("should propagate rejections from async handlers") + void propagatesRejections() { + assertThatThrownBy(() -> + new ConditionallyExecute() + .condition(true) + .onTrue(() -> CompletableFuture.failedFuture(new RuntimeException("boom"))) + .execute().join()) + .hasMessageContaining("boom"); + } + } + + // --------------------------------------------------------------------- + // executeSync() + // --------------------------------------------------------------------- + + @Nested + @DisplayName("executeSync()") + class ExecuteSync { + + @Test + @DisplayName("should execute onTrue synchronously when condition is true") + void runsOnTrueSync() { + AtomicBoolean called = new AtomicBoolean(false); + new ConditionallyExecute() + .condition(true) + .onTrue(Handler.sync(() -> called.set(true))) + .executeSync(); + assertThat(called).isTrue(); + } + + @Test + @DisplayName("should execute onFalse synchronously when condition is false") + void runsOnFalseSync() { + AtomicBoolean called = new AtomicBoolean(false); + new ConditionallyExecute() + .condition(false) + .onFalse(Handler.sync(() -> called.set(true))) + .executeSync(); + assertThat(called).isTrue(); + } + + @Test + @DisplayName("should not execute onFalse when condition is true (sync)") + void skipsOtherBranchSync() { + AtomicBoolean called = new AtomicBoolean(false); + new ConditionallyExecute() + .condition(true) + .onTrue(Handler.sync(() -> {})) + .onFalse(Handler.sync(() -> called.set(true))) + .executeSync(); + assertThat(called).isFalse(); + } + + @Test + @DisplayName("should execute multiple handlers in registration order (sync)") + void runsHandlersInOrder() { + List order = new ArrayList<>(); + new ConditionallyExecute() + .condition(true) + .onTrue(Handler.sync(() -> order.add(1))) + .onTrue(Handler.sync(() -> order.add(2))) + .onTrue(Handler.sync(() -> order.add(3))) + .executeSync(); + assertThat(order).containsExactly(1, 2, 3); + } + } + + // --------------------------------------------------------------------- + // Input validation + // --------------------------------------------------------------------- + + @Nested + @DisplayName("input validation") + class InputValidation { + + @Test + @DisplayName("should throw when onTrue receives null") + void rejectsNullOnTrue() { + assertThatThrownBy(() -> new ConditionallyExecute().onTrue((Handler) null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("onTrue"); + } + + @Test + @DisplayName("should throw when onFalse receives null") + void rejectsNullOnFalse() { + assertThatThrownBy(() -> new ConditionallyExecute().onFalse((Handler) null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("onFalse"); + } + + @Test + @DisplayName("should throw for null Runnable passed to onTrue") + void rejectsNullRunnableOnTrue() { + assertThatThrownBy(() -> new ConditionallyExecute().onTrue((Runnable) null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("onTrue"); + } + + @Test + @DisplayName("should throw when use() receives null") + void rejectsNullMiddleware() { + assertThatThrownBy(() -> new ConditionallyExecute().use(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("use"); + } + + @Test + @DisplayName("should throw when onError() receives null") + void rejectsNullOnError() { + assertThatThrownBy(() -> new ConditionallyExecute().onError(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("onError"); + } + } + + // --------------------------------------------------------------------- + // TimeoutPlugin + // --------------------------------------------------------------------- + + @Nested + @DisplayName("TimeoutPlugin") + class Timeout { + + @Test + @DisplayName("should throw TimeoutError when handler exceeds timeout") + void throwsOnTimeout() { + assertThatThrownBy(() -> + new ConditionallyExecute() + .use(TimeoutPlugin.of(50)) + .condition(true) + .onTrue(() -> { + CompletableFuture slow = new CompletableFuture<>(); + CompletableFuture.delayedExecutor(200, java.util.concurrent.TimeUnit.MILLISECONDS) + .execute(() -> slow.complete(null)); + return slow; + }) + .execute().join()) + .hasCauseInstanceOf(TimeoutError.class) + .hasMessageContaining("50ms"); + } + + @Test + @DisplayName("should not throw when handler completes within timeout") + void noThrowWhenWithinTimeout() { + AtomicBoolean ran = new AtomicBoolean(false); + new ConditionallyExecute() + .use(TimeoutPlugin.of(500)) + .condition(true) + .onTrue(() -> CompletableFuture.runAsync(() -> { + sleep(10); + ran.set(true); + })) + .execute().join(); + assertThat(ran).isTrue(); + } + + @Test + @DisplayName("should throw IllegalArgumentException for invalid ms argument") + void rejectsInvalidMs() { + assertThatThrownBy(() -> TimeoutPlugin.of(0)) + .hasMessageContaining("positive number"); + assertThatThrownBy(() -> TimeoutPlugin.of(-1)) + .hasMessageContaining("positive number"); + } + } + + // --------------------------------------------------------------------- + // RetryPlugin + // --------------------------------------------------------------------- + + @Nested + @DisplayName("RetryPlugin") + class Retry { + + @Test + @DisplayName("should retry failing handlers up to n times") + void retriesUpToN() { + AtomicInteger attempts = new AtomicInteger(0); + new ConditionallyExecute() + .use(RetryPlugin.of(2)) + .condition(true) + .onTrue(() -> { + int a = attempts.incrementAndGet(); + if (a < 3) { + return CompletableFuture.failedFuture(new RuntimeException("transient failure")); + } + return CompletableFuture.completedFuture(null); + }) + .execute().join(); + assertThat(attempts.get()).isEqualTo(3); + } + + @Test + @DisplayName("should throw after exhausting retries") + void throwsAfterExhausting() { + AtomicInteger attempts = new AtomicInteger(0); + assertThatThrownBy(() -> + new ConditionallyExecute() + .use(RetryPlugin.of(1)) + .condition(true) + .onTrue(() -> { + attempts.incrementAndGet(); + return CompletableFuture.failedFuture(new RuntimeException("always fails")); + }) + .execute().join()) + .hasMessageContaining("always fails"); + assertThat(attempts.get()).isEqualTo(2); + } + + @Test + @DisplayName("should not retry when n is 0") + void noRetriesWhenZero() { + AtomicInteger attempts = new AtomicInteger(0); + assertThatThrownBy(() -> + new ConditionallyExecute() + .use(RetryPlugin.of(0)) + .condition(true) + .onTrue(() -> { + attempts.incrementAndGet(); + return CompletableFuture.failedFuture(new RuntimeException("fail")); + }) + .execute().join()) + .hasMessageContaining("fail"); + assertThat(attempts.get()).isEqualTo(1); + } + + @Test + @DisplayName("should apply exponential backoff between retries") + void exponentialBackoff() { + AtomicInteger attempts = new AtomicInteger(0); + List times = new CopyOnWriteArrayList<>(); + new ConditionallyExecute() + .use(RetryPlugin.of(2, RetryPlugin.Backoff.EXPONENTIAL)) + .condition(true) + .onTrue(() -> { + times.add(System.currentTimeMillis()); + int a = attempts.incrementAndGet(); + if (a < 3) { + return CompletableFuture.failedFuture(new RuntimeException("transient")); + } + return CompletableFuture.completedFuture(null); + }) + .execute().join(); + assertThat(attempts.get()).isEqualTo(3); + assertThat(times.get(1) - times.get(0)).isGreaterThanOrEqualTo(90L); + assertThat(times.get(2) - times.get(1)).isGreaterThanOrEqualTo(180L); + } + + @Test + @DisplayName("should apply linear backoff between retries") + void linearBackoff() { + AtomicInteger attempts = new AtomicInteger(0); + List times = new CopyOnWriteArrayList<>(); + new ConditionallyExecute() + .use(RetryPlugin.of(2, RetryPlugin.Backoff.LINEAR)) + .condition(true) + .onTrue(() -> { + times.add(System.currentTimeMillis()); + int a = attempts.incrementAndGet(); + if (a < 3) { + return CompletableFuture.failedFuture(new RuntimeException("transient")); + } + return CompletableFuture.completedFuture(null); + }) + .execute().join(); + assertThat(attempts.get()).isEqualTo(3); + // linear: attempt 0→1: 0ms, attempt 1→2: 100ms + assertThat(times.get(2) - times.get(1)).isGreaterThanOrEqualTo(90L); + } + + @Test + @DisplayName("should throw for invalid arguments") + void rejectsInvalid() { + assertThatThrownBy(() -> RetryPlugin.of(-1)) + .hasMessageContaining("non-negative integer"); + assertThatThrownBy(() -> RetryPlugin.of(1, null)) + .hasMessageContaining("backoff"); + } + } + + // --------------------------------------------------------------------- + // DryRunPlugin + // --------------------------------------------------------------------- + + @Nested + @DisplayName("DryRunPlugin") + class DryRun { + + @Test + @DisplayName("should not execute handlers") + void skipsHandlers() { + AtomicBoolean called = new AtomicBoolean(false); + new ConditionallyExecute() + .use(DryRunPlugin.of(msg -> {})) + .condition(true) + .onTrue(Handler.sync(() -> called.set(true))) + .execute().join(); + assertThat(called).isFalse(); + } + + @Test + @DisplayName("should log what would have run") + void logsBranchAndCount() { + List logs = new ArrayList<>(); + new ConditionallyExecute() + .use(DryRunPlugin.of(logs::add)) + .condition(false) + .onFalse(Handler.sync(() -> {})) + .onFalse(Handler.sync(() -> {})) + .execute().join(); + assertThat(logs).hasSize(1); + assertThat(logs.get(0)).contains("DryRun"); + assertThat(logs.get(0)).contains("2"); + assertThat(logs.get(0)).contains("onFalse"); + } + } + + // --------------------------------------------------------------------- + // onError() + // --------------------------------------------------------------------- + + @Nested + @DisplayName("onError()") + class OnError { + + @Test + @DisplayName("should call onError instead of throwing when handler fails") + void routesToOnError() { + AtomicReference caught = new AtomicReference<>(null); + new ConditionallyExecute() + .condition(true) + .onTrue(() -> CompletableFuture.failedFuture(new RuntimeException("handler blew up"))) + .onError(caught::set) + .execute().join(); + assertThat(caught.get()).isNotNull(); + assertThat(caught.get().getMessage()).contains("handler blew up"); + } + + @Test + @DisplayName("ConditionallyExecuteError should have correct name property") + void errorClassMeta() { + ConditionallyExecuteError err = new ConditionallyExecuteError("test"); + assertThat(err.getMessage()).isEqualTo("test"); + assertThat(err).isInstanceOf(RuntimeException.class); + assertThat(err.getClass().getSimpleName()).isEqualTo("ConditionallyExecuteError"); + } + + @Test + @DisplayName("should call onError with TimeoutError on timeout") + void routesTimeoutToOnError() { + AtomicReference caught = new AtomicReference<>(null); + new ConditionallyExecute() + .use(TimeoutPlugin.of(30)) + .condition(true) + .onTrue(() -> { + CompletableFuture slow = new CompletableFuture<>(); + CompletableFuture.delayedExecutor(200, java.util.concurrent.TimeUnit.MILLISECONDS) + .execute(() -> slow.complete(null)); + return slow; + }) + .onError(caught::set) + .execute().join(); + assertThat(caught.get()).isInstanceOf(TimeoutError.class); + } + } + + // --------------------------------------------------------------------- + // Middleware + // --------------------------------------------------------------------- + + @Nested + @DisplayName("middleware (.use())") + class MiddlewareTests { + + @Test + @DisplayName("should call middleware before handler execution") + void runsBeforeAndAfter() { + List log = Collections.synchronizedList(new ArrayList<>()); + + new ConditionallyExecute() + .use((ctx, next) -> { + log.add("before"); + return next.proceed().thenRun(() -> log.add("after")); + }) + .condition(true) + .onTrue(Handler.sync(() -> log.add("handler"))) + .execute().join(); + + assertThat(log).containsExactly("before", "handler", "after"); + } + + @Test + @DisplayName("should expose correct branch in ctx") + void exposesBranchInCtx() { + AtomicReference captured = new AtomicReference<>(); + new ConditionallyExecute() + .use((ctx, next) -> { + captured.set(ctx); + return next.proceed(); + }) + .condition(false) + .onTrue(Handler.sync(() -> {})) + .onFalse(Handler.sync(() -> {})) + .execute().join(); + assertThat(captured.get().branch()).isEqualTo(Branch.ON_FALSE); + assertThat(captured.get().condition()).isFalse(); + } + + @Test + @DisplayName("should allow middleware to override condition") + void allowsConditionOverride() { + AtomicReference branch = new AtomicReference<>(null); + + Middleware overrideToFalse = (ctx, next) -> { + ctx.setCondition(false); + ctx.setBranch(Branch.ON_FALSE); + ctx.setHandlers(new ArrayList<>(ctx.onFalseHandlers())); + return next.proceed(); + }; + + new ConditionallyExecute() + .use(overrideToFalse) + .condition(true) + .onTrue(Handler.sync(() -> branch.set("true"))) + .onFalse(Handler.sync(() -> branch.set("false"))) + .execute().join(); + + assertThat(branch.get()).isEqualTo("false"); + } + + @Test + @DisplayName("should compose multiple middlewares in order") + void composesMiddlewares() { + List log = Collections.synchronizedList(new ArrayList<>()); + + new ConditionallyExecute() + .use((ctx, next) -> { + log.add("mw1-in"); + return next.proceed().thenRun(() -> log.add("mw1-out")); + }) + .use((ctx, next) -> { + log.add("mw2-in"); + return next.proceed().thenRun(() -> log.add("mw2-out")); + }) + .condition(true) + .onTrue(Handler.sync(() -> log.add("handler"))) + .execute().join(); + + assertThat(log).containsExactly("mw1-in", "mw2-in", "handler", "mw2-out", "mw1-out"); + } + } + + // --------------------------------------------------------------------- + // AuditLogPlugin + // --------------------------------------------------------------------- + + @Nested + @DisplayName("AuditLogPlugin") + class AuditLog { + + @Test + @DisplayName("should log to the provided logger after execution") + void logsToCustomLogger() { + List logs = new ArrayList<>(); + + new ConditionallyExecute() + .use(AuditLogPlugin.of(logs::add)) + .condition(true) + .onTrue(Handler.sync(() -> {})) + .execute().join(); + + assertThat(logs).hasSize(1); + assertThat(logs.get(0)).contains("ConditionallyExecute"); + assertThat(logs.get(0)).contains("condition=true"); + assertThat(logs.get(0)).contains("branch=onTrue"); + assertThat(logs.get(0)).contains("handlers=1"); + assertThat(logs.get(0)).contains("duration="); + } + + @Test + @DisplayName("should not log when plugin is not used") + void silentByDefault() { + List logs = new ArrayList<>(); + // No plugin → no log call possible; verify by asserting logs stays empty + new ConditionallyExecute() + .condition(true) + .onTrue(Handler.sync(() -> {})) + .execute().join(); + assertThat(logs).isEmpty(); + } + } + + // --------------------------------------------------------------------- + // Named condition registry + // --------------------------------------------------------------------- + + @Nested + @DisplayName("named condition registry") + class Registry { + + @AfterEach + void clearRegistry() { + ConditionallyExecute.clearRegistry(); + } + + @Test + @DisplayName("should evaluate a registered condition by name") + void registeredCondition() { + ConditionallyExecute.register("alwaysTrue", () -> true); + + AtomicReference branch = new AtomicReference<>(null); + new ConditionallyExecute() + .condition("alwaysTrue") + .onTrue(Handler.sync(() -> branch.set("true"))) + .onFalse(Handler.sync(() -> branch.set("false"))) + .execute().join(); + + assertThat(branch.get()).isEqualTo("true"); + } + + @Test + @DisplayName("should NOT use registry for non-string condition values") + void boolNotLookedUpInRegistry() { + ConditionallyExecute.register("false", () -> true); + AtomicReference branch = new AtomicReference<>(null); + new ConditionallyExecute() + .condition(false) + .onTrue(Handler.sync(() -> branch.set("true"))) + .onFalse(Handler.sync(() -> branch.set("false"))) + .execute().join(); + assertThat(branch.get()).isEqualTo("false"); + } + + @Test + @DisplayName("should support dynamic registered conditions") + void dynamicCondition() { + AtomicBoolean value = new AtomicBoolean(false); + ConditionallyExecute.register("dynamic", value::get); + + AtomicReference branch = new AtomicReference<>(null); + + new ConditionallyExecute() + .condition("dynamic") + .onTrue(Handler.sync(() -> branch.set("true"))) + .onFalse(Handler.sync(() -> branch.set("false"))) + .execute().join(); + assertThat(branch.get()).isEqualTo("false"); + + value.set(true); + new ConditionallyExecute() + .condition("dynamic") + .onTrue(Handler.sync(() -> branch.set("true"))) + .onFalse(Handler.sync(() -> branch.set("false"))) + .execute().join(); + assertThat(branch.get()).isEqualTo("true"); + } + + @Test + @DisplayName("should clear registry via clearRegistry()") + void clearsRegistry() { + ConditionallyExecute.register("myCondition", () -> false); + ConditionallyExecute.clearRegistry(); + + AtomicReference branch = new AtomicReference<>(null); + new ConditionallyExecute() + .condition("myCondition") // missing → coerces non-empty string to true + .onTrue(Handler.sync(() -> branch.set("true"))) + .onFalse(Handler.sync(() -> branch.set("false"))) + .execute().join(); + assertThat(branch.get()).isEqualTo("true"); + } + + @Test + @DisplayName("should remove a single entry via unregister()") + void unregistersSingle() { + ConditionallyExecute.register("gone", () -> false); + ConditionallyExecute.register("stays", () -> false); + ConditionallyExecute.unregister("gone"); + + AtomicReference branch = new AtomicReference<>(null); + new ConditionallyExecute() + .condition("gone") + .onTrue(Handler.sync(() -> branch.set("true"))) + .onFalse(Handler.sync(() -> branch.set("false"))) + .execute().join(); + assertThat(branch.get()).isEqualTo("true"); + + new ConditionallyExecute() + .condition("stays") + .onTrue(Handler.sync(() -> branch.set("true"))) + .onFalse(Handler.sync(() -> branch.set("false"))) + .execute().join(); + assertThat(branch.get()).isEqualTo("false"); + } + + @Test + @DisplayName("should throw for invalid register() arguments with useful messages") + void rejectsInvalidRegister() { + assertThatThrownBy(() -> ConditionallyExecute.register(null, () -> true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("string"); + assertThatThrownBy(() -> ConditionallyExecute.register("name", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("function"); + } + } + + // --------------------------------------------------------------------- + // CollectErrorsPlugin + // --------------------------------------------------------------------- + + @Nested + @DisplayName("CollectErrorsPlugin") + class CollectErrors { + + @Test + @DisplayName("should collect all handler errors into AggregateException") + void collectsAllErrors() { + assertThatThrownBy(() -> + new ConditionallyExecute() + .use(CollectErrorsPlugin.of()) + .condition(true) + .onTrue(() -> CompletableFuture.failedFuture(new RuntimeException("err1"))) + .onTrue(() -> CompletableFuture.failedFuture(new RuntimeException("err2"))) + .execute().join()) + .satisfies(thrown -> { + Throwable cause = thrown; + while (cause instanceof CompletionException && cause.getCause() != null) { + cause = cause.getCause(); + } + assertThat(cause).isInstanceOf(AggregateException.class); + AggregateException ae = (AggregateException) cause; + assertThat(ae.errors()).hasSize(2); + assertThat(ae.getMessage()).contains("2 handler"); + List messages = new ArrayList<>(); + for (Throwable e : ae.errors()) messages.add(e.getMessage()); + assertThat(messages).contains("err1", "err2"); + }); + } + + @Test + @DisplayName("should not throw when all handlers succeed") + void noThrowOnAllSuccess() { + AtomicInteger count = new AtomicInteger(0); + new ConditionallyExecute() + .use(CollectErrorsPlugin.of()) + .condition(true) + .onTrue(Handler.sync(count::incrementAndGet)) + .onTrue(Handler.sync(count::incrementAndGet)) + .execute().join(); + assertThat(count.get()).isEqualTo(2); + } + + @Test + @DisplayName("should not include successful handlers in error list") + void onlyFailedAreCollected() { + assertThatThrownBy(() -> + new ConditionallyExecute() + .use(CollectErrorsPlugin.of()) + .condition(true) + .onTrue(Handler.sync(() -> { /* succeeds */ })) + .onTrue(() -> CompletableFuture.failedFuture(new RuntimeException("only-this-fails"))) + .execute().join()) + .satisfies(thrown -> { + Throwable cause = thrown; + while (cause instanceof CompletionException && cause.getCause() != null) { + cause = cause.getCause(); + } + assertThat(cause).isInstanceOf(AggregateException.class); + AggregateException ae = (AggregateException) cause; + assertThat(ae.errors()).hasSize(1); + assertThat(ae.errors().get(0).getMessage()).contains("only-this-fails"); + }); + } + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/packages/java/src/test/java/com/bopke/conditionallyexecute/GrpcConsensusTest.java b/packages/java/src/test/java/com/bopke/conditionallyexecute/GrpcConsensusTest.java new file mode 100644 index 0000000..783b3e4 --- /dev/null +++ b/packages/java/src/test/java/com/bopke/conditionallyexecute/GrpcConsensusTest.java @@ -0,0 +1,216 @@ +package com.bopke.conditionallyexecute; + +import com.bopke.conditionallyexecute.plugins.GrpcConsensusPlugin; +import com.bopke.conditionallyexecute.plugins.GrpcNodeServer; +import com.bopke.conditionallyexecute.plugins.QuorumError; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.TestInstance; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Mirror of {@code packages/js/test/grpc.js}. + * + *

Assumes ports 52100-52102 (running nodes) and 59997-59999 (unreachable + * targets) are free on the test machine.

+ */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Timeout(value = 15, unit = TimeUnit.SECONDS) +class GrpcConsensusTest { + + private static final int[] PORTS = { 52100, 52101, 52102 }; + + private final List nodes = new ArrayList<>(); + + @BeforeAll + void startNodes() { + List> starts = new ArrayList<>(); + for (int i = 0; i < PORTS.length; i++) { + final int idx = i; + Map handlerMap = Map.of( + "deploy", Handler.sync(() -> { /* handler runs on node */ }), + "failing", Handler.sync(() -> { throw new RuntimeException("node " + idx + " handler failed"); }) + ); + starts.add(GrpcNodeServer.startAsync(PORTS[i], handlerMap)); + } + for (CompletableFuture f : starts) { + nodes.add(f.join()); + } + } + + @AfterAll + void stopNodes() { + List> stops = new ArrayList<>(); + for (GrpcNodeServer n : nodes) { + stops.add(n.close()); + } + for (CompletableFuture f : stops) { + try { + f.join(); + } catch (Exception ignored) { } + } + } + + private static List addresses() { + List a = new ArrayList<>(); + for (int p : PORTS) a.add("localhost:" + p); + return a; + } + + @Test + @DisplayName("should execute handler on all nodes and pass quorum") + void allNodesPassQuorum() { + AtomicBoolean coordinatorRan = new AtomicBoolean(false); + + new ConditionallyExecute() + .use(GrpcConsensusPlugin.of(GrpcConsensusPlugin.options() + .nodes(addresses()) + .handlerName("deploy") + .quorum(2) + .build())) + .condition(true) + .onTrue(Handler.sync(() -> coordinatorRan.set(true))) + .execute().join(); + + assertThat(coordinatorRan).isTrue(); + } + + @Test + @DisplayName("should skip coordinator handler when condition is false") + void skipsCoordinatorOnFalseCondition() { + AtomicBoolean coordinatorRan = new AtomicBoolean(false); + + try { + new ConditionallyExecute() + .use(GrpcConsensusPlugin.of(GrpcConsensusPlugin.options() + .nodes(addresses()) + .handlerName("deploy") + .quorum(1) + .build())) + .condition(false) + .onTrue(Handler.sync(() -> coordinatorRan.set(true))) + .execute().join(); + } catch (Exception expected) { + // quorum not reached when nodes report executed=false for condition=false + } + + assertThat(coordinatorRan).isFalse(); + } + + @Test + @DisplayName("should throw QuorumError when quorum is not reached") + void quorumNotReachedThrows() { + assertThatThrownBy(() -> + new ConditionallyExecute() + .use(GrpcConsensusPlugin.of(GrpcConsensusPlugin.options() + .nodes(List.of("localhost:59998", "localhost:59999")) + .handlerName("deploy") + .quorum(1) + .timeout(500) + .build())) + .condition(true) + .onTrue(Handler.sync(() -> {})) + .execute().join()) + .satisfies(thrown -> { + Throwable cause = thrown; + while (cause instanceof CompletionException && cause.getCause() != null) { + cause = cause.getCause(); + } + assertThat(cause).isInstanceOf(QuorumError.class); + QuorumError qe = (QuorumError) cause; + assertThat(qe.reached()).isEqualTo(0); + assertThat(qe.required()).isEqualTo(1); + }); + } + + @Test + @DisplayName("should expose node results on QuorumError") + void quorumErrorHasNodeResults() { + AtomicReference caught = new AtomicReference<>(null); + + new ConditionallyExecute() + .use(GrpcConsensusPlugin.of(GrpcConsensusPlugin.options() + .nodes(List.of("localhost:59997")) + .handlerName("deploy") + .quorum(1) + .timeout(300) + .build())) + .condition(true) + .onTrue(Handler.sync(() -> {})) + .onError(caught::set) + .execute().join(); + + assertThat(caught.get()).isInstanceOf(QuorumError.class); + QuorumError qe = (QuorumError) caught.get(); + assertThat(qe.nodeResults()).hasSize(1); + assertThat(qe.nodeResults().get(0).address()).isEqualTo("localhost:59997"); + } + + @Test + @DisplayName("should compose with other middleware") + void composesWithOtherMiddleware() { + List log = Collections.synchronizedList(new ArrayList<>()); + + new ConditionallyExecute() + .use((ctx, next) -> { + log.add("outer-in"); + return next.proceed().thenRun(() -> log.add("outer-out")); + }) + .use(GrpcConsensusPlugin.of(GrpcConsensusPlugin.options() + .nodes(List.of("localhost:" + PORTS[0], "localhost:" + PORTS[1])) + .handlerName("deploy") + .quorum(1) + .build())) + .use((ctx, next) -> { + log.add("inner-in"); + return next.proceed().thenRun(() -> log.add("inner-out")); + }) + .condition(true) + .onTrue(Handler.sync(() -> log.add("coordinator"))) + .execute().join(); + + assertThat(log).containsExactly("outer-in", "inner-in", "coordinator", "inner-out", "outer-out"); + } + + @Test + @DisplayName("should throw on invalid options") + void rejectsInvalidOptions() { + assertThatThrownBy(() -> + GrpcConsensusPlugin.of(GrpcConsensusPlugin.options() + .nodes(List.of()) + .handlerName("x") + .build())) + .hasMessageContaining("non-empty"); + + assertThatThrownBy(() -> + GrpcConsensusPlugin.of(GrpcConsensusPlugin.options() + .nodes(List.of("a")) + .handlerName("") + .build())) + .hasMessageContaining("handlerName"); + + assertThatThrownBy(() -> + GrpcConsensusPlugin.of(GrpcConsensusPlugin.options() + .nodes(List.of("a")) + .handlerName("x") + .quorum(5) + .build())) + .hasMessageContaining("cannot exceed"); + } +} diff --git a/packages/java/src/test/java/com/bopke/conditionallyexecute/MultiThreadedTest.java b/packages/java/src/test/java/com/bopke/conditionallyexecute/MultiThreadedTest.java new file mode 100644 index 0000000..414dcf6 --- /dev/null +++ b/packages/java/src/test/java/com/bopke/conditionallyexecute/MultiThreadedTest.java @@ -0,0 +1,134 @@ +package com.bopke.conditionallyexecute; + +import com.bopke.conditionallyexecute.plugins.MultiThreadedPlugin; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Mirror of {@code packages/js/test/multi-threaded.js}. + */ +@Timeout(value = 10, unit = TimeUnit.SECONDS) +class MultiThreadedTest { + + @Test + @DisplayName("should execute onTrue when majority votes true") + void onTrueOnMajorityTrue() { + AtomicReference branch = new AtomicReference<>(null); + + new ConditionallyExecute() + .use(MultiThreadedPlugin.of(MultiThreadedPlugin.options().nodes(3).build())) + .condition(true) + .onTrue(Handler.sync(() -> branch.set("true"))) + .onFalse(Handler.sync(() -> branch.set("false"))) + .execute().join(); + + assertThat(branch.get()).isEqualTo("true"); + } + + @Test + @DisplayName("should execute onFalse when majority votes false") + void onFalseOnMajorityFalse() { + AtomicReference branch = new AtomicReference<>(null); + + new ConditionallyExecute() + .use(MultiThreadedPlugin.of(MultiThreadedPlugin.options().nodes(3).build())) + .condition(false) + .onTrue(Handler.sync(() -> branch.set("true"))) + .onFalse(Handler.sync(() -> branch.set("false"))) + .execute().join(); + + assertThat(branch.get()).isEqualTo("false"); + } + + @Test + @DisplayName("should work with 5 nodes") + void fiveNodes() { + AtomicBoolean called = new AtomicBoolean(false); + + new ConditionallyExecute() + .use(MultiThreadedPlugin.of(MultiThreadedPlugin.options().nodes(5).build())) + .condition(true) + .onTrue(Handler.sync(() -> called.set(true))) + .execute().join(); + + assertThat(called).isTrue(); + } + + @Test + @DisplayName("should work with jitter enabled (chaos mode)") + void jitterMode() { + AtomicBoolean called = new AtomicBoolean(false); + + new ConditionallyExecute() + .use(MultiThreadedPlugin.of( + MultiThreadedPlugin.options().nodes(3).jitter(true).build())) + .condition(true) + .onTrue(Handler.sync(() -> called.set(true))) + .execute().join(); + + assertThat(called).isTrue(); + } + + @Test + @DisplayName("should compose with other middleware") + void composesWithOtherMiddleware() { + List log = Collections.synchronizedList(new ArrayList<>()); + + new ConditionallyExecute() + .use((ctx, next) -> { + log.add("outer-in"); + return next.proceed().thenRun(() -> log.add("outer-out")); + }) + .use(MultiThreadedPlugin.of(MultiThreadedPlugin.options().nodes(3).build())) + .use((ctx, next) -> { + log.add("inner-in"); + return next.proceed().thenRun(() -> log.add("inner-out")); + }) + .condition(true) + .onTrue(Handler.sync(() -> log.add("handler"))) + .execute().join(); + + assertThat(log).containsExactly("outer-in", "inner-in", "handler", "inner-out", "outer-out"); + } + + @Test + @DisplayName("should throw on even node count") + void rejectsEvenNodes() { + assertThatThrownBy(() -> + MultiThreadedPlugin.of(MultiThreadedPlugin.options().nodes(4).build())) + .hasMessageContaining("odd"); + } + + @Test + @DisplayName("should throw on node count < 3") + void rejectsTooFewNodes() { + assertThatThrownBy(() -> + MultiThreadedPlugin.of(MultiThreadedPlugin.options().nodes(1).build())) + .hasMessageContaining("≥ 3"); + } + + @Test + @DisplayName("should timeout when workers are too slow") + void timesOut() { + assertThatThrownBy(() -> + new ConditionallyExecute() + .use(MultiThreadedPlugin.of( + MultiThreadedPlugin.options().nodes(3).timeout(1).jitter(true).build())) + .condition(true) + .onTrue(Handler.sync(() -> {})) + .execute().join()) + .hasMessageContaining("timed out"); + } +} diff --git a/packages/js/.prettierrc b/packages/js/.prettierrc new file mode 100644 index 0000000..0772557 --- /dev/null +++ b/packages/js/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/packages/js/README.md b/packages/js/README.md new file mode 100644 index 0000000..01e7860 --- /dev/null +++ b/packages/js/README.md @@ -0,0 +1,287 @@ +
+ +# conditionally-execute + +**Enterprise-grade if-statement replacement** + +[![Node.js CI](https://github.com/bopke/conditionally-execute/actions/workflows/nodejs.yml/badge.svg)](https://github.com/bopke/conditionally-execute/actions/workflows/nodejs.yml) +[![npm version](https://img.shields.io/npm/v/conditionally-execute?color=crimson)](https://www.npmjs.com/package/conditionally-execute) +[![npm downloads](https://img.shields.io/npm/dm/conditionally-execute)](https://www.npmjs.com/package/conditionally-execute) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue?logo=typescript)](tsconfig.json) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://prettier.io) +[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) +[![Node.js >= 18](https://img.shields.io/node/v/conditionally-execute)](package.json) + +> Lets you abandon `if` keyword + +
+ +--- + +## Table of Contents + +- [Why?](#why) +- [Install](#install) +- [Quick start](#quick-start) +- [API](#api) + - [`new ConditionallyExecute(options?)`](#new-conditionallyexecuteoptions) + - [`.condition(value)`](#conditionvalue--this) + - [`.onTrue(fn)`](#ontruefn--this) + - [`.onFalse(fn)`](#onfalsefn--this) + - [`.execute()`](#execute--promisevoid) +- [Advanced usage](#advanced-usage) + - [Async handlers](#async-handlers) + - [Multiple handlers](#multiple-handlers) + - [Default condition](#default-condition) + - [Collecting errors](#collecting-errors) +- [Performance](#performance) +- [`executeSync()`](#executesync--void) +- [TypeScript](#typescript) +- [Contributing](#contributing) +- [License](#license) + +--- + +## Why? + +Because sometimes `if (condition) { ... } else { ... }` is just too readable +and you want your code to look more like a well-considered fluent API. + +No, really — fluent conditional execution is useful when you need to: +- Register multiple callbacks per branch +- Mix sync and async handlers transparently +- Pass condition evaluation and handler registration through separate pipeline stages + +--- + +## Install + +```bash +npm install conditionally-execute +``` + +**Requirements**: Node.js ≥ 18.0.0 + +--- + +## Quick start + +```javascript +const ConditionallyExecute = require('conditionally-execute'); + +await new ConditionallyExecute() + .condition(user.isAdmin) + .onTrue(() => grantAccess()) + .onFalse(() => denyAccess()) + .execute(); +``` + +--- + +## API + +### `new ConditionallyExecute(options?)` + +Creates a new instance. Optionally accepts a configuration object. + +```typescript +interface ConditionallyExecuteOptions { + initialCondition?: boolean; // default: true + collectErrors?: boolean; // default: false +} +``` + +### `.condition(value)` → `this` + +Sets the condition. Any value is accepted and coerced to `boolean` via `Boolean()`. +**Last call wins** — calling this multiple times overwrites the previous value. + +```javascript +.condition(1 === 1) // true +.condition(user.isAdmin) // boolean +.condition('non-empty') // truthy → true +.condition(0) // falsy → false +``` + +### `.onTrue(fn)` → `this` + +Registers a handler for the truthy branch. Throws `TypeError` if `fn` is not a function. + +### `.onFalse(fn)` → `this` + +Registers a handler for the falsy branch. Throws `TypeError` if `fn` is not a function. + +### `.execute()` → `Promise` + +Executes all handlers for the active branch **concurrently** via `Promise.all`. +Supports async handlers. Must be the last method call in the chain. + +### `.executeSync()` → `void` + +Executes all handlers for the active branch **synchronously**, in registration order. +Use when all handlers are synchronous and you want minimal overhead (~1.4x native `if`, no Promise allocation). + +> ⚠️ If a handler returns a Promise it is **not** awaited. Use `.execute()` for async handlers. + +```javascript +new ConditionallyExecute() + .condition(user.isAdmin) + .onTrue(() => grantAccess()) + .onFalse(() => denyAccess()) + .executeSync(); // no await needed +``` + +--- + +## Advanced usage + +### Async handlers + +Async handlers are fully supported and properly awaited: + +```javascript +await new ConditionallyExecute() + .condition(await checkPermissions(userId)) + .onTrue(async () => { + await db.grantAccess(userId); + await audit.log('access_granted', userId); + }) + .onFalse(async () => { + await audit.log('access_denied', userId); + await notifier.send(userId, 'Access denied'); + }) + .execute(); +``` + +### Multiple handlers + +Both `.onTrue()` and `.onFalse()` can be called multiple times. +All registered handlers for the active branch run **concurrently**: + +```javascript +await new ConditionallyExecute() + .condition(isDeployment) + .onTrue(() => slack.notify('Deployment started')) + .onTrue(() => dashboard.setStatus('deploying')) + .onTrue(() => metrics.increment('deployments.started')) + .onFalse(() => metrics.increment('deployments.skipped')) + .execute(); +``` + +### Default condition + +If `.condition()` is never called, the default is `true`: + +```javascript +// onTrue always fires +await new ConditionallyExecute() + .onTrue(() => console.log('this always runs')) + .execute(); +``` + +### Collecting errors + +By default, the first handler rejection short-circuits `execute()`. +Set `collectErrors: true` to run all handlers regardless and collect failures: + +```javascript +const ce = new ConditionallyExecute({ collectErrors: true }); + +try { + await ce + .condition(true) + .onTrue(async () => { throw new Error('handler 1 failed'); }) + .onTrue(async () => { throw new Error('handler 2 failed'); }) + .execute(); +} catch (err) { + // AggregateError: 2 handler(s) failed + console.log(err.errors); // [Error: handler 1 failed, Error: handler 2 failed] +} +``` + +### Refactoring guide + +```javascript +// Before +if (condition) { + doSomething(); +} else { + doSomethingElse(); +} + +// After +await new ConditionallyExecute() + .condition(condition) + .onTrue(() => doSomething()) + .onFalse(() => doSomethingElse()) + .execute(); +``` + +--- + +## Performance + +``` +conditionally-execute benchmark — 100,000 iterations + +──────────────────────────────────────────────────────────── +native if (true branch) 8.06 ms (0.081 μs/op) +native if (false branch) 7.03 ms (0.070 μs/op) +ConditionallyExecute.execute (true) 40.51 ms (0.405 μs/op) ~5x +ConditionallyExecute.execute (false) 44.11 ms (0.441 μs/op) ~6x +ConditionallyExecute.execute (default) 40.54 ms (0.405 μs/op) ~5x + +native if (true branch, sync ctx) 5.76 ms (0.058 μs/op) +ConditionallyExecute.executeSync (true) 8.21 ms (0.082 μs/op) ~1.4x ✅ +ConditionallyExecute.executeSync (false) 9.62 ms (0.096 μs/op) ~1.7x ✅ +──────────────────────────────────────────────────────────── + +⚠️ native if is faster. executeSync() closes the gap to ~1.4x. + Worth it for the DX gains either way. +``` + +Run benchmarks locally: `node bench.js` + +### When to use which + +| Use case | Method | +|---|---| +| Any async handler | `.execute()` | +| Multiple concurrent async handlers | `.execute()` | +| All sync handlers, perf-sensitive path | `.executeSync()` | +| You want to live dangerously | `.execute()` (drops Promise on floor if not awaited) | + +--- + +## TypeScript + +conditionally-execute is written in TypeScript with strict mode enabled. +Type definitions are included automatically. + +```typescript +import { ConditionallyExecute, Handler } from 'conditionally-execute'; + +const handler: Handler = async () => { + await doSomething(); +}; + +await new ConditionallyExecute() + .condition(someCondition) + .onTrue(handler) + .execute(); +``` + +--- + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). All contributions welcome. +Please follow the [Code of Conduct](CODE_OF_CONDUCT.md). + +--- + +## License + +MIT © [Michał Kubik](https://github.com/bopke) diff --git a/packages/js/bench.js b/packages/js/bench.js new file mode 100644 index 0000000..8a082bc --- /dev/null +++ b/packages/js/bench.js @@ -0,0 +1,89 @@ +/* eslint-disable no-console, no-constant-condition */ +'use strict'; + +/** + * Performance benchmark: native `if` vs ConditionallyExecute + * + * Expected result: native `if` is faster. + * Expected conclusion: worth it anyway for the readability gains. + */ + +const ConditionallyExecute = require('./src/index.js'); + +const ITERATIONS = 100_000; + +function noop() {} + +// --- Benchmark runner --- + +async function bench(name, fn) { + // warmup + for (let i = 0; i < 1000; i++) await fn(); + + const start = performance.now(); + for (let i = 0; i < ITERATIONS; i++) await fn(); + const elapsed = performance.now() - start; + + console.log(`${name.padEnd(40)} ${elapsed.toFixed(2).padStart(8)} ms (${(elapsed / ITERATIONS * 1000).toFixed(3)} μs/op)`); +} + +// --- Benchmarks --- + +async function main() { + console.log(`\nconditionally-execute benchmark — ${ITERATIONS.toLocaleString()} iterations\n`); + console.log('─'.repeat(60)); + + await bench('native if (true branch)', async () => { + if (true) { noop(); } + }); + + await bench('native if (false branch)', async () => { + if (false) { noop(); } else { noop(); } + }); + + await bench('ConditionallyExecute (true)', async () => { + await new ConditionallyExecute() + .condition(true) + .onTrue(noop) + .execute(); + }); + + await bench('ConditionallyExecute (false)', async () => { + await new ConditionallyExecute() + .condition(false) + .onFalse(noop) + .execute(); + }); + + await bench('ConditionallyExecute (default)', async () => { + await new ConditionallyExecute() + .onTrue(noop) + .execute(); + }); + + console.log(''); + + // Sync variants (no Promise overhead) + await bench('native if (true branch, sync ctx)', () => { + if (true) { noop(); } + }); + + await bench('ConditionallyExecute.executeSync (true)', () => { + new ConditionallyExecute() + .condition(true) + .onTrue(noop) + .executeSync(); + }); + + await bench('ConditionallyExecute.executeSync (false)', () => { + new ConditionallyExecute() + .condition(false) + .onFalse(noop) + .executeSync(); + }); + + console.log('─'.repeat(60)); + console.log('\n⚠️ native if is faster. Worth it for the DX gains.\n'); +} + +main().catch(console.error); diff --git a/packages/js/eslint.config.js b/packages/js/eslint.config.js new file mode 100644 index 0000000..0ced5d6 --- /dev/null +++ b/packages/js/eslint.config.js @@ -0,0 +1,52 @@ +'use strict'; + +const js = require('@eslint/js'); + +module.exports = [ + js.configs.recommended, + { + files: ['**/*.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + globals: { + require: 'readonly', + module: 'readonly', + exports: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + process: 'readonly', + console: 'readonly', + Buffer: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + performance: 'readonly', + AggregateError: 'readonly', + Promise: 'readonly', + }, + }, + rules: { + 'no-unused-vars': 'error', + 'no-console': 'warn', + 'eqeqeq': ['error', 'always'], + 'prefer-const': 'error', + 'no-var': 'error', + }, + }, + { + files: ['test/**/*.js'], + languageOptions: { + globals: { + describe: 'readonly', + it: 'readonly', + before: 'readonly', + after: 'readonly', + afterEach: 'readonly', + beforeEach: 'readonly', + }, + }, + }, + { + ignores: ['node_modules/', 'reports/'], + }, +]; diff --git a/packages/js/package-lock.json b/packages/js/package-lock.json new file mode 100644 index 0000000..0084f6f --- /dev/null +++ b/packages/js/package-lock.json @@ -0,0 +1,1764 @@ +{ + "name": "conditionally-execute", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "conditionally-execute", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "eslint": "^9.39.4", + "mocha": "^10.8.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mocha": { + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", + "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workerpool": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/packages/js/package.json b/packages/js/package.json new file mode 100644 index 0000000..cf4c335 --- /dev/null +++ b/packages/js/package.json @@ -0,0 +1,65 @@ +{ + "name": "conditionally-execute", + "description": "Composable conditional execution for Node.js", + "version": "2.0.0", + "homepage": "https://github.com/bopke/conditionally-execute", + "author": "Michał Kubik (https://github.com/bopke)", + "license": "MIT", + "contributors": [ + "Michał Kubik (https://github.com/bopke)" + ], + "repository": "bopke/conditionally-execute", + "bugs": { + "url": "https://github.com/bopke/conditionally-execute/issues" + }, + "files": [ + "src/" + ], + "main": "./src/index.js", + "exports": { + ".": "./src/index.js", + "./plugins": "./src/plugins/index.js", + "./plugins/*": "./src/plugins/*.js" + }, + "scripts": { + "test": "node test/parallel.js", + "test:core": "mocha --timeout 10000 test/core.js", + "test:grpc": "mocha --timeout 15000 test/grpc.js", + "test:multi-threaded": "mocha --timeout 15000 test/multi-threaded.js", + "lint": "eslint src/ test/ bench.js", + "lint:fix": "eslint src/ test/ bench.js --fix", + "bench": "node bench.js", + "mutation": "stryker run" + }, + "keywords": [ + "conditional", + "execution", + "middleware", + "composable", + "if" + ], + "engines": { + "node": ">=18.0.0" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@grpc/grpc-js": "^1.14.3", + "@grpc/proto-loader": "^0.8.1", + "@stryker-mutator/core": "^9.6.1", + "@stryker-mutator/mocha-runner": "^9.6.1", + "eslint": "^9.0.0", + "mocha": "^10.8.2" + }, + "peerDependencies": { + "@grpc/grpc-js": ">=1.8.0", + "@grpc/proto-loader": ">=0.7.0" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@grpc/proto-loader": { + "optional": true + } + } +} diff --git a/packages/js/src/index.js b/packages/js/src/index.js new file mode 100644 index 0000000..92bc19e --- /dev/null +++ b/packages/js/src/index.js @@ -0,0 +1,236 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Custom error types +// --------------------------------------------------------------------------- + +class ConditionallyExecuteError extends Error { + constructor(message) { + super(message); + this.name = 'ConditionallyExecuteError'; + } +} + +// --------------------------------------------------------------------------- +// Named condition registry +// --------------------------------------------------------------------------- + +/** @type {Map unknown>} */ +const _registry = new Map(); + +// --------------------------------------------------------------------------- +// Main class +// --------------------------------------------------------------------------- + +/** + * @typedef {() => void | Promise} Handler + */ + +/** + * @typedef {object} ExecutionContext + * @property {boolean} condition Current condition value (may be mutated by middleware). + * @property {string} branch Active branch: 'onTrue' | 'onFalse'. + * @property {Handler[]} handlers Active handlers for this branch (may be mutated by middleware). + * @property {Handler[]} _onTrue All registered onTrue handlers. + * @property {Handler[]} _onFalse All registered onFalse handlers. + */ + +/** + * @callback Middleware + * @param {ExecutionContext} ctx + * @param {() => Promise} next + * @returns {Promise} + */ + +/** + * ConditionallyExecute — composable conditional execution. + * + * Core provides: condition, onTrue, onFalse, onError, use(), execute(), executeSync(). + * Everything else (timeout, retry, dryRun, audit log, etc.) is a plugin via .use(). + * + * @example + * const { TimeoutPlugin, RetryPlugin } = require('./plugins'); + * + * await new ConditionallyExecute() + * .use(TimeoutPlugin(5000)) + * .use(RetryPlugin(3, { backoff: 'exponential' })) + * .condition(isHealthy) + * .onTrue(deployToProduction) + * .execute(); + */ +class ConditionallyExecute { + constructor() { + /** @private @type {boolean} */ + this._condition = true; + /** @private @type {Handler[]} */ + this._onTrue = []; + /** @private @type {Handler[]} */ + this._onFalse = []; + /** @private @type {Middleware[]} */ + this._middlewares = []; + /** @private @type {((err: Error) => void | Promise)|null} */ + this._errorHandler = null; + } + + // ------------------------------------------------------------------------- + // Static API + // ------------------------------------------------------------------------- + + /** + * Register a named condition for reuse across instances. + * @param {string} name + * @param {() => unknown} fn + */ + static register(name, fn) { + if (typeof name !== 'string') throw new TypeError(`register() expects a string name, got ${typeof name}`); + if (typeof fn !== 'function') throw new TypeError(`register() expects a function evaluator, got ${typeof fn}`); + _registry.set(name, fn); + } + + /** @param {string} name */ + static unregister(name) { + _registry.delete(name); + } + + static clearRegistry() { + _registry.clear(); + } + + // ------------------------------------------------------------------------- + // Builder API + // ------------------------------------------------------------------------- + + /** + * Sets the condition. Coerced to boolean. Last call wins. + * Accepts a named condition string registered via `ConditionallyExecute.register()`. + * Defaults to `true` if never called. + * @param {unknown} condition + * @returns {this} + */ + condition(condition) { + if (_registry.has(condition)) { + this._condition = Boolean(_registry.get(condition)()); + } else { + this._condition = Boolean(condition); + } + return this; + } + + /** + * Registers a handler for the truthy branch. + * @param {Handler} func + * @returns {this} + * @throws {TypeError} + */ + onTrue(func) { + if (typeof func !== 'function') { + throw new TypeError(`onTrue() expects a function, got ${typeof func}`); + } + this._onTrue.push(func); + return this; + } + + /** + * Registers a handler for the falsy branch. + * @param {Handler} func + * @returns {this} + * @throws {TypeError} + */ + onFalse(func) { + if (typeof func !== 'function') { + throw new TypeError(`onFalse() expects a function, got ${typeof func}`); + } + this._onFalse.push(func); + return this; + } + + /** + * Registers an error handler. Called instead of throwing when execution fails. + * @param {(err: Error) => void | Promise} fn + * @returns {this} + */ + onError(fn) { + if (typeof fn !== 'function') { + throw new TypeError(`onError() expects a function, got ${typeof fn}`); + } + this._errorHandler = fn; + return this; + } + + /** + * Installs a middleware. Middleware receives `(ctx, next)` and can inspect or + * mutate `ctx.condition`, `ctx.branch`, and `ctx.handlers` before/after execution. + * Middleware composes in registration order. + * + * @param {Middleware} middleware + * @returns {this} + * @example + * .use(async (ctx, next) => { + * console.log('before:', ctx.branch); + * await next(); + * console.log('after'); + * }) + */ + use(middleware) { + if (typeof middleware !== 'function') { + throw new TypeError(`use() expects a function middleware, got ${typeof middleware}`); + } + this._middlewares.push(middleware); + return this; + } + + // ------------------------------------------------------------------------- + // Execution + // ------------------------------------------------------------------------- + + /** + * Executes all active-branch handlers concurrently via `Promise.all`. + * Runs the full middleware chain first. + * @returns {Promise} + */ + async execute() { + /** @type {ExecutionContext} */ + const ctx = { + condition: this._condition, + branch: this._condition ? 'onTrue' : 'onFalse', + handlers: this._condition ? [...this._onTrue] : [...this._onFalse], + _onTrue: this._onTrue, + _onFalse: this._onFalse, + }; + + const dispatch = async (i) => { + if (i < this._middlewares.length) { + await this._middlewares[i](ctx, () => dispatch(i + 1)); + } else { + await Promise.all(ctx.handlers.map((fn) => fn())); + } + }; + + try { + await dispatch(0); + } catch (err) { + if (this._errorHandler) { + await this._errorHandler(err); + return; + } + throw err; + } + } + + /** + * Executes all active-branch handlers synchronously, in registration order. + * No middleware support. No Promise overhead. Use when handlers are sync + * and performance matters. + * @returns {void} + */ + executeSync() { + const handlers = this._condition ? this._onTrue : this._onFalse; + for (let i = 0; i < handlers.length; i++) { + handlers[i](); + } + } +} + +ConditionallyExecute.ConditionallyExecuteError = ConditionallyExecuteError; + +module.exports = ConditionallyExecute; diff --git a/packages/js/src/plugins/audit-log.js b/packages/js/src/plugins/audit-log.js new file mode 100644 index 0000000..7cfe01f --- /dev/null +++ b/packages/js/src/plugins/audit-log.js @@ -0,0 +1,64 @@ +'use strict'; + +/** + * @typedef {object} AuditLogOptions + * @property {(entry: AuditLogEntry) => void} [logger=console.log] Custom log sink. + */ + +/** + * @typedef {object} AuditLogEntry + * @property {string} timestamp ISO 8601 timestamp. + * @property {boolean} condition Resolved condition value. + * @property {string} branch Active branch ('onTrue' | 'onFalse'). + * @property {number} handlers Number of handlers that ran. + * @property {number} durationMs Execution duration in milliseconds. + */ + +/** + * AuditLogPlugin — logs execution metadata after the handler chain completes. + * + * Wraps the downstream chain and emits a structured log entry after `next()` + * resolves. The log sink defaults to `console.log` but can be replaced with + * any function (e.g. a structured logger, metrics emitter, etc.). + * + * @param {AuditLogOptions} [options] + * @returns {import('../index').Middleware} + * + * @example + * await new ConditionallyExecute() + * .use(AuditLogPlugin()) + * .condition(isReady) + * .onTrue(deploy) + * .execute(); + * // → [2026-05-16T...] ConditionallyExecute: condition=true branch=onTrue handlers=1 duration=4.20ms + * + * @example + * // Custom logger + * .use(AuditLogPlugin({ logger: (entry) => metrics.record('ce_execution', entry) })) + */ +function AuditLogPlugin({ logger } = {}) { + const log = typeof logger === 'function' ? logger : console.log; // eslint-disable-line no-console + + return async function auditLogMiddleware(ctx, next) { + const start = performance.now(); + await next(); + const durationMs = performance.now() - start; + + /** @type {AuditLogEntry} */ + const entry = { + timestamp: new Date().toISOString(), + condition: ctx.condition, + branch: ctx.branch, + handlers: ctx.handlers.length, + durationMs, + }; + + log( + `[${entry.timestamp}] ConditionallyExecute: ` + + `condition=${entry.condition} branch=${entry.branch} ` + + `handlers=${entry.handlers} duration=${entry.durationMs.toFixed(2)}ms` + ); + }; +} + +module.exports = { AuditLogPlugin }; diff --git a/packages/js/src/plugins/collect-errors.js b/packages/js/src/plugins/collect-errors.js new file mode 100644 index 0000000..c5a93fa --- /dev/null +++ b/packages/js/src/plugins/collect-errors.js @@ -0,0 +1,43 @@ +'use strict'; + +/** + * CollectErrorsPlugin — runs all handlers and aggregates failures instead of short-circuiting. + * + * By default, `Promise.all` stops on the first rejection. This plugin replaces each + * handler with an error-capturing wrapper so all handlers always run. If any failed, + * their errors are aggregated into a single `AggregateError` and thrown after all + * handlers complete. + * + * @returns {import('../index').Middleware} + * + * @example + * await new ConditionallyExecute() + * .use(CollectErrorsPlugin()) + * .condition(true) + * .onTrue(handlerA) // fails + * .onTrue(handlerB) // also runs, even though handlerA failed + * .onTrue(handlerC) // also runs + * .execute(); + * // throws AggregateError with all collected failures + */ +function CollectErrorsPlugin() { + return async function collectErrorsMiddleware(ctx, next) { + const errors = []; + + ctx.handlers = ctx.handlers.map((fn) => async () => { + try { + await fn(); + } catch (err) { + errors.push(err); + } + }); + + await next(); + + if (errors.length > 0) { + throw new AggregateError(errors, `${errors.length} handler(s) failed`); + } + }; +} + +module.exports = { CollectErrorsPlugin }; diff --git a/packages/js/src/plugins/dry-run.js b/packages/js/src/plugins/dry-run.js new file mode 100644 index 0000000..4ef6b49 --- /dev/null +++ b/packages/js/src/plugins/dry-run.js @@ -0,0 +1,33 @@ +'use strict'; + +/** + * DryRunPlugin — skips handler execution, logs what would have run. + * + * Intercepts the chain before handlers are invoked. Logs the branch name + * and handler count to stdout, then stops — `next()` is never called. + * Middleware registered after this plugin will not run. + * + * Compose last (innermost) so that upstream middleware still executes normally. + * + * @returns {import('../index').Middleware} + * + * @example + * await new ConditionallyExecute() + * .use(AuditLogPlugin()) // still runs + * .use(DryRunPlugin()) // stops here, handlers skipped + * .condition(isReady) + * .onTrue(deployToProduction) + * .execute(); + */ +function DryRunPlugin() { + return async function dryRunMiddleware(ctx) { + // eslint-disable-next-line no-console + console.log( + `[DryRun] ConditionallyExecute: would execute ${ctx.handlers.length} ` + + `handler(s) on branch ${ctx.branch}` + ); + // intentionally does not call next() + }; +} + +module.exports = { DryRunPlugin }; diff --git a/packages/js/src/plugins/grpc-consensus.js b/packages/js/src/plugins/grpc-consensus.js new file mode 100644 index 0000000..657b434 --- /dev/null +++ b/packages/js/src/plugins/grpc-consensus.js @@ -0,0 +1,333 @@ +'use strict'; + +/** + * GrpcConsensusPlugin — enterprise-grade distributed execution with quorum agreement. + * + * Architecture: + * - Each "node" is a gRPC server running ConditionallyExecuteNode service. + * - The coordinator (your app) calls Execute() on ALL nodes simultaneously. + * - Each node runs its locally-registered handler and reports success/failure. + * - If fewer than `quorum` nodes confirm successful execution → QuorumError. + * - All communication is via Protobuf over HTTP/2 (gRPC). + * + * Why gRPC? + * - Strongly typed via Protobuf schema + * - HTTP/2 multiplexing — all nodes called in a single round-trip + * - Industry standard (used by Kubernetes, etcd, Consul) + * - Appropriate for an if-statement replacement library + * + * @example + * const { GrpcConsensusPlugin, startGrpcNode } = require('conditionally-execute/plugins/grpc-consensus'); + * + * // Start nodes (in separate processes in production; here for demo): + * const n1 = await startGrpcNode(50051, { deploy: () => console.log('node1: deploying') }); + * const n2 = await startGrpcNode(50052, { deploy: () => console.log('node2: deploying') }); + * const n3 = await startGrpcNode(50053, { deploy: () => console.log('node3: deploying') }); + * + * await new ConditionallyExecute() + * .use(GrpcConsensusPlugin({ + * nodes: ['localhost:50051', 'localhost:50052', 'localhost:50053'], + * handlerName: 'deploy', + * quorum: 2, // at least 2/3 must succeed + * })) + * .condition(isReadyForDeploy) + * .onTrue(() => console.log('coordinator: quorum reached, deploy confirmed')) + * .execute(); + * + * await Promise.all([n1.close(), n2.close(), n3.close()]); + */ + +const grpc = require('@grpc/grpc-js'); +const protoLoader = require('@grpc/proto-loader'); +const path = require('path'); +const { randomUUID } = require('crypto'); + +// --------------------------------------------------------------------------- +// Load proto definition +// --------------------------------------------------------------------------- + +// Proto lives at monorepo root (../../../../proto/) — shared with the Java module. +const PROTO_PATH = path.join(__dirname, '..', '..', '..', '..', 'proto', 'conditionally_execute.proto'); + +const packageDef = protoLoader.loadSync(PROTO_PATH, { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, +}); + +const protoDescriptor = grpc.loadPackageDefinition(packageDef); +const { ConditionallyExecuteNode } = protoDescriptor.conditionally_execute; + +// --------------------------------------------------------------------------- +// Custom error types +// --------------------------------------------------------------------------- + +class QuorumError extends Error { + /** + * @param {number} reached + * @param {number} required + * @param {number} total + * @param {import('./grpc-consensus').NodeResult[]} results + */ + constructor(reached, required, total, results) { + super(`Quorum not reached: ${reached}/${total} nodes succeeded (required ${required})`); + this.name = 'QuorumError'; + this.reached = reached; + this.required = required; + this.total = total; + this.nodeResults = results; + } +} + +// --------------------------------------------------------------------------- +// Node server +// --------------------------------------------------------------------------- + +/** + * @typedef {Object} NodeServer + * @property {string} nodeId + * @property {number} port + * @property {() => void} close + */ + +/** + * Start a gRPC ConditionallyExecuteNode server on the given port. + * + * @param {number} port + * @param {Record void | Promise>} handlers Named handler map. + * @param {object} [options] + * @param {string} [options.nodeId] Custom node ID (defaults to `node-${port}`) + * @returns {Promise} + */ +async function startGrpcNode(port, handlers = {}, options = {}) { + const nodeId = options.nodeId || `node-${port}`; + + const server = new grpc.Server(); + + server.addService(ConditionallyExecuteNode.service, { + /** + * Execute a handler on this node. + */ + execute(call, callback) { + const { condition, handler_name, metadata } = call.request; + const handler = handlers[handler_name]; + + if (!handler) { + return callback(null, { + node_id: nodeId, + executed: false, + branch: condition ? 'onTrue' : 'onFalse', + duration_ms: 0, + error: `Handler '${handler_name}' not registered on ${nodeId}`, + }); + } + + const branch = condition ? 'onTrue' : 'onFalse'; + const start = performance.now(); + + // Only execute the handler if it matches the active branch + // (handlers are registered per branch on the node — if condition is true, + // we call the onTrue handler; if false, the onFalse handler) + // For simplicity: handler_name is the true-branch handler name. + // Node skips execution when condition doesn't match. + if (!condition) { + return callback(null, { + node_id: nodeId, + executed: false, + branch: 'onFalse', + duration_ms: 0, + error: '', + }); + } + + Promise.resolve() + .then(() => handler(metadata)) + .then(() => { + callback(null, { + node_id: nodeId, + executed: true, + branch, + duration_ms: performance.now() - start, + error: '', + }); + }) + .catch((err) => { + callback(null, { + node_id: nodeId, + executed: false, + branch, + duration_ms: performance.now() - start, + error: err.message || String(err), + }); + }); + }, + + /** + * Health check — returns node status and registered handler names. + */ + health(call, callback) { + callback(null, { + node_id: nodeId, + status: 'ok', + handlers: Object.keys(handlers), + }); + }, + }); + + await new Promise((resolve, reject) => { + server.bindAsync( + `0.0.0.0:${port}`, + grpc.ServerCredentials.createInsecure(), + (err, boundPort) => { + if (err) return reject(err); + resolve(boundPort); + } + ); + }); + + return { + nodeId, + port, + close() { + return new Promise((resolve) => server.tryShutdown(resolve)); + }, + }; +} + +// --------------------------------------------------------------------------- +// Coordinator client helpers +// --------------------------------------------------------------------------- + +/** + * @typedef {Object} NodeResult + * @property {string} address + * @property {boolean} success + * @property {import('@grpc/grpc-js').ServiceError|null} rpcError + * @property {object|null} response + */ + +/** + * Call Execute on a single gRPC node. + * @param {string} address e.g. "localhost:50051" + * @param {object} request + * @param {number} deadlineMs + * @returns {Promise} + */ +function callNode(address, request, deadlineMs) { + return new Promise((resolve) => { + const client = new ConditionallyExecuteNode( + address, + grpc.credentials.createInsecure() + ); + + const deadline = new Date(Date.now() + deadlineMs); + + client.execute(request, { deadline }, (err, response) => { + client.close(); + + if (err) { + resolve({ address, success: false, rpcError: err, response: null }); + } else { + resolve({ + address, + success: !response.error && response.executed, + rpcError: null, + response, + }); + } + }); + }); +} + +// --------------------------------------------------------------------------- +// Plugin factory +// --------------------------------------------------------------------------- + +/** + * @typedef {Object} GrpcConsensusOptions + * @property {string[]} nodes gRPC node addresses, e.g. ['localhost:50051', 'localhost:50052'] + * @property {string} handlerName Name of the handler to invoke on each node (must be registered) + * @property {number} [quorum] Minimum successful node responses required (default: majority) + * @property {number} [timeout=5000] Per-node RPC deadline in ms + * @property {boolean} [verbose=false] Log per-node results to stdout + */ + +/** + * Creates a GrpcConsensusPlugin middleware. + * Broadcasts Execute() to all nodes, verifies quorum, then proceeds. + * + * @param {GrpcConsensusOptions} options + * @returns {import('../index').Middleware} + */ +function GrpcConsensusPlugin(options) { + const { + nodes, + handlerName, + quorum = Math.floor(nodes.length / 2) + 1, + timeout = 5000, + verbose = false, + } = options; + + if (!Array.isArray(nodes) || nodes.length === 0) { + throw new Error('GrpcConsensusPlugin: nodes must be a non-empty array of gRPC addresses'); + } + if (typeof handlerName !== 'string' || !handlerName) { + throw new Error('GrpcConsensusPlugin: handlerName is required'); + } + if (quorum > nodes.length) { + throw new Error(`GrpcConsensusPlugin: quorum (${quorum}) cannot exceed node count (${nodes.length})`); + } + + return async function grpcConsensusMiddleware(ctx, next) { + const requestId = randomUUID(); + + const request = { + request_id: requestId, + condition: ctx.condition, + handler_name: handlerName, + metadata: {}, + }; + + // Fan out — call all nodes simultaneously + const results = await Promise.all( + nodes.map((addr) => callNode(addr, request, timeout)) + ); + + const succeeded = results.filter((r) => r.success).length; + + if (verbose || process.env.CE_GRPC_DEBUG) { + for (const r of results) { + const icon = r.success ? '✅' : '❌'; + const detail = r.rpcError + ? `RPC error: ${r.rpcError.message}` + : r.response + ? `branch=${r.response.branch} duration=${r.response.duration_ms?.toFixed(2)}ms` + : 'no response'; + console.log(`[GrpcConsensusPlugin] ${icon} ${r.address}: ${detail}`); + } + console.log( + `[GrpcConsensusPlugin] quorum: ${succeeded}/${nodes.length} ` + + `(required ${quorum}) → ${succeeded >= quorum ? 'PASSED' : 'FAILED'}` + ); + } + + if (succeeded < quorum) { + throw new QuorumError(succeeded, quorum, nodes.length, results); + } + + // Quorum reached — execute coordinator-side handlers + await next(); + }; +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +module.exports = { + GrpcConsensusPlugin, + startGrpcNode, + QuorumError, +}; diff --git a/packages/js/src/plugins/index.js b/packages/js/src/plugins/index.js new file mode 100644 index 0000000..cc7537f --- /dev/null +++ b/packages/js/src/plugins/index.js @@ -0,0 +1,38 @@ +'use strict'; + +/** + * conditionally-execute plugins + * + * All first-party plugins in one place. + * + * @example + * const { TimeoutPlugin, RetryPlugin, AuditLogPlugin } = require('conditionally-execute/plugins'); + */ + +const { TimeoutPlugin, TimeoutError } = require('./timeout'); +const { RetryPlugin } = require('./retry'); +const { DryRunPlugin } = require('./dry-run'); +const { AuditLogPlugin } = require('./audit-log'); +const { CollectErrorsPlugin } = require('./collect-errors'); +const { GrpcConsensusPlugin, startGrpcNode, QuorumError } = require('./grpc-consensus'); +const { MultiThreadedPlugin } = require('./multi-threaded'); + +module.exports = { + // Reliability + TimeoutPlugin, + TimeoutError, + RetryPlugin, + + // Observability + AuditLogPlugin, + + // Control flow + DryRunPlugin, + CollectErrorsPlugin, + + // Distributed execution + GrpcConsensusPlugin, + startGrpcNode, + QuorumError, + MultiThreadedPlugin, +}; diff --git a/packages/js/src/plugins/multi-threaded.js b/packages/js/src/plugins/multi-threaded.js new file mode 100644 index 0000000..bd5cf21 --- /dev/null +++ b/packages/js/src/plugins/multi-threaded.js @@ -0,0 +1,160 @@ +'use strict'; + +/** + * MultiThreadedPlugin — distributed consensus for your if-statements. + * + * Spawns N worker threads acting as independent consensus nodes. Each node + * receives the condition value and casts a vote. The majority vote determines + * which branch executes. Communication is via Node.js worker_threads + * MessageChannel (in-process RPC — production-grade architecture 🫡). + * + * @example + * const { MultiThreadedPlugin } = require('conditionally-execute/plugins/multi-threaded'); + * + * await new ConditionallyExecute() + * .use(MultiThreadedPlugin({ nodes: 5 })) + * .condition(userIsAdmin) + * .onTrue(() => grantAccess()) + * .onFalse(() => denyAccess()) + * .execute(); + * // 3/5 nodes must agree before any handler runs. + */ + +const { Worker } = require('worker_threads'); + +// --------------------------------------------------------------------------- +// Worker node source (runs in each thread) +// --------------------------------------------------------------------------- + +// Each node simulates an independent evaluation. In a real distributed system, +// each node would consult its own local state store, replica, or quorum peer. +// Here: votes deterministically with optional jitter for chaos testing. +const NODE_WORKER_CODE = /* javascript */ ` +const { parentPort, workerData } = require('worker_threads'); + +const { condition, nodeId, jitter } = workerData; + +function evaluate() { + // Deterministic evaluation — real nodes might check local state here + return Boolean(condition); +} + +const vote = evaluate(); + +// Simulate network RTT variance when jitter is enabled +const delay = jitter ? Math.floor(Math.random() * 50) : 0; + +setTimeout(() => { + parentPort.postMessage({ nodeId, vote }); +}, delay); +`; + +// --------------------------------------------------------------------------- +// Vote collection +// --------------------------------------------------------------------------- + +/** + * Spawn N worker threads and collect their votes. + * @param {unknown} condition + * @param {number} nodeCount + * @param {number} timeoutMs + * @param {boolean} jitter + * @returns {Promise} + */ +async function collectVotes(condition, nodeCount, timeoutMs, jitter) { + return new Promise((resolve, reject) => { + const votes = []; + const workers = []; + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + workers.forEach((w) => w.terminate()); + reject(new Error(`MultiThreadedPlugin: vote collection timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + for (let i = 0; i < nodeCount; i++) { + const worker = new Worker(NODE_WORKER_CODE, { + eval: true, + workerData: { condition, nodeId: i, jitter }, + }); + + workers.push(worker); + + worker.on('message', ({ vote }) => { + if (settled) return; + votes.push(vote); + + if (votes.length === nodeCount) { + settled = true; + clearTimeout(timer); + workers.forEach((w) => w.terminate()); + resolve(votes); + } + }); + + worker.on('error', (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + workers.forEach((w) => w.terminate()); + reject(err); + }); + } + }); +} + +// --------------------------------------------------------------------------- +// Plugin factory +// --------------------------------------------------------------------------- + +/** + * @typedef {object} ConsensusOptions + * @property {number} [nodes=3] Number of consensus nodes. Must be odd (≥ 3). + * @property {number} [timeout=2000] Max ms to wait for all votes before aborting. + * @property {boolean} [jitter=false] Add random latency per node (chaos testing). + * @property {boolean} [verbose=false] Log vote results to stdout. + */ + +/** + * Creates a MultiThreadedPlugin middleware for ConditionallyExecute. + * + * @param {ConsensusOptions} [options] + * @returns {import('../index').Middleware} + */ +function MultiThreadedPlugin(options = {}) { + const { nodes = 3, timeout = 2000, jitter = false, verbose = false } = options; + + if (!Number.isInteger(nodes) || nodes < 3) { + throw new Error('MultiThreadedPlugin: nodes must be an integer ≥ 3'); + } + if (nodes % 2 === 0) { + throw new Error('MultiThreadedPlugin: nodes must be odd to guarantee a clear majority'); + } + + return async function consensusMiddleware(ctx, next) { + const votes = await collectVotes(ctx.condition, nodes, timeout, jitter); + + const trueVotes = votes.filter(Boolean).length; + const falseVotes = nodes - trueVotes; + const consensus = trueVotes > falseVotes; + + if (verbose || process.env.CE_CONSENSUS_DEBUG) { + // eslint-disable-next-line no-console + console.log( + `[MultiThreadedPlugin] ${nodes} nodes voted: ` + + `${trueVotes} true / ${falseVotes} false → consensus=${consensus}` + ); + } + + // Override execution context with consensus result + ctx.condition = consensus; + ctx.branch = consensus ? 'onTrue' : 'onFalse'; + ctx.handlers = consensus ? ctx._onTrue : ctx._onFalse; + + await next(); + }; +} + +module.exports = { MultiThreadedPlugin, collectVotes }; diff --git a/packages/js/src/plugins/retry.js b/packages/js/src/plugins/retry.js new file mode 100644 index 0000000..e5149ec --- /dev/null +++ b/packages/js/src/plugins/retry.js @@ -0,0 +1,89 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * @param {number} attempt Zero-based attempt index. + * @param {'none'|'linear'|'exponential'} strategy + * @returns {number} Delay in milliseconds. + */ +function backoffDelay(attempt, strategy) { + switch (strategy) { + case 'linear': return attempt * 100; + case 'exponential': return Math.pow(2, attempt) * 100; + default: return 0; + } +} + +/** + * Wraps a handler function with retry logic. + * @param {() => void | Promise} fn + * @param {number} maxRetries + * @param {'none'|'linear'|'exponential'} backoff + * @returns {() => Promise} + */ +function wrapWithRetry(fn, maxRetries, backoff) { + return async function retryWrapper() { + let lastError; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + if (attempt < maxRetries) { + const delay = backoffDelay(attempt, backoff); + if (delay > 0) await new Promise((r) => setTimeout(r, delay)); + } + } + } + throw lastError; + }; +} + +// --------------------------------------------------------------------------- +// Plugin factory +// --------------------------------------------------------------------------- + +/** + * @typedef {object} RetryOptions + * @property {'none'|'linear'|'exponential'} [backoff='none'] Backoff strategy between attempts. + */ + +/** + * RetryPlugin — retries each handler individually on failure. + * + * Wraps each handler in `ctx.handlers` with retry logic before passing + * control downstream. Each handler is retried independently up to `n` times. + * + * @param {number} n Maximum number of retries (0 = no retry, try once). + * @param {RetryOptions} [options] + * @returns {import('../index').Middleware} + * + * @example + * await new ConditionallyExecute() + * .use(RetryPlugin(3, { backoff: 'exponential' })) + * .condition(isHealthy) + * .onTrue(flakyNetworkCall) + * .execute(); + */ +function RetryPlugin(n, { backoff = 'none' } = {}) { + if (typeof n !== 'number' || n < 0 || !Number.isInteger(n)) { + throw new TypeError(`RetryPlugin: n must be a non-negative integer, got ${n}`); + } + if (!['none', 'linear', 'exponential'].includes(backoff)) { + throw new TypeError(`RetryPlugin: backoff must be 'none', 'linear', or 'exponential', got '${backoff}'`); + } + + return async function retryMiddleware(ctx, next) { + ctx.handlers = ctx.handlers.map((fn) => wrapWithRetry(fn, n, backoff)); + await next(); + }; +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +module.exports = { RetryPlugin }; diff --git a/packages/js/src/plugins/timeout.js b/packages/js/src/plugins/timeout.js new file mode 100644 index 0000000..46e1efc --- /dev/null +++ b/packages/js/src/plugins/timeout.js @@ -0,0 +1,61 @@ +'use strict'; + +const ConditionallyExecute = require('../'); +const { ConditionallyExecuteError } = ConditionallyExecute; + +// --------------------------------------------------------------------------- +// TimeoutError +// --------------------------------------------------------------------------- + +class TimeoutError extends ConditionallyExecuteError { + /** + * @param {number} ms + */ + constructor(ms) { + super(`Handler execution timed out after ${ms}ms`); + this.name = 'TimeoutError'; + this.ms = ms; + } +} + +// --------------------------------------------------------------------------- +// Plugin factory +// --------------------------------------------------------------------------- + +/** + * TimeoutPlugin — aborts handler execution if it exceeds the given duration. + * + * Wraps the downstream middleware chain in a `Promise.race`. If the deadline + * fires first, throws `TimeoutError`. Compose before other middleware so the + * timeout covers the entire remaining chain. + * + * @param {number} ms Maximum allowed execution time in milliseconds. + * @returns {import('../index').Middleware} + * + * @example + * await new ConditionallyExecute() + * .use(TimeoutPlugin(3000)) + * .condition(isReady) + * .onTrue(slowHandler) + * .execute(); + */ +function TimeoutPlugin(ms) { + if (typeof ms !== 'number' || ms <= 0) { + throw new TypeError(`TimeoutPlugin: ms must be a positive number, got ${ms}`); + } + + return async function timeoutMiddleware(ctx, next) { + await Promise.race([ + next(), + new Promise((_, reject) => + setTimeout(() => reject(new TimeoutError(ms)), ms) + ), + ]); + }; +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +module.exports = { TimeoutPlugin, TimeoutError }; diff --git a/packages/js/stryker.config.mjs b/packages/js/stryker.config.mjs new file mode 100644 index 0000000..42ffd80 --- /dev/null +++ b/packages/js/stryker.config.mjs @@ -0,0 +1,25 @@ +// @ts-check +/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */ +const config = { + testRunner: 'mocha', + testRunnerNodeArgs: [], + mocha: { + spec: ['test/core.js'], + timeout: 10000, + }, + mutate: ['src/index.js'], + reporters: ['progress', 'clear-text', 'html'], + htmlReporter: { + fileName: 'reports/mutation/mutation.html', + }, + thresholds: { + high: 80, + low: 60, + break: 50, + }, + concurrency: 4, + timeoutMS: 10000, + timeoutFactor: 1.5, +}; + +export default config; diff --git a/packages/js/test/core.js b/packages/js/test/core.js new file mode 100644 index 0000000..6e462e9 --- /dev/null +++ b/packages/js/test/core.js @@ -0,0 +1,769 @@ +'use strict'; + +const assert = require('assert'); +const ConditionallyExecute = require('../'); +const { TimeoutPlugin, TimeoutError } = require('../src/plugins/timeout'); +const { RetryPlugin } = require('../src/plugins/retry'); +const { DryRunPlugin } = require('../src/plugins/dry-run'); +const { AuditLogPlugin } = require('../src/plugins/audit-log'); +const { CollectErrorsPlugin } = require('../src/plugins/collect-errors'); + +// --------------------------------------------------------------------------- +// Basic functionality +// --------------------------------------------------------------------------- + +describe('basic functionality', function () { + it('should execute onFalse when condition is falsy', async function () { + let wasOnTrueExecuted = false; + let wasOnFalseExecuted = false; + + await new ConditionallyExecute() + .onTrue(() => { wasOnTrueExecuted = true; }) + .onFalse(() => { wasOnFalseExecuted = true; }) + .condition(1 === 2) + .execute(); + + assert.equal(wasOnFalseExecuted, true); + assert.equal(wasOnTrueExecuted, false); + }); + + it('should execute onTrue when condition is truthy', async function () { + let wasOnTrueExecuted = false; + let wasOnFalseExecuted = false; + + await new ConditionallyExecute() + .onTrue(() => { wasOnTrueExecuted = true; }) + .onFalse(() => { wasOnFalseExecuted = true; }) + .condition(1 === 1) + .execute(); + + assert.equal(wasOnFalseExecuted, false); + assert.equal(wasOnTrueExecuted, true); + }); + + it('should execute onTrue when no condition is set (default true)', async function () { + let wasOnTrueExecuted = false; + let wasOnFalseExecuted = false; + + await new ConditionallyExecute() + .onTrue(() => { wasOnTrueExecuted = true; }) + .onFalse(() => { wasOnFalseExecuted = true; }) + .execute(); + + assert.equal(wasOnFalseExecuted, false); + assert.equal(wasOnTrueExecuted, true); + }); +}); + +// --------------------------------------------------------------------------- +// Extended functionality +// --------------------------------------------------------------------------- + +describe('extended functionality', function () { + it('should execute all onFalse functions when condition is falsy', async function () { + let wasOnTrueExecuted = false; + let wasOnFalseExecuted = false; + let wasSecondOnFalseExecuted = false; + + await new ConditionallyExecute() + .onTrue(() => { wasOnTrueExecuted = true; }) + .onFalse(() => { wasOnFalseExecuted = true; }) + .onFalse(() => { wasSecondOnFalseExecuted = true; }) + .condition(1 === 2) + .execute(); + + assert.equal(wasOnFalseExecuted, true); + assert.equal(wasSecondOnFalseExecuted, true); + assert.equal(wasOnTrueExecuted, false); + }); + + it('should execute all onTrue functions when condition is truthy', async function () { + let wasOnTrueExecuted = false; + let wasSecondOnTrueExecuted = false; + let wasOnFalseExecuted = false; + + await new ConditionallyExecute() + .onTrue(() => { wasOnTrueExecuted = true; }) + .onTrue(() => { wasSecondOnTrueExecuted = true; }) + .onFalse(() => { wasOnFalseExecuted = true; }) + .execute(); + + assert.equal(wasOnFalseExecuted, false); + assert.equal(wasOnTrueExecuted, true); + assert.equal(wasSecondOnTrueExecuted, true); + }); + + it('should execute all onTrue functions when no condition is set', async function () { + let wasOnTrueExecuted = false; + let wasSecondOnTrueExecuted = false; + let wasOnFalseExecuted = false; + + await new ConditionallyExecute() + .onTrue(() => { wasOnTrueExecuted = true; }) + .onTrue(() => { wasSecondOnTrueExecuted = true; }) + .onFalse(() => { wasOnFalseExecuted = true; }) + .execute(); + + assert.equal(wasOnFalseExecuted, false); + assert.equal(wasOnTrueExecuted, true); + assert.equal(wasSecondOnTrueExecuted, true); + }); +}); + +// --------------------------------------------------------------------------- +// condition() semantics +// --------------------------------------------------------------------------- + +describe('condition() semantics', function () { + it('should coerce non-boolean truthy values to true', async function () { + let branch = null; + + await new ConditionallyExecute() + .condition('non-empty string') + .onTrue(() => { branch = 'true'; }) + .onFalse(() => { branch = 'false'; }) + .execute(); + + assert.equal(branch, 'true'); + }); + + it('should coerce non-boolean falsy values to false', async function () { + for (const falsy of [0, '', null, undefined, NaN]) { + let branch = null; + await new ConditionallyExecute() + .condition(falsy) + .onTrue(() => { branch = 'true'; }) + .onFalse(() => { branch = 'false'; }) + .execute(); + assert.equal(branch, 'false', `Expected false for condition(${String(falsy)})`); + } + }); + + it('should use last condition() call when called multiple times', async function () { + let branch = null; + + await new ConditionallyExecute() + .condition(false) + .condition(true) // last call wins + .onTrue(() => { branch = 'true'; }) + .onFalse(() => { branch = 'false'; }) + .execute(); + + assert.equal(branch, 'true'); + }); +}); + +// --------------------------------------------------------------------------- +// Async handlers +// --------------------------------------------------------------------------- + +describe('async handlers', function () { + it('should await async onTrue handlers', async function () { + let result = false; + + await new ConditionallyExecute() + .condition(true) + .onTrue(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + result = true; + }) + .execute(); + + assert.equal(result, true); + }); + + it('should await async onFalse handlers', async function () { + let result = false; + + await new ConditionallyExecute() + .condition(false) + .onFalse(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + result = true; + }) + .execute(); + + assert.equal(result, true); + }); + + it('should run multiple async handlers concurrently', async function () { + const order = []; + + await new ConditionallyExecute() + .condition(true) + .onTrue(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + order.push('slow'); + }) + .onTrue(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + order.push('fast'); + }) + .execute(); + + assert.equal(order.length, 2); + assert.ok(order.includes('slow')); + assert.ok(order.includes('fast')); + }); + + it('should propagate rejections from async handlers', async function () { + await assert.rejects( + () => new ConditionallyExecute() + .condition(true) + .onTrue(async () => { throw new Error('boom'); }) + .execute(), + /boom/ + ); + }); +}); + +// --------------------------------------------------------------------------- +// executeSync() +// --------------------------------------------------------------------------- + +describe('executeSync()', function () { + it('should execute onTrue synchronously when condition is true', function () { + let called = false; + new ConditionallyExecute() + .condition(true) + .onTrue(() => { called = true; }) + .executeSync(); + assert.equal(called, true); + }); + + it('should execute onFalse synchronously when condition is false', function () { + let called = false; + new ConditionallyExecute() + .condition(false) + .onFalse(() => { called = true; }) + .executeSync(); + assert.equal(called, true); + }); + + it('should not execute onFalse when condition is true (sync)', function () { + let called = false; + new ConditionallyExecute() + .condition(true) + .onTrue(() => {}) + .onFalse(() => { called = true; }) + .executeSync(); + assert.equal(called, false); + }); + + it('should execute multiple handlers in registration order (sync)', function () { + const order = []; + new ConditionallyExecute() + .condition(true) + .onTrue(() => { order.push(1); }) + .onTrue(() => { order.push(2); }) + .onTrue(() => { order.push(3); }) + .executeSync(); + assert.deepEqual(order, [1, 2, 3]); + }); +}); + +// --------------------------------------------------------------------------- +// Input validation +// --------------------------------------------------------------------------- + +describe('input validation', function () { + it('should throw TypeError when onTrue receives a non-function', function () { + assert.throws( + () => new ConditionallyExecute().onTrue('not a function'), + (err) => { assert.ok(err instanceof TypeError); assert.match(err.message, /onTrue/); return true; } + ); + }); + + it('should throw TypeError when onFalse receives a non-function', function () { + assert.throws( + () => new ConditionallyExecute().onFalse(42), + (err) => { assert.ok(err instanceof TypeError); assert.match(err.message, /onFalse/); return true; } + ); + }); + + it('should throw TypeError for null passed to onTrue', function () { + assert.throws( + () => new ConditionallyExecute().onTrue(null), + (err) => { assert.ok(err instanceof TypeError); assert.match(err.message, /onTrue/); return true; } + ); + }); + + it('should throw TypeError when use() receives a non-function', function () { + assert.throws( + () => new ConditionallyExecute().use('not a middleware'), + (err) => { assert.ok(err instanceof TypeError); assert.match(err.message, /use/); return true; } + ); + }); + + it('should throw TypeError when onError() receives a non-function', function () { + assert.throws( + () => new ConditionallyExecute().onError(123), + (err) => { assert.ok(err instanceof TypeError); assert.match(err.message, /onError/); return true; } + ); + }); +}); + +// --------------------------------------------------------------------------- +// TimeoutPlugin +// --------------------------------------------------------------------------- + +describe('TimeoutPlugin', function () { + it('should throw TimeoutError when handler exceeds timeout', async function () { + await assert.rejects( + () => new ConditionallyExecute() + .use(TimeoutPlugin(50)) + .condition(true) + .onTrue(async () => new Promise((r) => setTimeout(r, 200))) + .execute(), + (err) => { + assert.ok(err instanceof TimeoutError); + assert.equal(err.name, 'TimeoutError'); + assert.match(err.message, /50ms/); + return true; + } + ); + }); + + it('should not throw when handler completes within timeout', async function () { + let ran = false; + await new ConditionallyExecute() + .use(TimeoutPlugin(500)) + .condition(true) + .onTrue(async () => { + await new Promise((r) => setTimeout(r, 10)); + ran = true; + }) + .execute(); + assert.equal(ran, true); + }); + + it('should throw TypeError for invalid ms argument', function () { + assert.throws(() => TimeoutPlugin(0), /positive number/); + assert.throws(() => TimeoutPlugin(-1), /positive number/); + assert.throws(() => TimeoutPlugin('500'), /positive number/); + }); +}); + +// --------------------------------------------------------------------------- +// RetryPlugin +// --------------------------------------------------------------------------- + +describe('RetryPlugin', function () { + it('should retry failing handlers up to n times', async function () { + let attempts = 0; + await new ConditionallyExecute() + .use(RetryPlugin(2)) + .condition(true) + .onTrue(async () => { + attempts++; + if (attempts < 3) throw new Error('transient failure'); + }) + .execute(); + assert.equal(attempts, 3); // 1 initial + 2 retries — not 2, not 4 + }); + + it('should throw after exhausting retries', async function () { + let attempts = 0; + await assert.rejects( + () => new ConditionallyExecute() + .use(RetryPlugin(1)) + .condition(true) + .onTrue(() => { attempts++; throw new Error('always fails'); }) + .execute(), + /always fails/ + ); + assert.equal(attempts, 2); // exactly 2: 1 initial + 1 retry (not 1, not 3) + }); + + it('should not retry when n is 0', async function () { + let attempts = 0; + await assert.rejects( + () => new ConditionallyExecute() + .use(RetryPlugin(0)) + .condition(true) + .onTrue(() => { attempts++; throw new Error('fail'); }) + .execute(), + /fail/ + ); + assert.equal(attempts, 1); // exactly 1 — no retries + }); + + it('should apply exponential backoff between retries', async function () { + let attempts = 0; + const times = []; + await new ConditionallyExecute() + .use(RetryPlugin(2, { backoff: 'exponential' })) + .condition(true) + .onTrue(async () => { + times.push(Date.now()); + attempts++; + if (attempts < 3) throw new Error('transient'); + }) + .execute(); + assert.equal(attempts, 3); + // exponential: attempt 0→1: 100ms, attempt 1→2: 200ms + assert.ok(times[1] - times[0] >= 90, `backoff too short: ${times[1] - times[0]}ms`); + assert.ok(times[2] - times[1] >= 180, `backoff too short: ${times[2] - times[1]}ms`); + }); + + it('should apply linear backoff between retries', async function () { + let attempts = 0; + const times = []; + await new ConditionallyExecute() + .use(RetryPlugin(2, { backoff: 'linear' })) + .condition(true) + .onTrue(async () => { + times.push(Date.now()); + attempts++; + if (attempts < 3) throw new Error('transient'); + }) + .execute(); + assert.equal(attempts, 3); + // linear: attempt 0→1: 0ms, attempt 1→2: 100ms + assert.ok(times[2] - times[1] >= 90, `linear backoff too short: ${times[2] - times[1]}ms`); + }); + + it('should throw TypeError for invalid arguments', function () { + assert.throws(() => RetryPlugin(-1), /non-negative integer/); + assert.throws(() => RetryPlugin(1.5), /non-negative integer/); + assert.throws(() => RetryPlugin(1, { backoff: 'random' }), /backoff/); + }); +}); + +// --------------------------------------------------------------------------- +// DryRunPlugin +// --------------------------------------------------------------------------- + +describe('DryRunPlugin', function () { + it('should not execute handlers', async function () { + let called = false; + await new ConditionallyExecute() + .use(DryRunPlugin()) + .condition(true) + .onTrue(() => { called = true; }) + .execute(); + assert.equal(called, false); + }); + + it('should log what would have run', async function () { + const logs = []; + const origLog = console.log; + console.log = (...args) => logs.push(args.join(' ')); + try { + await new ConditionallyExecute() + .use(DryRunPlugin()) + .condition(false) + .onFalse(() => {}) + .onFalse(() => {}) + .execute(); + } finally { + console.log = origLog; + } + assert.equal(logs.length, 1); + assert.match(logs[0], /DryRun/); + assert.match(logs[0], /2/); + assert.match(logs[0], /onFalse/); + }); +}); + +// --------------------------------------------------------------------------- +// onError() +// --------------------------------------------------------------------------- + +describe('onError()', function () { + it('should call onError instead of throwing when handler fails', async function () { + let caughtError = null; + await new ConditionallyExecute() + .condition(true) + .onTrue(() => { throw new Error('handler blew up'); }) + .onError((err) => { caughtError = err; }) + .execute(); + assert.ok(caughtError instanceof Error); + assert.match(caughtError.message, /handler blew up/); + }); + + it('ConditionallyExecuteError should have correct name property', function () { + const err = new ConditionallyExecute.ConditionallyExecuteError('test'); + assert.equal(err.name, 'ConditionallyExecuteError'); + assert.equal(err.message, 'test'); + assert.ok(err instanceof Error); + }); + + it('should call onError with TimeoutError on timeout', async function () { + let caughtError = null; + await new ConditionallyExecute() + .use(TimeoutPlugin(30)) + .condition(true) + .onTrue(async () => new Promise((r) => setTimeout(r, 200))) + .onError((err) => { caughtError = err; }) + .execute(); + assert.ok(caughtError instanceof TimeoutError); + }); +}); + +// --------------------------------------------------------------------------- +// Middleware (.use()) +// --------------------------------------------------------------------------- + +describe('middleware (.use())', function () { + it('should call middleware before handler execution', async function () { + const log = []; + + await new ConditionallyExecute() + .use(async (ctx, next) => { + log.push('before'); + await next(); + log.push('after'); + }) + .condition(true) + .onTrue(() => { log.push('handler'); }) + .execute(); + + assert.deepEqual(log, ['before', 'handler', 'after']); + }); + + it('should expose correct branch in ctx', async function () { + let capturedCtx = null; + await new ConditionallyExecute() + .use(async (ctx, next) => { capturedCtx = ctx; await next(); }) + .condition(false) + .onTrue(() => {}) + .onFalse(() => {}) + .execute(); + assert.equal(capturedCtx.branch, 'onFalse'); + assert.equal(capturedCtx.condition, false); + }); + + it('should allow middleware to override condition', async function () { + let branch = null; + + const overrideToFalse = async (ctx, next) => { + ctx.condition = false; + ctx.branch = 'onFalse'; + ctx.handlers = ctx._onFalse; + await next(); + }; + + await new ConditionallyExecute() + .use(overrideToFalse) + .condition(true) + .onTrue(() => { branch = 'true'; }) + .onFalse(() => { branch = 'false'; }) + .execute(); + + assert.equal(branch, 'false'); + }); + + it('should compose multiple middlewares in order', async function () { + const log = []; + + await new ConditionallyExecute() + .use(async (ctx, next) => { log.push('mw1-in'); await next(); log.push('mw1-out'); }) + .use(async (ctx, next) => { log.push('mw2-in'); await next(); log.push('mw2-out'); }) + .condition(true) + .onTrue(() => { log.push('handler'); }) + .execute(); + + assert.deepEqual(log, ['mw1-in', 'mw2-in', 'handler', 'mw2-out', 'mw1-out']); + }); +}); + +// --------------------------------------------------------------------------- +// AuditLogPlugin +// --------------------------------------------------------------------------- + +describe('AuditLogPlugin', function () { + it('should log to the provided logger after execution', async function () { + const logs = []; + + await new ConditionallyExecute() + .use(AuditLogPlugin({ logger: (msg) => logs.push(msg) })) + .condition(true) + .onTrue(() => {}) + .execute(); + + assert.equal(logs.length, 1); + assert.match(logs[0], /ConditionallyExecute/); + assert.match(logs[0], /condition=true/); + assert.match(logs[0], /branch=onTrue/); + assert.match(logs[0], /handlers=1/); + assert.match(logs[0], /duration=/); + }); + + it('should not log when plugin is not used', async function () { + const logs = []; + const original = console.log; + console.log = (...args) => logs.push(args.join(' ')); + + try { + await new ConditionallyExecute() + .condition(true) + .onTrue(() => {}) + .execute(); + } finally { + console.log = original; + } + + assert.equal(logs.length, 0); + }); +}); + +// --------------------------------------------------------------------------- +// Named condition registry +// --------------------------------------------------------------------------- + +describe('named condition registry', function () { + afterEach(function () { + ConditionallyExecute.clearRegistry(); + }); + + it('should evaluate a registered condition by name', async function () { + ConditionallyExecute.register('alwaysTrue', () => true); + + let branch = null; + await new ConditionallyExecute() + .condition('alwaysTrue') + .onTrue(() => { branch = 'true'; }) + .onFalse(() => { branch = 'false'; }) + .execute(); + + assert.equal(branch, 'true'); + }); + + it('should NOT use registry for non-string condition values', async function () { + // Register string keys whose string representation matches truthy/falsy values. + // If typeof check is removed, passing a non-string would still miss the Map key + // (Map is type-strict). This test verifies boolean false is coerced, not registry-looked-up. + ConditionallyExecute.register('false', () => true); // would flip branch if used + let branch = null; + await new ConditionallyExecute() + .condition(false) // boolean, not string — must NOT hit registry + .onTrue(() => { branch = 'true'; }) + .onFalse(() => { branch = 'false'; }) + .execute(); + assert.equal(branch, 'false'); // Boolean(false) = false, registry not consulted + }); + + it('should support dynamic registered conditions', async function () { + let value = false; + ConditionallyExecute.register('dynamic', () => value); + + let branch = null; + + await new ConditionallyExecute() + .condition('dynamic') + .onTrue(() => { branch = 'true'; }) + .onFalse(() => { branch = 'false'; }) + .execute(); + assert.equal(branch, 'false'); + + value = true; + await new ConditionallyExecute() + .condition('dynamic') + .onTrue(() => { branch = 'true'; }) + .onFalse(() => { branch = 'false'; }) + .execute(); + assert.equal(branch, 'true'); + }); + + it('should clear registry via clearRegistry()', async function () { + // register a condition that returns FALSE — so if clear() did nothing, branch would be 'false' + ConditionallyExecute.register('myCondition', () => false); + ConditionallyExecute.clearRegistry(); + + let branch = null; + await new ConditionallyExecute() + .condition('myCondition') // not in registry after clear → Boolean('myCondition') = true + .onTrue(() => { branch = 'true'; }) + .onFalse(() => { branch = 'false'; }) + .execute(); + assert.equal(branch, 'true'); // proves clear() actually removed the entry + }); + + it('should remove a single entry via unregister()', async function () { + ConditionallyExecute.register('gone', () => false); + ConditionallyExecute.register('stays', () => false); + ConditionallyExecute.unregister('gone'); + + // 'gone' is no longer in registry → Boolean('gone') = true + let branch = null; + await new ConditionallyExecute() + .condition('gone') + .onTrue(() => { branch = 'true'; }) + .onFalse(() => { branch = 'false'; }) + .execute(); + assert.equal(branch, 'true'); + + // 'stays' still resolves via registry → false + await new ConditionallyExecute() + .condition('stays') + .onTrue(() => { branch = 'true'; }) + .onFalse(() => { branch = 'false'; }) + .execute(); + assert.equal(branch, 'false'); + }); + + it('should throw TypeError for invalid register() arguments with useful messages', function () { + assert.throws( + () => ConditionallyExecute.register(123, () => {}), + (err) => { assert.ok(err instanceof TypeError); assert.match(err.message, /string/); return true; } + ); + assert.throws( + () => ConditionallyExecute.register('name', 'not-a-fn'), + (err) => { assert.ok(err instanceof TypeError); assert.match(err.message, /function/); return true; } + ); + }); +}); + +// --------------------------------------------------------------------------- +// CollectErrorsPlugin +// --------------------------------------------------------------------------- + +describe('CollectErrorsPlugin', function () { + it('should collect all handler errors into AggregateError', async function () { + await assert.rejects( + () => new ConditionallyExecute() + .use(CollectErrorsPlugin()) + .condition(true) + .onTrue(() => { throw new Error('err1'); }) + .onTrue(() => { throw new Error('err2'); }) + .execute(), + (err) => { + assert.ok(err instanceof AggregateError); + assert.equal(err.errors.length, 2); + assert.match(err.errors[0].message, /err1/); + assert.match(err.errors[1].message, /err2/); + assert.match(err.message, /2 handler/); + return true; + } + ); + }); + + it('should not throw when all handlers succeed', async function () { + let count = 0; + await new ConditionallyExecute() + .use(CollectErrorsPlugin()) + .condition(true) + .onTrue(() => { count++; }) + .onTrue(() => { count++; }) + .execute(); + assert.equal(count, 2); + }); + + it('should not include successful handlers in error list', async function () { + await assert.rejects( + () => new ConditionallyExecute() + .use(CollectErrorsPlugin()) + .condition(true) + .onTrue(() => { /* succeeds */ }) + .onTrue(() => { throw new Error('only-this-fails'); }) + .execute(), + (err) => { + assert.ok(err instanceof AggregateError); + assert.equal(err.errors.length, 1); + assert.match(err.errors[0].message, /only-this-fails/); + return true; + } + ); + }); +}); diff --git a/packages/js/test/grpc.js b/packages/js/test/grpc.js new file mode 100644 index 0000000..59ad287 --- /dev/null +++ b/packages/js/test/grpc.js @@ -0,0 +1,130 @@ +'use strict'; + +const assert = require('assert'); +const ConditionallyExecute = require('../'); +const { GrpcConsensusPlugin, startGrpcNode, QuorumError } = require('../src/plugins/grpc-consensus'); + +// Use high ports to avoid conflicts +const PORTS = [52100, 52101, 52102]; + +describe('GrpcConsensusPlugin', function () { + this.timeout(15000); + + let nodes = []; + + before(async function () { + // Start 3 gRPC nodes, each with local handlers + nodes = await Promise.all( + PORTS.map((port, i) => + startGrpcNode(port, { + deploy: () => { /* handler runs on node */ }, + failing: () => { throw new Error(`node ${i} handler failed`); }, + }) + ) + ); + }); + + after(async function () { + await Promise.all(nodes.map((n) => n.close())); + }); + + it('should execute handler on all nodes and pass quorum', async function () { + let coordinatorRan = false; + + await new ConditionallyExecute() + .use(GrpcConsensusPlugin({ + nodes: PORTS.map((p) => `localhost:${p}`), + handlerName: 'deploy', + quorum: 2, + })) + .condition(true) + .onTrue(() => { coordinatorRan = true; }) + .execute(); + + assert.equal(coordinatorRan, true); + }); + + it('should skip coordinator handler when condition is false', async function () { + let coordinatorRan = false; + + await new ConditionallyExecute() + .use(GrpcConsensusPlugin({ + nodes: PORTS.map((p) => `localhost:${p}`), + handlerName: 'deploy', + quorum: 1, // condition=false → nodes return executed=false → quorum fails + })) + .condition(false) + .onTrue(() => { coordinatorRan = true; }) + .execute() + .catch(() => {}); // expected: quorum not reached + + assert.equal(coordinatorRan, false); + }); + + it('should throw QuorumError when quorum is not reached', async function () { + // Use a port with no running node → all RPC calls fail → quorum not reached + await assert.rejects( + () => new ConditionallyExecute() + .use(GrpcConsensusPlugin({ + nodes: ['localhost:59998', 'localhost:59999'], + handlerName: 'deploy', + quorum: 1, + timeout: 500, + })) + .condition(true) + .onTrue(() => {}) + .execute(), + (err) => { + assert.ok(err instanceof QuorumError, `Expected QuorumError, got ${err.constructor.name}: ${err.message}`); + assert.equal(err.reached, 0); + assert.equal(err.required, 1); + return true; + } + ); + }); + + it('should expose node results on QuorumError', async function () { + let caughtError = null; + + await new ConditionallyExecute() + .use(GrpcConsensusPlugin({ + nodes: ['localhost:59997'], + handlerName: 'deploy', + quorum: 1, + timeout: 300, + })) + .condition(true) + .onTrue(() => {}) + .onError((err) => { caughtError = err; }) + .execute(); + + assert.ok(caughtError instanceof QuorumError); + assert.ok(Array.isArray(caughtError.nodeResults)); + assert.equal(caughtError.nodeResults.length, 1); + assert.equal(caughtError.nodeResults[0].address, 'localhost:59997'); + }); + + it('should compose with other middleware', async function () { + const log = []; + + await new ConditionallyExecute() + .use(async (ctx, next) => { log.push('outer-in'); await next(); log.push('outer-out'); }) + .use(GrpcConsensusPlugin({ + nodes: PORTS.slice(0, 2).map((p) => `localhost:${p}`), + handlerName: 'deploy', + quorum: 1, + })) + .use(async (ctx, next) => { log.push('inner-in'); await next(); log.push('inner-out'); }) + .condition(true) + .onTrue(() => { log.push('coordinator'); }) + .execute(); + + assert.deepEqual(log, ['outer-in', 'inner-in', 'coordinator', 'inner-out', 'outer-out']); + }); + + it('should throw on invalid options', function () { + assert.throws(() => GrpcConsensusPlugin({ nodes: [], handlerName: 'x' }), /non-empty/); + assert.throws(() => GrpcConsensusPlugin({ nodes: ['a'], handlerName: '' }), /handlerName/); + assert.throws(() => GrpcConsensusPlugin({ nodes: ['a'], handlerName: 'x', quorum: 5 }), /cannot exceed/); + }); +}); diff --git a/packages/js/test/multi-threaded.js b/packages/js/test/multi-threaded.js new file mode 100644 index 0000000..7aa7abe --- /dev/null +++ b/packages/js/test/multi-threaded.js @@ -0,0 +1,104 @@ +'use strict'; + +/** + * Consensus plugin tests. + * Run standalone: node --experimental-worker test-consensus.js + * Or via parallel suite: node test-parallel.js + */ + +const assert = require('assert'); +const ConditionallyExecute = require('../'); +const { MultiThreadedPlugin } = require('../src/plugins/multi-threaded'); + +describe('MultiThreadedPlugin', function () { + this.timeout(10000); // consensus involves worker threads + + it('should execute onTrue when majority votes true', async function () { + let branch = null; + + await new ConditionallyExecute() + .use(MultiThreadedPlugin({ nodes: 3 })) + .condition(true) + .onTrue(() => { branch = 'true'; }) + .onFalse(() => { branch = 'false'; }) + .execute(); + + assert.equal(branch, 'true'); + }); + + it('should execute onFalse when majority votes false', async function () { + let branch = null; + + await new ConditionallyExecute() + .use(MultiThreadedPlugin({ nodes: 3 })) + .condition(false) + .onTrue(() => { branch = 'true'; }) + .onFalse(() => { branch = 'false'; }) + .execute(); + + assert.equal(branch, 'false'); + }); + + it('should work with 5 nodes', async function () { + let called = false; + + await new ConditionallyExecute() + .use(MultiThreadedPlugin({ nodes: 5 })) + .condition(true) + .onTrue(() => { called = true; }) + .execute(); + + assert.equal(called, true); + }); + + it('should work with jitter enabled (chaos mode)', async function () { + let called = false; + + await new ConditionallyExecute() + .use(MultiThreadedPlugin({ nodes: 3, jitter: true })) + .condition(true) + .onTrue(() => { called = true; }) + .execute(); + + assert.equal(called, true); + }); + + it('should compose with other middleware', async function () { + const log = []; + + await new ConditionallyExecute() + .use(async (ctx, next) => { log.push('outer-in'); await next(); log.push('outer-out'); }) + .use(MultiThreadedPlugin({ nodes: 3 })) + .use(async (ctx, next) => { log.push('inner-in'); await next(); log.push('inner-out'); }) + .condition(true) + .onTrue(() => { log.push('handler'); }) + .execute(); + + assert.deepEqual(log, ['outer-in', 'inner-in', 'handler', 'inner-out', 'outer-out']); + }); + + it('should throw on even node count', function () { + assert.throws( + () => MultiThreadedPlugin({ nodes: 4 }), + /odd/ + ); + }); + + it('should throw on node count < 3', function () { + assert.throws( + () => MultiThreadedPlugin({ nodes: 1 }), + /≥ 3/ + ); + }); + + it('should timeout when workers are too slow', async function () { + await assert.rejects( + () => new ConditionallyExecute() + .use(MultiThreadedPlugin({ nodes: 3, timeout: 1 })) // 1ms — impossible + .condition(true) + .onTrue(() => {}) + .execute(), + /timed out/ + ); + }); +}); diff --git a/packages/js/test/parallel.js b/packages/js/test/parallel.js new file mode 100644 index 0000000..8fb64fa --- /dev/null +++ b/packages/js/test/parallel.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Parallel test suite runner. + * + * Spawns each test file as a separate child process (via `npx mocha`), + * running all suites concurrently. Aggregates results and exits 1 if + * any suite fails. + * + * Usage: node test-parallel.js + * + * Because even your test suite deserves distributed execution. + */ + +const { spawn } = require('child_process'); +const path = require('path'); + +const TEST_SUITES = [ + { name: 'core ', file: 'test/core.js' }, + { name: 'multi-thread', file: 'test/multi-threaded.js' }, + { name: 'grpc ', file: 'test/grpc.js' }, +]; + +const startTime = Date.now(); + +console.log(`\n🚀 Running ${TEST_SUITES.length} test suites in parallel...\n`); +console.log('─'.repeat(60)); + +const jobs = TEST_SUITES.map(({ name, file }) => { + return new Promise((resolve) => { + const chunks = []; + + const proc = spawn('npx', ['--yes', 'mocha', '--timeout', '10000', file], { + cwd: path.resolve(__dirname, '..'), + env: { ...process.env, FORCE_COLOR: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + proc.stdout.on('data', (d) => chunks.push(d)); + proc.stderr.on('data', (d) => chunks.push(d)); + + proc.on('close', (code) => { + resolve({ + name, + file, + success: code === 0, + output: Buffer.concat(chunks).toString('utf8'), + }); + }); + + proc.on('error', (err) => { + resolve({ name, file, success: false, output: err.message }); + }); + }); +}); + +Promise.all(jobs).then((results) => { + for (const r of results) { + const icon = r.success ? '✅' : '❌'; + console.log(`\n${icon} [${r.name}] ${r.file}`); + console.log('─'.repeat(60)); + console.log(r.output.split('\n').map((l) => ' ' + l).join('\n')); + } + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); + const passed = results.filter((r) => r.success).length; + const failed = results.length - passed; + + console.log('─'.repeat(60)); + console.log(`\n📊 ${passed}/${results.length} suites passed in ${elapsed}s (ran concurrently)\n`); + + process.exit(failed > 0 ? 1 : 0); +}); diff --git a/proto/conditionally_execute.proto b/proto/conditionally_execute.proto new file mode 100644 index 0000000..7e37e8d --- /dev/null +++ b/proto/conditionally_execute.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +package conditionally_execute; + +// Each ConditionallyExecute node exposes this service. +// The coordinator calls Execute() on all nodes simultaneously. +// Each node runs its locally registered handler and reports back. +service ConditionallyExecuteNode { + // Execute a conditional handler on this node. + rpc Execute (ExecuteRequest) returns (ExecuteResponse); + + // Health check — verifies the node is reachable before execution. + rpc Health (HealthRequest) returns (HealthResponse); +} + +message ExecuteRequest { + // Unique ID for this execution round (for idempotency / tracing) + string request_id = 1; + + // The evaluated condition value + bool condition = 2; + + // Name of the handler to invoke (must be registered on the node) + string handler_name = 3; + + // Optional metadata passed through to the node + map metadata = 4; +} + +message ExecuteResponse { + // Node identifier + string node_id = 1; + + // Whether the handler was actually executed (false = handler not found / condition filtered it) + bool executed = 2; + + // Which branch ran: "onTrue" or "onFalse" + string branch = 3; + + // Wall-clock duration of handler execution on this node (ms) + double duration_ms = 4; + + // Non-empty if the handler threw an error + string error = 5; +} + +message HealthRequest {} + +message HealthResponse { + string node_id = 1; + + // "ok" | "degraded" | "unhealthy" + string status = 2; + + // Registered handler names on this node + repeated string handlers = 3; +} diff --git a/test.js b/test.js deleted file mode 100644 index fa9022b..0000000 --- a/test.js +++ /dev/null @@ -1,121 +0,0 @@ -'use strict'; - -require('mocha'); - -const assert = require('assert'); -const ConditionallyExecute = require('./'); - -describe('basic functionality', function () { - it('should execute onFalse when condition is falsy', function (done) { - let condExec = new ConditionallyExecute(); - let wasOnTrueExecuted = false; - let wasOnFalseExecuted = false; - condExec.onTrue(() => { - wasOnTrueExecuted = true; - }); - condExec.onFalse(() => { - wasOnFalseExecuted = true; - }); - condExec.condition(1 === 2).execute(); - assert.equal(wasOnFalseExecuted, true); - assert.equal(wasOnTrueExecuted, false); - done(); - }); - - it('should execute onTrue when condition is truthy', function (done) { - let condExec = new ConditionallyExecute(); - let wasOnTrueExecuted = false; - let wasOnFalseExecuted = false; - condExec.onTrue(() => { - wasOnTrueExecuted = true; - }); - condExec.onFalse(() => { - wasOnFalseExecuted = true; - }); - condExec.condition(1 === 1).execute(); - assert.equal(wasOnFalseExecuted, false); - assert.equal(wasOnTrueExecuted, true); - done(); - }); - - it('should execute onTrue when there is no conditions set', function (done) { - let condExec = new ConditionallyExecute(); - let wasOnTrueExecuted = false; - let wasOnFalseExecuted = false; - condExec.onTrue(() => { - wasOnTrueExecuted = true; - }); - condExec.onFalse(() => { - wasOnFalseExecuted = true; - }); - condExec.execute(); - assert.equal(wasOnFalseExecuted, false); - assert.equal(wasOnTrueExecuted, true); - done(); - }); -}); - -describe('extended functionality', function () { - it('should execute all onFalse functions when condition is falsy', function (done) { - let condExec = new ConditionallyExecute(); - let wasOnTrueExecuted = false; - let wasOnFalseExecuted = false; - let wasSecondOnFalseExecuted = false; - condExec.onTrue(() => { - wasOnTrueExecuted = true; - }); - condExec.onFalse(() => { - wasOnFalseExecuted = true; - }); - condExec.onFalse(() => { - wasSecondOnFalseExecuted = true; - }) - condExec.condition(1 === 2).execute(); - assert.equal(wasOnFalseExecuted, true); - assert.equal(wasSecondOnFalseExecuted, true); - assert.equal(wasOnTrueExecuted, false); - done(); - }); - - it('should execute all onTrue functions when condition is truthy', function (done) { - let condExec = new ConditionallyExecute(); - let wasOnTrueExecuted = false; - let wasSecondOnTrueExecuted = false; - let wasOnFalseExecuted = false; - condExec.onTrue(() => { - wasOnTrueExecuted = true; - }); - condExec.onTrue(() => { - wasSecondOnTrueExecuted = true; - }) - condExec.onFalse(() => { - wasOnFalseExecuted = true; - }); - condExec.execute(); - assert.equal(wasOnFalseExecuted, false); - assert.equal(wasOnTrueExecuted, true); - assert.equal(wasSecondOnTrueExecuted, true); - done(); - }); - - it('should execute all onTrue functions when there is no conditions set', function (done) { - let condExec = new ConditionallyExecute(); - let wasOnTrueExecuted = false; - let wasSecondOnTrueExecuted = false; - let wasOnFalseExecuted = false; - condExec.onTrue(() => { - wasOnTrueExecuted = true; - }); - condExec.onTrue(() => { - wasSecondOnTrueExecuted = true; - }) - condExec.onFalse(() => { - wasOnFalseExecuted = true; - }); - condExec.execute(); - assert.equal(wasOnFalseExecuted, false); - assert.equal(wasOnTrueExecuted, true); - assert.equal(wasSecondOnTrueExecuted, true); - done(); - }); -}); \ No newline at end of file