Skip to content

prod-ready: fix condition bug, async support, input validation, updated CI - #17

Open
twinksanderson wants to merge 14 commits into
bopke:masterfrom
twinksanderson:prod-ready-riced-x100
Open

prod-ready: fix condition bug, async support, input validation, updated CI#17
twinksanderson wants to merge 14 commits into
bopke:masterfrom
twinksanderson:prod-ready-riced-x100

Conversation

@twinksanderson

@twinksanderson twinksanderson commented May 16, 2026

Copy link
Copy Markdown

1. Condition state mutation was broken

Problem: The condition value was stored as this.True — a public property initialized to the boolean true. The guard (!!this.True) ? this.True = condition : null caused any subsequent .condition() call after the first truthy value to be silently ignored. Calling .condition(false).condition(true) would not update the condition. The public property also exposed internal state to accidental mutation by external code.

Fix: Renamed to this._condition. Replaced the guard with this._condition = Boolean(condition) — last call wins, always.


2. Promise return values from handlers were discarded

Problem: Handlers could return Promises, but execute() never awaited them — they would fire and be forgotten, with no way to know if they resolved or rejected.

Fix: execute() is now async. All active handlers are collected and awaited concurrently via Promise.all, with rejection propagation intact.


3. Invalid handler arguments failed at the wrong time

Problem: Passing a non-function to onTrue() or onFalse() produced no error at the call site. The failure surfaced later at .execute() time with a confusing message, making the root cause hard to trace.

Fix: Both methods now validate the argument immediately and throw TypeError with a descriptive message pointing to the offending call.


4. No structured error handling path

Problem: Any exception thrown inside a handler propagated directly to the caller with no interception point. There was no way to handle errors gracefully without wrapping every .execute() call in try/catch.

Fix: Added .onError(fn). When set, errors are routed to the handler instead of being re-thrown. Execution completes normally from the caller's perspective.


5. No execution timeout

Problem: A slow or hanging handler would block indefinitely. There was no mechanism to enforce a time bound on execution.

Fix: .timeout(ms) wraps the execution in a Promise.race against a rejection timer. On breach, throws TimeoutError — a subclass of ConditionallyExecuteError — with the configured duration in the message.


6. No retry support

Problem: Transient handler failures required manual retry logic at the call site, duplicated across every use.

Fix: .retry(n, { backoff }) retries the handler invocation up to n times. Supports 'none', 'linear' (n × 100ms), and 'exponential' (2ⁿ × 100ms) backoff strategies.


7. No composability layer

Problem: Cross-cutting concerns — logging, tracing, feature flags, auth checks — had to be embedded directly into handlers or duplicated across call sites. There was no interception layer.

Fix: .use(middleware) installs an Express-style middleware function receiving (ctx, next). Middleware can inspect or mutate ctx.condition, ctx.branch, and ctx.handlers before and after execution. Middleware composes in registration order.


8. Condition logic was always inline

Problem: Condition functions could not be named, shared, or tested in isolation. Every use site required an inline expression or a manually imported function.

Fix: ConditionallyExecute.register(name, fn) stores named condition functions in a static Map. Passing a string to .condition('name') resolves it from the registry at execute time. unregister() and clearRegistry() allow cleanup in tests.


9. No synchronous execution path

Problem: Synchronous use cases paid the full async overhead of Promise.all even when no async work was involved.

Fix: executeSync() executes handlers in a tight for-loop with no Promise construction. Measured overhead vs. native if:

Variant Time Overhead
Native if ~0.07 µs
executeSync() ~0.09 µs ~1.4×
execute() ~0.41 µs ~5×

10. No distributed execution primitives

Problem: The library had no extensibility story for running handlers across multiple nodes or enforcing agreement before proceeding. The plugin system added in this PR enables this, but no plugins existed in the original codebase.

Fix: Two plugins added under plugins/:

MultiThreadedPlugin — in-process consensus via worker_threads. Spins up N worker threads, each votes on the condition independently, coordinator proceeds only when majority agrees.

GrpcConsensusPlugin — real distributed execution over gRPC (HTTP/2 + Protobuf). The coordinator fans out Execute() RPC calls to all registered node addresses simultaneously. Each node runs its locally-registered handler and reports back. If fewer than quorum nodes confirm successful execution, a QuorumError is thrown containing .reached, .required, .total, and .nodeResults[] for diagnostics.

