Skip to content

Support async rules in eager enforce(value) chains with awaitable chains and type updates#1261

Open
ealush wants to merge 4 commits intolatestfrom
codex/implement-async-rule-support-in-enforce-library
Open

Support async rules in eager enforce(value) chains with awaitable chains and type updates#1261
ealush wants to merge 4 commits intolatestfrom
codex/implement-async-rule-support-in-enforce-library

Conversation

@ealush
Copy link
Copy Markdown
Owner

@ealush ealush commented Mar 15, 2026

Summary

Adds async rule support to the eager enforce(value) API. Custom rules registered via enforce.extend() can now return Promises. The chain becomes awaitable once an async rule is invoked.

enforce.extend({
  isAvailable: async (value: string) => {
    const taken = await checkUsername(value);
    return { pass: !taken, message: () => 'Username taken' };
  },
});

await enforce('ealush').isString().isAvailable(); // awaitable chain

Key behaviors

  • Sync chains are unaffected.then/.catch/.finally are only exposed at runtime after an async rule is called (no breaking change for existing sync usage)
  • Fail-fast — sync rules before an async rule throw immediately; sync rules after an async rule are queued behind the promise
  • .pass is reliable — set to true optimistically, updated to false if the promise rejects
  • .finally() support — full Promise-like ergonomics on async chains
  • Lazy API guardenforce.asyncRule().run(value) throws a clear error directing users to the eager API
  • Type-level async trackingAsyncMode type parameter propagates through the chain; ThenableEnforce (with .then/.catch/.finally) is only intersected when AsyncMode extends true
  • Context preservedtransformResult runs inside ctx.run() so message functions using enforce.context() work correctly in both sync and async paths

Test coverage (50 tests)

  • Basic pass/fail for { pass } results, boolean results, delayed rules
  • Async rules with additional arguments
  • Error messages: message functions, message strings, default messages, .message() override
  • Chain ordering: sync→async, async→sync, multi-async, interleaved
  • .pass property: optimistic true, stays true on resolve, set false on reject
  • Thenable behavior: sync not thenable, async thenable, .then/.catch/.finally, try/catch
  • Lazy API guard: .run() and .test() throw with rule name and guidance
  • Execution order guarantees and short-circuit on failure
  • Independent chain isolation

Benchmarks (16 benchmarks across 5 groups)

  • Single rule: immediate pass (~1M ops/s), immediate fail (~410K ops/s), delayed
  • Chain patterns: sync→async, async→sync, multi-sync+async, two async, interleaved
  • Sync baseline comparison
  • Message handling: message function pass/fail, .message() override
  • Throughput: sequential vs concurrent (10x), sync baseline

Test plan

  • All 1222 n4s tests pass (50 new async tests)
  • Full suite: 2621 tests pass
  • Zero TypeScript errors
  • All lint rules pass (complexity, max-statements, max-params, max-lines)
  • Benchmarks run successfully
  • Type test: sync chains don't expose .then at type level
  • Type test: async chains are properly typed as awaitable

Summary by CodeRabbit

  • New Features

    • Full support for asynchronous custom validation rules, with correct promise chaining across mixed sync/async sequences.
    • Enforce objects now expose then/catch/finally semantics for async flows.
  • Behavior Changes

    • Lazy-style validation now rejects when given async rules (explicit error for promise-returning rules).
    • Validation chains report pass/fail immediately then update on async resolution.
  • Tests

    • Expanded tests and benchmarks covering async rule behavior, ordering, error messages, and integration.

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vest Ready Ready Preview, Comment Apr 1, 2026 6:32pm
vest-next Ready Ready Preview, Comment Apr 1, 2026 6:32pm

@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Support async rules in eager enforce chains with promise queuing and type updates

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add async rule support to eager enforce(value) chains with Promise handling
• Implement internal pendingPromise tracking to queue rules after async execution
• Extend type system with AsyncMode generics to reflect async transitions
• Preserve fail-fast sync behavior while enabling awaitable chains
• Add comprehensive tests for async custom rules and type safety
Diagram
flowchart LR
  A["enforce(value)"] --> B["Sync Rule Execution"]
  B --> C{Rule Returns Promise?}
  C -->|No| D["Continue Sync Chain"]
  C -->|Yes| E["Create pendingPromise"]
  E --> F["Queue Subsequent Rules"]
  F --> G["Return Awaitable Chain"]
  D --> H["Return Proxy"]
  G --> I["Implement then/catch"]
Loading

Grey Divider

File Changes

1. packages/n4s/src/eager.ts ✨ Enhancement +35/-2

Add pendingPromise tracking and reserved property handlers

packages/n4s/src/eager.ts


2. packages/n4s/src/eager/eagerTypes.ts ✨ Enhancement +45/-21

Add AsyncMode generic parameter for type propagation

packages/n4s/src/eager/eagerTypes.ts


3. packages/n4s/src/eager/ruleCallGenerator.ts ✨ Enhancement +68/-14

Implement promise detection and rule queuing logic

packages/n4s/src/eager/ruleCallGenerator.ts


View more (6)
4. packages/n4s/src/n4sTypes.ts ✨ Enhancement +14/-3

Allow Promise returns in custom matchers with async tracking

packages/n4s/src/n4sTypes.ts


5. packages/n4s/src/__tests__/asyncEnforce.types.test.ts 🧪 Tests +31/-0

Test async enforce type safety and awaitable chains

packages/n4s/src/tests/asyncEnforce.types.test.ts


6. packages/n4s/src/__tests__/extendAsync.test.ts 🧪 Tests +18/-0

Test async custom rule registration and execution

packages/n4s/src/tests/extendAsync.test.ts


7. packages/n4s/src/__tests__/extend.test.ts 🧪 Tests +2/-4

Update test to verify promise-returning rules work

packages/n4s/src/tests/extend.test.ts


8. packages/n4s/src/rules/__tests__/asyncEnforce.test.ts 🧪 Tests +49/-0

Test async enforcement with fail-fast and queuing behavior

packages/n4s/src/rules/tests/asyncEnforce.test.ts


9. plans/async_enforcements_summary.md 📝 Documentation +25/-0

Document async enforcement architecture and guarantees

plans/async_enforcements_summary.md


Grey Divider

Qodo Logo

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 15, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fdd12667-1da4-49f6-92b3-c736d54237d6

📥 Commits

Reviewing files that changed from the base of the PR and between 54c7048 and da1eae8.

📒 Files selected for processing (5)
  • packages/n4s/src/__tests__/extendAsync.test.ts
  • packages/n4s/src/eager.ts
  • packages/n4s/src/eager/eagerTypes.ts
  • packages/n4s/src/eager/ruleCallGenerator.ts
  • packages/vest/bench/enforce-async.bench.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/n4s/src/tests/extendAsync.test.ts
  • packages/n4s/src/eager.ts
  • packages/n4s/src/eager/eagerTypes.ts

📝 Walkthrough

Walkthrough

Adds comprehensive async/promise support to the N4S enforce API: types track async mode, eager chains become thenable, rule execution tracks a pending promise and chains async rule results, and lazy APIs now reject promise-returning rules.

Changes

Cohort / File(s) Summary
Type System & Async Mode
packages/n4s/src/eager/eagerTypes.ts, packages/n4s/src/n4sTypes.ts
Introduce AsyncMode propagation, add ThenableEnforce, IsPromiseLike and NextAsyncMode helpers; update many rule/return types to conditionally include thenable behavior.
Eager runtime & proxy changes
packages/n4s/src/eager.ts
Add PROMISE_KEYS handling, track pendingPromise, expose pass on returned proxy, and route then/catch/finally access to the pending promise when present.
Rule execution & chaining
packages/n4s/src/eager/ruleCallGenerator.ts
Add isPromise checks, introduce processRuleResult and setAsyncResult, add getPendingPromise/setPendingPromise hooks, schedule rule execution after prior pending promises, and set proxy pass immediately with async rejection setting false.
Lazy API constraint
packages/n4s/src/extendLogic.ts
Add invariant to throw if a lazy rule returns a Promise (lazy API now rejects promise-returning rules).
Tests: async matchers & behavior
packages/n4s/src/__tests__/asyncEnforce.types.test.ts, packages/n4s/src/__tests__/extend.test.ts, packages/n4s/src/__tests__/extendAsync.test.ts, packages/n4s/src/rules/__tests__/asyncEnforce.test.ts
Add type augmentations for async matchers; new and updated tests covering async extend, chaining, queuing, fail-fast, thenable behavior, message scoping, ordering, and lazy vs eager differences.
Benchmarks
packages/vest/bench/enforce-async.bench.ts
Add Vitest benchmarks exercising various async/sync chain patterns, message handling, and throughput scenarios for async rules.

Sequence Diagram(s)

sequenceDiagram
    participant User as User Code
    participant Chain as Enforce Chain (proxy)
    participant RuleExec as Rule Executor
    participant Pending as PendingPromise Handler

    User->>Chain: enforce('value').isString()
    Chain->>RuleExec: execute sync rule
    RuleExec-->>Chain: sync result (pass/fail)
    Chain-->>User: return proxy (sync)

    User->>Chain: .isAsyncRule()
    Chain->>RuleExec: call async rule
    RuleExec->>Pending: returns Promise
    Pending-->>Chain: set pendingPromise
    Chain-->>User: return thenable proxy (pass=true)

    User->>Chain: await / .then()
    Chain->>Pending: access then/catch handler
    Pending->>RuleExec: process resolved result
    RuleExec-->>Pending: validate transformedResult.pass
    Pending-->>User: resolve void or reject Error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I dug a tunnel through promise fields,
I queued my hops where async yields.
Rules now wait, then tidy pass,
Lazy gardens close their glass.
Hooray — a rabbit's async dance! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main changes: adding async rule support to eager enforce chains with awaitable return types and updated TypeScript types.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/implement-async-rule-support-in-enforce-library

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented Mar 15, 2026

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Context lost during messages🐞 Bug ≡ Correctness
Description
transformResult() now runs outside ctx.run(), so rule-returned message functions that rely on
enforce.context() will see no context and can produce incorrect/missing messages. This affects
both sync rules and queued async rules because the context implementation resets immediately after
the ctx.run callback returns.
Code

packages/n4s/src/eager/ruleCallGenerator.ts[R19-50]

+function processRuleResult(
+  result: any,
+  ruleName: string,
+  value: any,
+  customMessage: string | undefined,
+  args: any[],
+): void {
+  const transformedResult = transformResult(result, ruleName, value, ...args);
+
+  invariant(
+    transformedResult.pass,
+    enforceMessage(ruleName, transformedResult, value, customMessage),
+  );
+}
+
export function createRuleCall(config: RuleCallConfig) {
-  const { target, rule, ruleName, value, customMessage, clearMessage } = config;
+  const {
+    target,
+    rule,
+    ruleName,
+    value,
+    customMessage,
+    clearMessage,
+    getPendingPromise,
+    setPendingPromise,
+  } = config;
return function ruleCall(...args: any[]): any {
-    const transformedResult = ctx.run({ value }, () =>
-      transformResult(
+    const runRule = () =>
+      ctx.run({ value }, () =>
      (rule as (...args: any[]) => any)(value, ...args),
-        ruleName,
-        value,
-        ...args,
-      ),
-    );
+      );
Evidence
ctx.run is synchronous and restores the prior context immediately after the callback returns, so
any message evaluation happening after ctx.run(...) cannot read context. processRuleResult calls
transformResult, which executes result.message via dynamicValue; enforce.context() reads
ctx.use(), which returns the default (no active context) outside a ctx.run.

packages/n4s/src/eager/ruleCallGenerator.ts[19-50]
packages/context/src/context.ts[37-46]
packages/n4s/src/ruleResult.ts[27-44]
packages/n4s/src/n4s.ts[90-92]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`transformResult()` is executed outside `ctx.run()`, so message functions (`result.message`) evaluated via `dynamicValue` cannot read `enforce.context()` (the context is reset immediately after `ctx.run` returns).
### Issue Context
`packages/context/src/context.ts` shows `ctx.run` restores the parent context right after the callback returns, so any result/message processing must happen inside the callback if it needs context.
### Fix Focus Areas
- packages/n4s/src/eager/ruleCallGenerator.ts[19-95]
Suggested approach:
- Move `processRuleResult(...)` invocation inside `ctx.run({ value }, ...)`.
- For async rules, when the promise resolves, wrap the `processRuleResult(resolvedResult, ...)` call in a fresh `ctx.run({ value }, ...)` so message functions can still access context at evaluation time.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Async rules break lazy API🐞 Bug ≡ Correctness
Description
Promise-returning custom rules registered via enforce.extend(...) are also registered into the
lazy RuleInstance API, but lazy execution wraps results with RuleRunReturn.create, which treats
a Promise as an invalid object and returns pass: false. This makes
enforce.myAsyncRule().run(value) silently fail even when the async rule would pass in eager mode.
Code

packages/n4s/src/n4sTypes.ts[R44-53]

+type IsPromiseLike<T> = T extends PromiseLike<any> ? true : false;
+type NextAsyncMode<
+  CurrentAsync extends boolean,
+  RuleReturn,
+> = CurrentAsync extends true ? true : IsPromiseLike<RuleReturn>;
+
export type EnforceCustomMatcher<F extends CB> = (
...args: CustomMatcherArgs<F extends keyof n4s.EnforceMatchers ? F : never>
-) => boolean | RuleRunReturn<any>;
+) => boolean | RuleRunReturn<any> | Promise<boolean | RuleRunReturn<any>>;
Evidence
This PR explicitly allows custom matchers to return Promises at the type level, and the eager
runtime now accepts Promise results. However, extendEnforce registers the same custom rule into
the lazy API and wraps its result with RuleRunReturn.create(res, value); when res is a Promise,
RuleRunReturn.create falls through to fromObject, where pass.pass is not a boolean and the
rule is treated as failing.

packages/n4s/src/n4sTypes.ts[50-53]
packages/n4s/src/extendLogic.ts[48-64]
packages/n4s/src/utils/RuleRunReturn.ts[44-65]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Async (Promise-returning) custom rules are now allowed/encouraged for eager enforcement, but `enforce.extend` also wires those rules into the lazy `RuleInstance` API where Promises are not supported, leading to silent incorrect failures.
### Issue Context
`extendEnforce` registers rules into both eager and lazy APIs. Lazy `RuleRunReturn.create` cannot interpret a Promise result.
### Fix Focus Areas
- packages/n4s/src/extendLogic.ts[48-64]
- packages/n4s/src/utils/RuleRunReturn.ts[44-65]
- packages/n4s/src/n4sTypes.ts[50-53]
Suggested approach options (pick one):
1) **Guard/throw in lazy wrapper**: In `ruleWrapper`, if `res` is promise-like, throw an explicit error (e.g., &quot;Async custom rules are not supported in lazy RuleInstance API; use eager enforce(value) and await the chain&quot;).
2) **Do not register lazy rules** for promise-returning matchers (hard to detect reliably without calling the function; you can at least block reserved `async` declarations in user typing or provide a separate `extendAsync` that only extends eager).
3) Implement an **AsyncRuleInstance** (larger change) so lazy `.run/.test` can return promises.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. AsyncMode loses thenables 🐞 Bug ≡ Correctness
Description
NextAsyncMode uses a distributive conditional, so matchers typed as a union like `boolean |
Promise<boolean> widen AsyncMode to boolean and the awaitable ThenableEnforce` surface
disappears from the resulting eager chain type. This causes false-negative typing where a chain that
may be async is not typed as awaitable.
Code

packages/n4s/src/n4sTypes.ts[R44-70]

+type IsPromiseLike<T> = T extends PromiseLike<any> ? true : false;
+type NextAsyncMode<
+  CurrentAsync extends boolean,
+  RuleReturn,
+> = CurrentAsync extends true ? true : IsPromiseLike<RuleReturn>;
+
export type EnforceCustomMatcher<F extends CB> = (
...args: CustomMatcherArgs<F extends keyof n4s.EnforceMatchers ? F : never>
-) => boolean | RuleRunReturn<any>;
+) => boolean | RuleRunReturn<any> | Promise<boolean | RuleRunReturn<any>>;
/**
* Maps custom rules to eager API signatures (drops the value parameter).
* Only includes rules where T matches the first parameter type.
*/
-export type TCustomRules<T, A, S> = {
+export type TCustomRules<T, A, S, AsyncMode extends boolean> = {
[K in keyof n4s.EnforceMatchers as T extends FirstParam<
  n4s.EnforceMatchers[K]
>
  ? K
  : never]: (
  ...args: CustomMatcherArgs<K>
-  ) => import('./eager').EnforceEagerReturn<T, A, S>;
+  ) => import('./eager').EnforceEagerReturn<
+    T,
+    A,
+    S,
+    NextAsyncMode<AsyncMode, ReturnType<Extract<n4s.EnforceMatchers[K], CB>>>
+  >;
Evidence
Because IsPromiseLike is distributive, any union that includes both promise and non-promise
returns becomes boolean instead of a literal true. When AsyncMode becomes boolean,
EnforceEagerReturn’s conditional (AsyncMode extends true ? ThenableEnforce : unknown) collapses
to unknown, losing .then/.catch typing even though runtime supports it.

packages/n4s/src/n4sTypes.ts[44-49]
packages/n4s/src/eager/eagerTypes.ts[86-94]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`NextAsyncMode` becomes `boolean` (not a literal) when a rule return type is a union containing both PromiseLike and non-PromiseLike, which removes thenable typing from the eager chain.
### Issue Context
This is a type-level issue that impacts DX and correctness of TS signatures.
### Fix Focus Areas
- packages/n4s/src/n4sTypes.ts[44-71]
Suggested fix:
- Replace `IsPromiseLike&lt;T&gt; = T extends PromiseLike&lt;any&gt; ? true : false` with a non-widening union check, e.g.:
- `type HasPromiseLike&lt;T&gt; = [Extract&lt;T, PromiseLike&lt;any&gt;&gt;] extends [never] ? false : true;`
- and `type NextAsyncMode&lt;Cur extends boolean, R&gt; = Cur extends true ? true : HasPromiseLike&lt;R&gt;;`
This yields a literal `true` when the return type *may* be a promise.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. then/catch rule collision🐞 Bug ≡ Correctness
Description
The eager proxy now reserves then and catch and returns promise handlers before rule lookup,
making any custom rule named then or catch unreachable. This is a breaking behavior change for
existing extensions using those matcher names.
Code

packages/n4s/src/eager.ts[R76-104]

+  const ensurePendingPromise = () => {
+    if (!pendingPromise) {
+      pendingPromise = Promise.resolve();
+    }
+
+    return pendingPromise;
+  };
+
+  const getReservedProperty = (key: string) => {
+    switch (key) {
+      case MESSAGE_KEY:
+        return setMessage;
+      case THEN_KEY:
+        return ensurePendingPromise().then.bind(ensurePendingPromise());
+      case CATCH_KEY:
+        return ensurePendingPromise().catch.bind(ensurePendingPromise());
+      default:
+        return undefined;
+    }
+  };
+
const proxy: EagerReturn<T> = new Proxy(
  {},
  {
    get(_target: any, key: string) {
-        if (key === MESSAGE_KEY) return setMessage;
+        const reservedProperty = getReservedProperty(key);
+        if (reservedProperty) {
+          return reservedProperty;
+        }
Evidence
The proxy get trap checks getReservedProperty first, and getReservedProperty returns handlers
for then and catch, so getRule/getSchemaRule is never consulted for those property names.

packages/n4s/src/eager.ts[76-120]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Custom rules named `then` or `catch` can no longer be called because those keys are reserved for thenable behavior.
### Issue Context
This is a breaking change; it should fail loudly at extension time rather than silently shadowing the user’s rule.
### Fix Focus Areas
- packages/n4s/src/eager.ts[76-120]
- packages/n4s/src/eager/ruleRegistry.ts[12-25]
- packages/n4s/src/extendLogic.ts[42-64]
Suggested approach:
- Add a reserved-name guard in `extendEager` and/or `extendEnforce` that throws if `rules` contains `then`, `catch` (and likely `message`).
- Update documentation/changelog accordingly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the enforce(value) validation library by introducing robust support for asynchronous rules. It allows developers to seamlessly integrate custom Promise-returning validation logic into existing eager validation chains, ensuring that the system correctly handles both synchronous and asynchronous operations while maintaining its fail-fast behavior for synchronous rules. The changes also include comprehensive type updates to provide accurate TypeScript inference for awaitable validation chains.

Highlights

  • Asynchronous Rule Support: Enabled support for Promise-returning custom rules within the eager enforce(value) API, allowing for asynchronous validation logic.
  • Awaitable Enforce Chains: Introduced the ability to await enforce chains, making mixed synchronous and asynchronous validation flows more ergonomic and intuitive.
  • Type System Enhancements: Updated TypeScript types to accurately reflect when an enforce chain becomes awaitable due to the presence of asynchronous rules, improving type safety and developer experience.
  • Internal Promise Tracking: Implemented internal pendingPromise tracking in eager.ts to manage the sequential execution of asynchronous rules within a chain.
  • Rule Result Processing: Modified ruleCallGenerator.ts to detect Promise results from rules, queue subsequent rule processing, and unify synchronous and asynchronous result handling.
  • Comprehensive Testing: Added extensive new tests and updated existing ones to thoroughly cover the new asynchronous behavior and type changes, ensuring reliability.
Changelog
  • packages/n4s/src/tests/asyncEnforce.types.test.ts
    • Added a new test file to verify type-level support for awaiting enforce chains after an async rule.
  • packages/n4s/src/tests/extend.test.ts
    • Modified an existing test to correctly handle custom rules that return promises, changing from throwing an error to resolving.
  • packages/n4s/src/tests/extendAsync.test.ts
    • Added a new test file to verify the registration and execution of async custom rules.
  • packages/n4s/src/eager.ts
    • Updated imports to use type keyword for Maybe.
    • Added THEN_KEY and CATCH_KEY constants for reserved promise methods.
    • Introduced pendingPromise tracking to manage asynchronous operations.
    • Implemented getReservedProperty to expose then and catch handlers for awaitable chains.
    • Updated createRuleCall configuration to include getPendingPromise and setPendingPromise.
  • packages/n4s/src/eager/eagerTypes.ts
    • Modified type definitions to introduce ThenableEnforce and AsyncMode generics.
    • Updated EnforceEagerReturn type to reflect when a chain becomes awaitable.
  • packages/n4s/src/eager/ruleCallGenerator.ts
    • Imported isPromise utility from vest-utils.
    • Refactored rule result processing into a new processRuleResult function.
    • Updated createRuleCall to handle promises by queuing subsequent rules and updating pendingPromise.
  • packages/n4s/src/n4sTypes.ts
    • Added IsPromiseLike and NextAsyncMode types for advanced type inference.
    • Updated EnforceCustomMatcher to allow custom matchers to return Promises.
    • Extended TCustomRules to propagate async mode through types, reflecting the awaitability of chains.
  • plans/async_enforcements_summary.md
    • Added a new markdown file documenting the architectural shift, behavior guarantees, and type updates for async enforcements.
Activity
  • The vitest test suite was executed, and all tests passed successfully.
  • New tests were specifically added to verify awaiting chains, the execution and rejection of async custom rules, and updated behavior in existing tests.
  • No automated test failures were observed after the changes were applied.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces asynchronous rule support to the eager enforce(value) API, a significant enhancement. The changes are well-implemented across the board, including runtime logic for promise-based rule execution, corresponding TypeScript type propagation to make chains awaitable, and comprehensive tests for the new async behaviors. My review found the code to be of high quality. I have one suggestion in ruleCallGenerator.ts to simplify a promise chain for better readability and maintainability.

Comment on lines +55 to +59
pendingPromise.then(() => {
return Promise.resolve(runRule()).then(ruleResult => {
processRuleResult(ruleResult, ruleName, value, customMessage, args);
});
}),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The nested .then() and Promise.resolve() call are unnecessary here. The .then() method of a promise automatically handles promise unwrapping (or 'thenable assimilation'), so you can chain the calls directly. This simplifies the code and reduces nesting, improving readability.

        pendingPromise.then(() => runRule()).then(ruleResult => {
          processRuleResult(ruleResult, ruleName, value, customMessage, args);
        }),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit — the promise chain is now simplified to .then(() => runRule()).then(ruleResult => { ... }) as suggested.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice—this is much cleaner, and it relies on native promise unwrapping correctly. Thanks for updating it.

Comment thread packages/n4s/src/eager/ruleCallGenerator.ts Outdated
Comment thread packages/n4s/src/n4sTypes.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
packages/n4s/src/eager.ts (1)

127-128: Setting proxy.pass = true unconditionally may be misleading.

Setting pass = true at initialization implies the validation has passed before any rules have run. While this may be intentional (empty chains pass), it could cause confusion if users check .pass before calling any validation rules. Consider adding a comment explaining this design decision.

📝 Add clarifying comment
+  // An empty chain (no rules called) is considered passing
   proxy.pass = true;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/n4s/src/eager.ts` around lines 127 - 128, The assignment proxy.pass
= true sets the validation as "passed" at initialization which can be
misleading; update the code around the proxy.pass assignment to include a
concise comment explaining the design decision (e.g., that empty/absent rule
chains are considered passing and therefore .pass defaults true) and warn
consumers not to rely on .pass before any rules execute; reference the
proxy.pass initialization in the eager.ts file (the proxy.pass assignment) so
future readers understand why the default is true.
packages/n4s/src/eager/ruleCallGenerator.ts (1)

52-65: Consider clarifying the error propagation behavior in async chains.

When processRuleResult throws inside the promise chain (line 57), the error propagates as a rejection. However, target.pass = true is set unconditionally on line 63 before the promise settles. This is correct for the async model (callers must await to see failures), but could be confusing.

A brief comment would help clarify that pass = true is a placeholder until the promise resolves/rejects.

📝 Add clarifying comment
+      // pass is set optimistically; actual result is determined when the promise settles
       clearMessage();
       target.pass = true;
       return target;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/n4s/src/eager/ruleCallGenerator.ts` around lines 52 - 65, Add a
short clarifying comment near the async branch that handles pending promises
(around getPendingPromise / setPendingPromise) explaining that target.pass is
set to true immediately as a placeholder for the asynchronous result and that
any error thrown inside the chained promise (from runRule or processRuleResult)
will propagate as a rejection which callers must await to observe; mention the
specific symbols getPendingPromise, setPendingPromise, runRule,
processRuleResult and target.pass so reviewers can find the spot.
packages/n4s/src/__tests__/extendAsync.test.ts (1)

6-17: Consider moving enforce.extend to beforeAll for test isolation.

Other async test files in this PR (e.g., asyncEnforce.test.ts) use beforeAll for extending enforce. Moving the extension outside the test case ensures consistent setup patterns across the test suite and avoids potential issues if more tests are added to this file later.

♻️ Suggested refactor
 describe('enforce.extend with async rules', () => {
+  beforeAll(() => {
+    enforce.extend({
+      myCustomAsyncRule: async (val: number) => {
+        return { pass: val > 5 };
+      },
+    });
+  });
+
   it('should register and execute an async custom rule properly', async () => {
-    enforce.extend({
-      myCustomAsyncRule: async (val: number) => {
-        return { pass: val > 5 };
-      },
-    });
-
     // `@ts-expect-error` - testing dynamic rule
     await expect(enforce(10).myCustomAsyncRule()).resolves.toBeUndefined();
     // `@ts-expect-error` - testing dynamic rule
     await expect(enforce(2).myCustomAsyncRule()).rejects.toThrow();
   });
 });

You'll also need to add beforeAll to the import on line 1.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/n4s/src/__tests__/extendAsync.test.ts` around lines 6 - 17, Move the
enforce.extend call out of the test body into a beforeAll block so the async
rule registration runs once per suite; add beforeAll to the test imports (as
suggested) and place the existing enforce.extend({ myCustomAsyncRule: async
(val: number) => ({ pass: val > 5 }) }) inside that beforeAll, then remove the
extend call from the 'should register and execute an async custom rule properly'
test so the test simply invokes enforce(10).myCustomAsyncRule() and
enforce(2).myCustomAsyncRule() as before.
packages/n4s/src/eager/eagerTypes.ts (1)

14-16: Consider adding finally for full Promise ergonomics.

The ThenableEnforce type omits finally, which is commonly used for cleanup in async flows (e.g., await enforce(x).asyncRule().finally(() => cleanup())). While not required for basic await support, adding it would provide a more complete Promise-like experience.

♻️ Suggested enhancement
 type ThenableEnforce = PromiseLike<void> & {
   catch: (onrejected?: ((reason: unknown) => unknown) | null) => Promise<void>;
+  finally: (onfinally?: (() => void) | null) => Promise<void>;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/n4s/src/eager/eagerTypes.ts` around lines 14 - 16, The
ThenableEnforce type currently models PromiseLike<void> with a custom catch but
omits finally; update the ThenableEnforce declaration (the type named
ThenableEnforce) to include a finally method signature (e.g.,
finally(onfinally?: (() => void) | null): Promise<void>) so it exposes
Promise-style cleanup chaining alongside the existing catch, preserving
PromiseLike<void> behavior and return type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/n4s/src/eager.ts`:
- Around line 84-95: The switch in getReservedProperty calls
ensurePendingPromise() twice for THEN_KEY and CATCH_KEY which can create
confusing/fragile promise references and eagerly create a pending promise;
change each branch to call ensurePendingPromise() exactly once, store it in a
local const (e.g., const p = ensurePendingPromise()) and then return
p.then.bind(p) for THEN_KEY and p.catch.bind(p) for CATCH_KEY so the same
promise reference is used and the call site is clearer (refer to
getReservedProperty, THEN_KEY, CATCH_KEY, and ensurePendingPromise).

---

Nitpick comments:
In `@packages/n4s/src/__tests__/extendAsync.test.ts`:
- Around line 6-17: Move the enforce.extend call out of the test body into a
beforeAll block so the async rule registration runs once per suite; add
beforeAll to the test imports (as suggested) and place the existing
enforce.extend({ myCustomAsyncRule: async (val: number) => ({ pass: val > 5 })
}) inside that beforeAll, then remove the extend call from the 'should register
and execute an async custom rule properly' test so the test simply invokes
enforce(10).myCustomAsyncRule() and enforce(2).myCustomAsyncRule() as before.

In `@packages/n4s/src/eager.ts`:
- Around line 127-128: The assignment proxy.pass = true sets the validation as
"passed" at initialization which can be misleading; update the code around the
proxy.pass assignment to include a concise comment explaining the design
decision (e.g., that empty/absent rule chains are considered passing and
therefore .pass defaults true) and warn consumers not to rely on .pass before
any rules execute; reference the proxy.pass initialization in the eager.ts file
(the proxy.pass assignment) so future readers understand why the default is
true.

In `@packages/n4s/src/eager/eagerTypes.ts`:
- Around line 14-16: The ThenableEnforce type currently models PromiseLike<void>
with a custom catch but omits finally; update the ThenableEnforce declaration
(the type named ThenableEnforce) to include a finally method signature (e.g.,
finally(onfinally?: (() => void) | null): Promise<void>) so it exposes
Promise-style cleanup chaining alongside the existing catch, preserving
PromiseLike<void> behavior and return type.

In `@packages/n4s/src/eager/ruleCallGenerator.ts`:
- Around line 52-65: Add a short clarifying comment near the async branch that
handles pending promises (around getPendingPromise / setPendingPromise)
explaining that target.pass is set to true immediately as a placeholder for the
asynchronous result and that any error thrown inside the chained promise (from
runRule or processRuleResult) will propagate as a rejection which callers must
await to observe; mention the specific symbols getPendingPromise,
setPendingPromise, runRule, processRuleResult and target.pass so reviewers can
find the spot.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fbc5bdb4-f56d-45e1-972e-ab4d02c11256

📥 Commits

Reviewing files that changed from the base of the PR and between d8c22eb and 11123c2.

📒 Files selected for processing (9)
  • packages/n4s/src/__tests__/asyncEnforce.types.test.ts
  • packages/n4s/src/__tests__/extend.test.ts
  • packages/n4s/src/__tests__/extendAsync.test.ts
  • packages/n4s/src/eager.ts
  • packages/n4s/src/eager/eagerTypes.ts
  • packages/n4s/src/eager/ruleCallGenerator.ts
  • packages/n4s/src/n4sTypes.ts
  • packages/n4s/src/rules/__tests__/asyncEnforce.test.ts
  • plans/async_enforcements_summary.md

Comment thread packages/n4s/src/eager.ts Outdated
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Mar 15, 2026

🚀 Benchmark Results

Suite Benchmark Ops/sec (Hz) P99 (ms) Margin of Error Diff (Abs) Diff (%)
Complex Data Validation Enforce Huge String 346.83 8.5641 5.80% 0 0.00%
State Management Serialize Large 266.66 5.1108 2.87% -32.32 -10.81% ⚠️
Integration & Edge Cases Callback Overhead 4.285 235.91 0.42% 0 0.00%
Complex Feature Mix full run with feature flags 135.44 13.9872 5.35% 0 0.00%
Complex Feature Mix focused/conditional run 242.42 7.8506 3.07% 0 0.00%
Conditional isolates skip even indices 557.09 3.556 7.07% 0 0.00%
Conditional isolates omit multiples of 4 452.41 5.4219 10.95% +95.64 +26.81% 🎉
Dynamic each and groups longer list 236.1 10.7506 14.76% +31 +15.11% 🎉
Reconciler & History Diffing Reconciler (Stable List) 3.974 276.01 3.03% 0 0.00%
Reconciler & History Diffing Reconciler (Full Invalidation) 4.108 249.83 0.90% 0 0.00%
Reconciler & History Diffing Reconciler (Prepend Item) 4.085 256.75 1.34% 0 0.00%
Reconciler & History Diffing Reconciler (Append Item) 4.097 248.5 0.50% 0 0.00%
Reconciler & History Diffing Reconciler (Interleaved) 4.103 246.05 0.36% 0 0.00%
Reconciler & History Diffing Isolate Reordering (Reverse) 4.041 281.14 3.62% 0 0.00%
Reconciler & History Diffing Isolate Reordering (Shuffle) 4.084 248.43 0.61% 0 0.00%
Reconciler & History Diffing Orphan GC Pressure 7.863 133.19 1.31% 0 0.00%
Result Selectors & Reporting hasErrors (Volume) 795.77 1.5532 1.02% 0 0.00%
Result Selectors & Reporting getErrors (Group Lookup) 378.79 8.1411 7.76% -69.64 -15.53% ⚠️
Result Selectors & Reporting Summary Generation (Large) 3.568 283.07 0.35% 0 0.00%
Async & Concurrency Stress Pending Storm (Memory) 4.053 248.23 0.35% 0 0.00%
Async & Concurrency Stress Resolve Storm (Throughput) 4.028 251.38 0.57% 0 0.00%
Async & Concurrency Stress Reject Storm 3.982 252.67 0.28% 0 0.00%
Async & Concurrency Stress Async Race 172.21 7.5692 2.91% 0 0.00%
Control Flow & Hooks Internals test.memo (Thrashing) 165.31 8.3921 2.41% 0 0.00%
Control Flow & Hooks Internals test.memo (Stagnation) 632.3 2.6079 1.38% 0 0.00%
Control Flow & Hooks Internals skipWhen (Active) 8.475 122.62 1.42% 0 0.00%
Control Flow & Hooks Internals only Starvation (Early) 7.291 139.88 0.75% 0 0.00%
Control Flow & Hooks Internals only Starvation (Late) 7.143 163.61 4.43% 0 0.00%
VestBus & Internals Bus Scaling 198.6 6.1294 1.93% 0 0.00%
VestBus & Internals State Refill 115.9 23.7635 8.44% 0 0.00%
Memory & Object Lifecycle Test Object Allocator 8.459 128.47 2.21% 0 0.00%
Memory & Object Lifecycle Garbage Collection Friendly 8.525 120.86 0.83% 0 0.00%
Serialization Serialize (Large) 131.29 9.1502 2.57% -16.02 -10.88% ⚠️
Serialization Deserialize (Large) 85.956 13.3828 2.22% 0 0.00%
Edge Cases & Integration Broad Group 4.126 243.81 0.21% 0 0.00%
Edge Cases & Integration Namespace Collision 4.153 243.5 0.49% 0 0.00%
Edge Cases & Integration Large Field Names 196.26 7.3123 2.30% 0 0.00%
Edge Cases & Integration Large Failure Messages 342.95 5.4314 3.07% 0 0.00%
Feature Coverage Matrix enforce matrix (small payload) 376.32 6.189 6.40% +62.29 +19.84% 🎉
Feature Coverage Matrix enforce matrix (larger payload) 613.55 5.4609 7.96% -75.3 -10.93% ⚠️
Feature Coverage Matrix flow control eager mode 308.69 6.5951 7.13% 0 0.00%
Feature Coverage Matrix flow control one mode 286 7.1897 6.25% +27.81 +10.77% 🎉
Reordering & Reconciliation each (Reorder - Reverse) 102.5 14.3802 5.54% 0 0.00%
Reordering & Reconciliation each (Reorder - Insert Middle) 96.825 19.5483 6.07% 0 0.00%
Reordering & Reconciliation each (Reorder - Delete Middle) 105.61 14.8875 4.51% 0 0.00%
Reordering & Reconciliation each (Key Thrashing) 269.55 7.509 5.11% 0 0.00%
State Mutation & Reset suite.remove() (Many Fields) 168.99 10.5318 2.23% 0 0.00%
State Mutation & Reset suite.reset() (Memory Reclamation) 8.755 115.5 0.57% 0 0.00%
Concurrency & Events Bus Stress 4.319 234.49 0.41% 0 0.00%
Nested Fields with Hooks depth 3 with 40 fields per level 11.674 94.5996 8.71% 0 0.00%
Nested Fields with Hooks depth 4 with 60 fields per level 6.341 183.57 35.63% 0 0.00%
Nested Fields with Hooks depth 5 with 80 fields per level 6.188 162.97 10.79% 0 0.00%
Field Volume Stress 10 fields 329.56 9.0433 6.97% -27.96 -7.82% ⚠️
Field Volume Stress 500 fields 4.74 217.82 0.94% 0 0.00%
Field Volume Stress 1000 fields 2.07 534.01 2.65% 0 0.00%
Deep Nesting Stress depth 10 79.444 28.0202 6.19% 0 0.00%
Deep Nesting Stress depth 50 32.038 41.3302 2.42% 0 0.00%
Deep Nesting Stress depth 100 20.716 51.101 0.87% 0 0.00%
Complex Combinations & Edge Cases High Frequency test Creation 184.8 10.4844 3.32% 0 0.00%
Core Test Functionality test (High Volume, Same Name) 4.399 231.66 0.72% 0 0.00%
Core Test Functionality test (High Volume, Unique Names) 4.376 230.62 0.35% 0 0.00%
Async Enforce - Single Rule async rule (delayed resolve, pass) 899.87 2.1584 0.94% 0 0.00%
Raw Output
See CI logs for full output
PREVIOUS_RESULTS

🚀 Benchmark Results

Suite Benchmark Ops/sec (Hz) P99 (ms) Margin of Error Diff (Abs) Diff (%)
Complex Data Validation Enforce Huge String 335.45 8.742 6.34% 0 0.00%
State Management Serialize Large 292.16 4.5673 1.92% 0 0.00%
Integration & Edge Cases Callback Overhead 4.097 250.63 1.19% 0 0.00%
Complex Feature Mix full run with feature flags 128.99 13.1642 8.04% 0 0.00%
Complex Feature Mix focused/conditional run 233.58 8.3854 3.06% 0 0.00%
Conditional isolates skip even indices 364.41 12.0968 20.54% -221.43 -37.80% ⚠️
Conditional isolates omit multiples of 4 506.03 4.3375 9.17% 0 0.00%
Dynamic each and groups longer list 304.87 4.5583 4.77% +47.55 +18.48% 🎉
Reconciler & History Diffing Reconciler (Stable List) 3.993 269.31 2.30% 0 0.00%
Reconciler & History Diffing Reconciler (Full Invalidation) 4.169 242.83 0.59% 0 0.00%
Reconciler & History Diffing Reconciler (Prepend Item) 4.19 241.8 0.49% 0 0.00%
Reconciler & History Diffing Reconciler (Append Item) 4.185 239.84 0.20% 0 0.00%
Reconciler & History Diffing Reconciler (Interleaved) 4.183 242.45 0.46% 0 0.00%
Reconciler & History Diffing Isolate Reordering (Reverse) 4.186 240.61 0.38% 0 0.00%
Reconciler & History Diffing Isolate Reordering (Shuffle) 4.181 248.79 1.04% 0 0.00%
Reconciler & History Diffing Orphan GC Pressure 8.056 125.46 0.35% 0 0.00%
Result Selectors & Reporting hasErrors (Volume) 869.07 1.8684 1.13% 0 0.00%
Result Selectors & Reporting getErrors (Group Lookup) 429.23 2.5263 0.32% +31.98 +8.05% 🎉
Result Selectors & Reporting Summary Generation (Large) 3.721 287.88 1.83% 0 0.00%
Async & Concurrency Stress Pending Storm (Memory) 4.107 247.9 0.71% 0 0.00%
Async & Concurrency Stress Resolve Storm (Throughput) 4.151 243.67 0.42% 0 0.00%
Async & Concurrency Stress Reject Storm 4.065 267.61 2.22% 0 0.00%
Async & Concurrency Stress Async Race 167.53 7.7233 2.74% 0 0.00%
Control Flow & Hooks Internals test.memo (Thrashing) 159.22 9.12 2.39% 0 0.00%
Control Flow & Hooks Internals test.memo (Stagnation) 623.25 2.6995 1.50% 0 0.00%
Control Flow & Hooks Internals skipWhen (Active) 8.783 117.61 1.15% 0 0.00%
Control Flow & Hooks Internals only Starvation (Early) 7.416 138.79 1.41% 0 0.00%
Control Flow & Hooks Internals only Starvation (Late) 7.364 137.43 0.45% 0 0.00%
VestBus & Internals Bus Scaling 197.1 6.1732 1.69% 0 0.00%
VestBus & Internals State Refill 121.99 10.9986 3.01% 0 0.00%
Memory & Object Lifecycle Test Object Allocator 8.734 119.77 1.72% 0 0.00%
Memory & Object Lifecycle Garbage Collection Friendly 8.839 113.94 0.33% 0 0.00%
Serialization Serialize (Large) 149.84 9.7277 3.12% +19.22 +14.71% 🎉
Serialization Deserialize (Large) 95.56 12.2006 1.36% +7.373 +8.36% 🎉
Edge Cases & Integration Broad Group 4.282 237.95 0.57% 0 0.00%
Edge Cases & Integration Namespace Collision 4.341 231.61 0.20% 0 0.00%
Edge Cases & Integration Large Field Names 194.52 7.2275 2.40% 0 0.00%
Edge Cases & Integration Large Failure Messages 336.76 5.3102 3.03% 0 0.00%
Feature Coverage Matrix enforce matrix (small payload) 379.36 7.0382 7.27% 0 0.00%
Feature Coverage Matrix enforce matrix (larger payload) 623.69 5.0899 9.12% 0 0.00%
Feature Coverage Matrix flow control eager mode 288.01 7.3073 8.05% -26.58 -8.45% ⚠️
Feature Coverage Matrix flow control one mode 289.49 6.5127 6.05% 0 0.00%
Reordering & Reconciliation each (Reorder - Reverse) 109.1 15.7023 6.01% 0 0.00%
Reordering & Reconciliation each (Reorder - Insert Middle) 103.84 12.2137 3.26% 0 0.00%
Reordering & Reconciliation each (Reorder - Delete Middle) 108.65 12.2764 3.53% -6.5 -5.64% ⚠️
Reordering & Reconciliation each (Key Thrashing) 283.5 6.1186 4.02% 0 0.00%
State Mutation & Reset suite.remove() (Many Fields) 170.59 10.7806 2.45% 0 0.00%
State Mutation & Reset suite.reset() (Memory Reclamation) 8.943 113.47 0.74% 0 0.00%
Concurrency & Events Bus Stress 4.353 233.77 0.65% 0 0.00%
Nested Fields with Hooks depth 3 with 40 fields per level 11.472 95.9624 9.72% 0 0.00%
Nested Fields with Hooks depth 4 with 60 fields per level 6.211 186.34 37.04% 0 0.00%
Nested Fields with Hooks depth 5 with 80 fields per level 6.289 159.69 5.48% 0 0.00%
Field Volume Stress 10 fields 361.45 9.6668 5.43% 0 0.00%
Field Volume Stress 500 fields 4.772 213.69 0.58% 0 0.00%
Field Volume Stress 1000 fields 2.041 494.71 0.38% -0.12 -5.56% ⚠️
Deep Nesting Stress depth 10 81.477 39.5368 7.61% 0 0.00%
Deep Nesting Stress depth 50 32.949 35.6115 1.92% 0 0.00%
Deep Nesting Stress depth 100 21.078 48.6577 0.70% 0 0.00%
Complex Combinations & Edge Cases High Frequency test Creation 191.95 7.4829 2.16% 0 0.00%
Core Test Functionality test (High Volume, Same Name) 4.26 238.87 0.62% 0 0.00%
Core Test Functionality test (High Volume, Unique Names) 4.211 240.81 0.70% 0 0.00%
Raw Output
See CI logs for full output

🚀 Benchmark Results

Suite Benchmark Ops/sec (Hz) P99 (ms) Margin of Error Diff (Abs) Diff (%)
Complex Data Validation Enforce Huge String 357.02 8.2252 6.89% 0 0.00%
State Management Serialize Large 284.11 5.1019 2.15% 0 0.00%
Integration & Edge Cases Callback Overhead 4.101 245.14 0.35% 0 0.00%
Complex Feature Mix full run with feature flags 132.88 15.2077 5.11% 0 0.00%
Complex Feature Mix focused/conditional run 234.75 8.5851 3.31% 0 0.00%
Conditional isolates skip even indices 371.42 10.6055 19.50% -114.86 -23.62% ⚠️
Conditional isolates omit multiples of 4 507.05 4.6253 9.10% 0 0.00%
Dynamic each and groups longer list 256.52 6.426 11.50% 0 0.00%
Reconciler & History Diffing Reconciler (Stable List) 3.95 280 2.97% 0 0.00%
Reconciler & History Diffing Reconciler (Full Invalidation) 4.046 251.24 0.72% 0 0.00%
Reconciler & History Diffing Reconciler (Prepend Item) 4.052 256.01 0.98% 0 0.00%
Reconciler & History Diffing Reconciler (Append Item) 4.037 249.31 0.36% 0 0.00%
Reconciler & History Diffing Reconciler (Interleaved) 4.028 255.14 0.76% 0 0.00%
Reconciler & History Diffing Isolate Reordering (Reverse) 4.036 249.88 0.32% 0 0.00%
Reconciler & History Diffing Isolate Reordering (Shuffle) 3.993 270.18 2.07% 0 0.00%
Reconciler & History Diffing Orphan GC Pressure 7.855 129.12 0.51% 0 0.00%
Result Selectors & Reporting hasErrors (Volume) 869.89 1.9498 1.41% 0 0.00%
Result Selectors & Reporting getErrors (Group Lookup) 472.59 2.5007 0.65% 0 0.00%
Result Selectors & Reporting Summary Generation (Large) 3.507 287.82 0.49% 0 0.00%
Async & Concurrency Stress Pending Storm (Memory) 4.003 251.89 0.67% 0 0.00%
Async & Concurrency Stress Resolve Storm (Throughput) 4.003 256.82 0.77% 0 0.00%
Async & Concurrency Stress Reject Storm 4.022 251.51 0.36% 0 0.00%
Async & Concurrency Stress Async Race 166.72 10.3055 3.55% 0 0.00%
Control Flow & Hooks Internals test.memo (Thrashing) 163.08 7.5101 2.24% 0 0.00%
Control Flow & Hooks Internals test.memo (Stagnation) 629.13 2.8488 1.72% 0 0.00%
Control Flow & Hooks Internals skipWhen (Active) 8.389 120.7 0.37% 0 0.00%
Control Flow & Hooks Internals only Starvation (Early) 7.203 142.71 1.04% 0 0.00%
Control Flow & Hooks Internals only Starvation (Late) 7.34 138.25 0.99% 0 0.00%
VestBus & Internals Bus Scaling 193.44 7.7309 2.36% 0 0.00%
VestBus & Internals State Refill 123.15 12.7454 3.86% 0 0.00%
Memory & Object Lifecycle Test Object Allocator 8.665 117.46 0.64% 0 0.00%
Memory & Object Lifecycle Garbage Collection Friendly 8.44 131.99 3.76% 0 0.00%
Serialization Serialize (Large) 122.45 9.5934 2.09% -13.74 -10.09% ⚠️
Serialization Deserialize (Large) 93.879 13.0836 1.85% 0 0.00%
Edge Cases & Integration Broad Group 4.208 240.99 0.73% 0 0.00%
Edge Cases & Integration Namespace Collision 4.185 253.34 1.66% 0 0.00%
Edge Cases & Integration Large Field Names 196.48 6.5423 2.30% 0 0.00%
Edge Cases & Integration Large Failure Messages 339.29 5.1646 3.27% 0 0.00%
Feature Coverage Matrix enforce matrix (small payload) 375.92 6.7459 7.13% 0 0.00%
Feature Coverage Matrix enforce matrix (larger payload) 590.39 5.2992 9.36% 0 0.00%
Feature Coverage Matrix flow control eager mode 278.51 7.4848 8.26% -29.97 -9.72% ⚠️
Feature Coverage Matrix flow control one mode 288.61 8.9055 7.80% 0 0.00%
Reordering & Reconciliation each (Reorder - Reverse) 105.11 15.5374 5.73% 0 0.00%
Reordering & Reconciliation each (Reorder - Insert Middle) 100.27 13.1974 4.10% 0 0.00%
Reordering & Reconciliation each (Reorder - Delete Middle) 107.86 12.3513 3.99% 0 0.00%
Reordering & Reconciliation each (Key Thrashing) 273.73 7.0221 4.87% +15.17 +5.87% 🎉
State Mutation & Reset suite.remove() (Many Fields) 171.6 7.8449 1.58% 0 0.00%
State Mutation & Reset suite.reset() (Memory Reclamation) 8.551 118.94 0.81% 0 0.00%
Concurrency & Events Bus Stress 4.288 234.84 0.40% 0 0.00%
Nested Fields with Hooks depth 3 with 40 fields per level 11.728 96.617 10.97% 0 0.00%
Nested Fields with Hooks depth 4 with 60 fields per level 6.332 184.2 36.54% 0 0.00%
Nested Fields with Hooks depth 5 with 80 fields per level 6.223 162.09 10.95% 0 0.00%
Field Volume Stress 10 fields 332.29 9.5057 7.13% -26.35 -7.35% ⚠️
Field Volume Stress 500 fields 4.767 232.03 2.73% 0 0.00%
Field Volume Stress 1000 fields 2.123 479.26 0.89% 0 0.00%
Deep Nesting Stress depth 10 75.662 38.0116 7.43% 0 0.00%
Deep Nesting Stress depth 50 30.939 37.8557 2.02% 0 0.00%
Deep Nesting Stress depth 100 19.409 53.4001 0.89% 0 0.00%
Complex Combinations & Edge Cases High Frequency test Creation 185.75 10.1727 3.39% 0 0.00%
Core Test Functionality test (High Volume, Same Name) 4.185 242.57 0.63% 0 0.00%
Core Test Functionality test (High Volume, Unique Names) 4.186 241.52 0.31% 0 0.00%
Raw Output
See CI logs for full output

ealush added 2 commits April 1, 2026 21:01
- Fix context loss: wrap transformResult in ctx.run() so message
  functions using enforce.context() resolve correctly for both sync
  and async rule paths
- Guard lazy API against async rules: throw a clear error when a
  Promise-returning custom rule is used via the lazy API
  (enforce.rule().run()) instead of silently returning pass: false
- Fix double ensurePendingPromise() call in eager.ts then/catch
  handlers — store in local variable
- Simplify promise chaining in ruleCallGenerator (remove redundant
  Promise.resolve wrappers)
- Fix @ts-expect-error placement in type test
- Remove Codex-generated plans/async_enforcements_summary.md
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/n4s/src/extendLogic.ts`:
- Around line 53-58: The call to ctx.run(...) may return a Promise which, if it
later rejects, will become an unhandled rejection because the invariant is
thrown immediately; modify the logic around res (from ctx.run({ value }, () =>
rule(value, ...args))) so that if isPromise(res) is true you attach a rejection
handler (e.g. res.catch(() => {})) before calling invariant; update the check
that uses isPromise(res) and the invariant that references ruleName so the
Promise has its .catch registered to swallow the rejection prior to throwing the
invariant error for lazy async rules.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 62b301f9-abb7-4b89-9c86-d2186e44faa3

📥 Commits

Reviewing files that changed from the base of the PR and between 11123c2 and 54c7048.

📒 Files selected for processing (4)
  • packages/n4s/src/__tests__/asyncEnforce.types.test.ts
  • packages/n4s/src/eager.ts
  • packages/n4s/src/eager/ruleCallGenerator.ts
  • packages/n4s/src/extendLogic.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/n4s/src/tests/asyncEnforce.types.test.ts
  • packages/n4s/src/eager.ts

Comment on lines 53 to +58
const res = ctx.run({ value }, () => rule(value, ...args));

invariant(
!isPromise(res),
`enforce.extend: rule "${ruleName}" returned a Promise. Async rules are only supported in the eager API (enforce(value).rule()), not in the lazy API (enforce.rule()).`,
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n packages/n4s/src/extendLogic.ts | head -80

Repository: ealush/vest

Length of output: 2788


🏁 Script executed:

fd -t f "\.test\.ts$" packages/n4s/src/ -x basename {} | head -20

Repository: ealush/vest

Length of output: 453


🏁 Script executed:

find packages/n4s -name "*extend*" -o -name "*custom*rule*"

Repository: ealush/vest

Length of output: 223


🏁 Script executed:

cat -n packages/n4s/src/__tests__/extendAsync.test.ts

Repository: ealush/vest

Length of output: 766


🏁 Script executed:

cat -n packages/n4s/src/__tests__/extend.test.ts

Repository: ealush/vest

Length of output: 50367


🏁 Script executed:

grep -n "lazy.*[Pp]romise\|[Pp]romise.*lazy" packages/n4s/src/__tests__/extend.test.ts

Repository: ealush/vest

Length of output: 37


🏁 Script executed:

fd -t f "enforceContext" packages/n4s/src/ | head -5

Repository: ealush/vest

Length of output: 89


🏁 Script executed:

cat -n packages/n4s/src/enforceContext.ts

Repository: ealush/vest

Length of output: 2289


🏁 Script executed:

rg "ctx\.run" packages/n4s/src/ -A 2 -B 2 | head -40

Repository: ealush/vest

Length of output: 2734


🏁 Script executed:

rg "export.*invariant|function invariant" -A 3 packages/n4s/src/ | head -20

Repository: ealush/vest

Length of output: 37


🏁 Script executed:

grep -rn "unhandled\|rejection\|catch" packages/n4s/src/__tests__/extendAsync.test.ts packages/n4s/src/__tests__/extend.test.ts | grep -i "promise\|async"

Repository: ealush/vest

Length of output: 37


🏁 Script executed:

grep -n "asyncRule\|async.*rule" packages/n4s/src/__tests__/extend.test.ts | head -20

Repository: ealush/vest

Length of output: 184


🏁 Script executed:

grep -n "Async rules are only supported" packages/n4s/src/ -r

Repository: ealush/vest

Length of output: 266


🏁 Script executed:

cat packages/n4s/src/__tests__/extendAsync.test.ts

Repository: ealush/vest

Length of output: 640


🏁 Script executed:

grep -n "lazy.*async\|async.*lazy" packages/n4s/src/__tests__/extend.test.ts

Repository: ealush/vest

Length of output: 37


🏁 Script executed:

grep -n "\.catch\|\.finally" packages/n4s/src/extendLogic.ts

Repository: ealush/vest

Length of output: 37


Attach a rejection handler before failing fast on lazy async rules.

Line 53 invokes the custom rule before the Promise guard. If that rule returns a Promise which later rejects, the invariant thrown on lines 55-58 will be raised, but the created Promise is left without a handler and can surface as an unhandled rejection. Attach .catch() to swallow the rejection before throwing the invariant error.

Suggested fix
     const res = ctx.run({ value }, () => rule(value, ...args));

-      invariant(
-        !isPromise(res),
-        `enforce.extend: rule "${ruleName}" returned a Promise. Async rules are only supported in the eager API (enforce(value).rule()), not in the lazy API (enforce.rule()).`,
-      );
+      if (isPromise(res)) {
+        void Promise.resolve(res).catch(() => undefined);
+        invariant(
+          false,
+          `enforce.extend: rule "${ruleName}" returned a Promise. Async rules are only supported in the eager API (enforce(value).rule()), not in the lazy API (enforce.rule()).`,
+        );
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const res = ctx.run({ value }, () => rule(value, ...args));
invariant(
!isPromise(res),
`enforce.extend: rule "${ruleName}" returned a Promise. Async rules are only supported in the eager API (enforce(value).rule()), not in the lazy API (enforce.rule()).`,
);
const res = ctx.run({ value }, () => rule(value, ...args));
if (isPromise(res)) {
void Promise.resolve(res).catch(() => undefined);
invariant(
false,
`enforce.extend: rule "${ruleName}" returned a Promise. Async rules are only supported in the eager API (enforce(value).rule()), not in the lazy API (enforce.rule()).`,
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/n4s/src/extendLogic.ts` around lines 53 - 58, The call to
ctx.run(...) may return a Promise which, if it later rejects, will become an
unhandled rejection because the invariant is thrown immediately; modify the
logic around res (from ctx.run({ value }, () => rule(value, ...args))) so that
if isPromise(res) is true you attach a rejection handler (e.g. res.catch(() =>
{})) before calling invariant; update the check that uses isPromise(res) and the
invariant that references ruleName so the Promise has its .catch registered to
swallow the rejection prior to throwing the invariant error for lazy async
rules.

- Sync chains are no longer thenable at runtime (breaking change fix)
- .pass is set to false when async rules reject
- .finally() support added to type and runtime
- Remove unnecessary ensurePendingPromise
- Add 50 async tests covering all chain patterns, error messages,
  .pass behavior, thenable semantics, lazy API guard, execution
  order, and chain isolation
- Add async enforce benchmarks (16 benchmarks across 5 groups)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex shelved Feature shelved - revisit later

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant