prod-ready: fix condition bug, async support, input validation, updated CI - #17
Open
twinksanderson wants to merge 14 commits into
Open
prod-ready: fix condition bug, async support, input validation, updated CI#17twinksanderson wants to merge 14 commits into
twinksanderson wants to merge 14 commits into
Conversation
…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
force-pushed
the
prod-ready-riced-x100
branch
from
May 16, 2026 16:18
d0f69af to
4e5e6b4
Compare
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1. Condition state mutation was broken
Problem: The condition value was stored as
this.True— a public property initialized to the booleantrue. The guard(!!this.True) ? this.True = condition : nullcaused 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 withthis._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 nowasync. All active handlers are collected and awaited concurrently viaPromise.all, with rejection propagation intact.3. Invalid handler arguments failed at the wrong time
Problem: Passing a non-function to
onTrue()oronFalse()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
TypeErrorwith 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 aPromise.raceagainst a rejection timer. On breach, throwsTimeoutError— a subclass ofConditionallyExecuteError— 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 tontimes. 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 mutatectx.condition,ctx.branch, andctx.handlersbefore 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 staticMap. Passing a string to.condition('name')resolves it from the registry at execute time.unregister()andclearRegistry()allow cleanup in tests.9. No synchronous execution path
Problem: Synchronous use cases paid the full async overhead of
Promise.alleven when no async work was involved.Fix:
executeSync()executes handlers in a tight for-loop with no Promise construction. Measured overhead vs. nativeif:ifexecuteSync()execute()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 viaworker_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 outExecute()RPC calls to all registered node addresses simultaneously. Each node runs its locally-registered handler and reports back. If fewer thanquorumnodes confirm successful execution, aQuorumErroris thrown containing.reached,.required,.total, and.nodeResults[]for diagnostics.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:test.js)test-multi-threaded.js)test-grpc.js)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@v2andactions/setup-node@v1(both deprecated). Test matrix included Node 16 (EOL since September 2023). No linting step.Fix: Updated to
actions/checkout@v4andactions/setup-node@v4with npm cache. Removed Node 16 from the matrix. Addedengines: { node: ">=18" }topackage.json. Added ESLint with a lint step in CI.