const n1 = await startGrpcNode(50051, { deploy: () => { /* node 1 work */ } });
const n2 = await startGrpcNode(50052, { deploy: () => { /* node 2 work */ } });
const n3 = await startGrpcNode(50053, { deploy: () => { /* node 3 work */ } });

await new ConditionallyExecute()
  .use(GrpcConsensusPlugin({
    nodes: ['localhost:50051', 'localhost:50052', 'localhost:50053'],
    handlerName: 'deploy',
    quorum: 2,
  }))
  .condition(isReadyForDeploy)
  .onTrue(() => console.log('Quorum reached. Proceeding.'))
  .execute();

await Promise.all([n1.close(), n2.close(), n3.close()]);

11. Test coverage was insufficient to detect regressions

Problem: The original suite had 6 tests covering the happy path only. No async coverage, no edge case coverage, no way to detect silent regressions in condition evaluation or branch selection.

Fix: 61 tests across three suites, run in parallel via node test-parallel.js:

Suite Tests
Core (test.js) 47
MultiThreadedPlugin (test-multi-threaded.js) 8
GrpcConsensusPlugin (test-grpc.js) 6

Stryker mutation testing applied to index.js: 89.57% mutation score (143 killed / 3 timeout / 15 survived / 2 no-coverage). Configured thresholds: high ≥ 80%, low ≥ 60%, break < 50%.


12. CI configuration was outdated

Problem: Workflow used actions/checkout@v2 and actions/setup-node@v1 (both deprecated). Test matrix included Node 16 (EOL since September 2023). No linting step.

Fix: Updated to actions/checkout@v4 and actions/setup-node@v4 with npm cache. Removed Node 16 from the matrix. Added engines: { node: ">=18" } to package.json. Added ESLint with a lint step in CI.

…ed CI

- Fix `this.True` (public, misleading) → `this._condition` (private, correct)
- Fix broken multi-condition semantics: last `.condition()` call now wins
  (previously: second call was silently ignored if first returned falsy)
- Add input validation: `onTrue()`/`onFalse()` throw `TypeError` for non-functions
- Make `.execute()` async — handlers run concurrently via `Promise.all`
- Fix README typo: `conditionaly-execute` → `conditionally-execute`
- Add full API documentation and async/default-condition examples to README
- Add `.eslintrc.js` with reasonable ESLint config
- Add `lint` script to package.json, `engines: { node: ">=18" }`
- Update CI: `actions/checkout@v2` → `v4`, `setup-node@v1` → `v4` with cache
- Remove no-op `npm run build --if-present` from CI, add lint step
- Expand tests: 16 total (was 6) — covers async, input validation,
  falsy coercion, multiple condition() calls, concurrent handler execution
@twinksanderson
twinksanderson force-pushed the prod-ready-riced-x100 branch from d0f69af to 4e5e6b4 Compare May 16, 2026 16:18
TypeScript:
- Migrate source to src/index.ts with strict mode
- Add ConditionallyExecuteOptions interface (initialCondition, collectErrors)
- collectErrors mode: AggregateError on multi-handler failures
- Full JSDoc with @example, @throws, @SInCE on all public members
- tsconfig.json with strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes
- package.json: types, exports map, version bump to 2.0.0

Tooling:
- .prettierrc — singleQuote, trailingComma es5, printWidth 100
- .editorconfig — utf-8, LF, 2-space indent
- .eslintrc.js — updated for TypeScript source path

CI:
- Split into test job (matrix 18/20/22/24) + typecheck job (LTS)
- Both jobs use checkout@v4, setup-node@v4 with npm cache

GitHub community files:
- .github/ISSUE_TEMPLATE/bug_report.yml (structured form)
- .github/ISSUE_TEMPLATE/feature_request.yml
- .github/PULL_REQUEST_TEMPLATE.md
- .github/dependabot.yml (npm + github-actions, weekly)
- CONTRIBUTING.md (setup, scripts, commit convention, code style)
- CODE_OF_CONDUCT.md (Contributor Covenant 2.1)
- SECURITY.md (supported versions, private reporting instructions)
- CHANGELOG.md (Keep a Changelog format, full 1.0.0→2.0.0 diff)

Documentation:
- README: 9 badges, Table of Contents, Why section, full API reference,
  Advanced usage (async, multiple handlers, collectErrors, refactoring guide),
  Performance benchmark table, TypeScript section
- bench.js: 100k-iteration benchmark comparing native if vs ConditionallyExecute
  (spoiler: native if wins, but the DX gains are worth it)
Adds executeSync() variant that runs handlers in a tight for-loop
instead of Promise.all. Real measured overhead: ~1.4x vs native if
(down from ~5x with execute()). No fake numbers.

Tests expanded 16 → 20, all passing. Benchmark updated with real
numbers and when-to-use guide.
Tier 1:
- timeout(ms) option — throws TimeoutError via Promise.race
- retry(n, { backoff }) option — exponential/linear backoff between retries
- dryRun: true option — logs what would run, touches nothing
- onError(fn) — catches handler errors without try/catch

Tier 2:
- .use(middleware) — Express-style middleware chain with ctx mutation
- ConditionallyExecute.register(name, fn) — named condition registry
- auditLog: true option — structured stdout log per execution

Consensus plugin (plugins/consensus.js):
- ConsensusPlugin({ nodes, timeout, jitter }) middleware
- Spawns N worker_threads as independent consensus nodes
- Each node votes via MessageChannel (in-process RPC)
- Majority vote overrides ctx.condition before handlers run
- chaos mode (jitter: true) adds random RTT variance per node

Tests: 20 → 45 (37 core + 8 consensus)
Parallel test runner (test-parallel.js):
- Spawns each suite as a separate child process concurrently
- Aggregates pass/fail, exits 1 on any failure
Adds GrpcConsensusPlugin — each node is a real gRPC server (HTTP/2 +
Protobuf). Coordinator fans out Execute() to all nodes simultaneously.
Each node runs its locally-registered handler. QuorumError thrown if
fewer than `quorum` nodes confirm success.

- plugins/proto/conditionally_execute.proto — typed schema
- plugins/grpc-consensus.js — GrpcConsensusPlugin, startGrpcNode, QuorumError
- plugins/consensus.js → plugins/multi-threaded.js (honest rename)
- test-grpc.js — 6 gRPC tests (real servers on localhost)
- test-parallel.js — now runs 3 suites: core + multi-thread + grpc

Total: 51/51 tests, 3 suites in parallel
Added stryker.config.mjs targeting index.js with mocha runner.
Killed 40 survived mutants by:
- asserting error message content (not just TypeError type)
- asserting TimeoutError.name === 'TimeoutError'
- testing clearRegistry() actually clears (not no-op)
- testing that numeric conditions skip registry lookup (typeof guard)
- testing auditLog output via console.log spy
- testing backoff timing (exponential/linear measured with Date.now())
- testing exact retry counts at boundary (0, 1, 2 retries)
- testing collectErrors only collects rejected (not fulfilled) handlers
- asserting ctx.branch value in middleware
- asserting AggregateError message content

Final: 143 killed / 3 timeout / 15 survived / 2 no-cov (89.57%)
Threshold: high=80 low=60 break=50 ✅
Core index.js is now lean — condition, onTrue, onFalse, onError,
use(), execute(), executeSync(), named registry. No constructor options.

New plugins under plugins/:
- TimeoutPlugin(ms)       — Promise.race with TimeoutError
- RetryPlugin(n, opts)    — per-handler retry with backoff strategies
- DryRunPlugin()          — skip handlers, log intent
- AuditLogPlugin(opts)    — structured log entry with custom logger sink
- CollectErrorsPlugin()   — AggregateError instead of short-circuit

plugins/index.js barrel-exports all first-party plugins.

All tests updated to plugin-based API. 63 tests passing across 3 suites.
Fix 3 surviving mutants:
- clearRegistry/unregister: tests now register fn returning false so
  clearing actually changes observable behavior
- ConditionallyExecuteError.name: explicit assertion on .name property
- typeof condition === 'string': redundant check removed (register()
  enforces string keys, Map.has() is type-strict so the guard added
  no protection); removal makes mutation killable and code simpler
…acts

- Move tests to test/ (core.js, grpc.js, multi-threaded.js, parallel.js)
- Remove src/index.ts, tsconfig.json — TypeScript migration was reverted
- Remove bun.lock — repo uses npm
- Fix package.json: main/exports → index.js, files → [index.js, plugins/],
  drop typescript/@types/node devDeps, grpc pkgs → peerDeps (optional),
  fix all script references
- Migrate ESLint to flat config (eslint.config.js, add @eslint/js)
- Remove Build and TypeScript typecheck steps from CI workflow
- Fix all lint errors (unused vars, useless assignments, constant conditions)
- Add reports/ and bun lock files to .gitignore
The JS module moves into packages/js/ and the gRPC schema into a top-level
proto/ directory so it can be shared with the upcoming Java module without
duplicating the contract.

- packages/js/ — JS source, tests, build config, README (no behavior change;
  package.json main now resolves via ./src/index.js)
- proto/conditionally_execute.proto — shared gRPC schema (moved from
  plugins/proto/), referenced from packages/js/src/plugins/grpc-consensus.js
- package.json (root) — npm workspaces declaration pointing at packages/js
- .github/workflows/nodejs.yml — runs from packages/js/ working dir, with
  cache-dependency-path scoped to the workspace lockfile
- README.md (root) — monorepo overview; original README preserved at
  packages/js/README.md

No npm/runtime behavior change for consumers — the published package still
imports as 'conditionally-execute' and exports the same surface.
Adds packages/java/ as a sibling to packages/js/, implementing the entire
conditionally-execute API surface in idiomatic Java. Targets JDK 25 with
modern features: records, sealed exception hierarchy, virtual threads
(MultiThreadedPlugin), pattern matching for switch, CompletableFuture for
the async model.

The gRPC plugin in either language can interop with the other — both
modules share proto/conditionally_execute.proto. JS coordinator → Java
nodes, Java coordinator → JS nodes, mixed-language quorums.

## API surface (one-to-one with JS)

Core:
- ConditionallyExecute (builder: condition / onTrue / onFalse / onError /
  onErrorAsync / use / execute / executeSync; static register / unregister
  / clearRegistry)
- Context (mutable — middleware can override condition/branch/handlers
  matching JS semantics)
- Branch (enum)
- Handler (functional interface with .sync(Runnable) / .async(Supplier))
- Middleware (BiFunction-shaped) + Next
- ConditionallyExecuteError (RuntimeException base)
- AggregateException (JS AggregateError equivalent)

Plugins (com.bopke.conditionallyexecute.plugins):
- TimeoutPlugin + TimeoutError
- RetryPlugin (Backoff.NONE / LINEAR / EXPONENTIAL)
- AuditLogPlugin
- CollectErrorsPlugin
- DryRunPlugin
- MultiThreadedPlugin (uses virtual threads instead of JS worker_threads)
- GrpcConsensusPlugin + QuorumError + NodeResult
- GrpcNodeServer (test-time server; equivalent to JS startGrpcNode)

## Build

- Gradle Kotlin DSL (packages/java/build.gradle.kts)
- foojay-resolver-convention plugin auto-provisions JDK 25 toolchain
- protobuf-gradle-plugin 0.9.4 generates Java + gRPC stubs from the
  shared proto, with a stageProto task that injects java_package /
  java_multiple_files options without modifying the source-of-truth file
- Test stack: JUnit 5 (Jupiter) + AssertJ

## Tests

66 tests across CoreTest, MultiThreadedTest, GrpcConsensusTest mirroring
the JS suite (47 + 8 + 6 = 61 minimum target, plus a few Java-specific
input validation variants).

## CI

.github/workflows/java.yml runs Gradle build + tests on Temurin JDK 25.
Kotlin DSL shadows the 'java' identifier with the Java extension
namespace, so 'java.time.Duration' resolves to the wrong thing.
Use an explicit import instead.
close() returns CompletableFuture<Void> (async graceful shutdown), which
is incompatible with AutoCloseable.close() returning void. The async API
is what tests and consumers actually need; the AutoCloseable marker was
unused (no try-with-resources usage).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant