Support async rules in eager enforce(value) chains with awaitable chains and type updates#1261
Support async rules in eager enforce(value) chains with awaitable chains and type updates#1261
enforce(value) chains with awaitable chains and type updates#1261Conversation
Review Summary by QodoSupport async rules in eager enforce chains with promise queuing and type updates
WalkthroughsDescription• 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 Diagramflowchart 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"]
File Changes1. packages/n4s/src/eager.ts
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughAdds 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Code Review by Qodo
1.
|
Summary of ChangesHello, 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 Highlights
Changelog
Activity
Using Gemini Code AssistThe 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
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 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
|
There was a problem hiding this comment.
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.
| pendingPromise.then(() => { | ||
| return Promise.resolve(runRule()).then(ruleResult => { | ||
| processRuleResult(ruleResult, ruleName, value, customMessage, args); | ||
| }); | ||
| }), |
There was a problem hiding this comment.
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);
}),There was a problem hiding this comment.
Fixed in the latest commit — the promise chain is now simplified to .then(() => runRule()).then(ruleResult => { ... }) as suggested.
There was a problem hiding this comment.
Nice—this is much cleaner, and it relies on native promise unwrapping correctly. Thanks for updating it.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (4)
packages/n4s/src/eager.ts (1)
127-128: Settingproxy.pass = trueunconditionally may be misleading.Setting
pass = trueat 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.passbefore 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
processRuleResultthrows inside the promise chain (line 57), the error propagates as a rejection. However,target.pass = trueis 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 = trueis 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 movingenforce.extendtobeforeAllfor test isolation.Other async test files in this PR (e.g.,
asyncEnforce.test.ts) usebeforeAllfor 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
beforeAllto 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 addingfinallyfor full Promise ergonomics.The
ThenableEnforcetype omitsfinally, which is commonly used for cleanup in async flows (e.g.,await enforce(x).asyncRule().finally(() => cleanup())). While not required for basicawaitsupport, 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
📒 Files selected for processing (9)
packages/n4s/src/__tests__/asyncEnforce.types.test.tspackages/n4s/src/__tests__/extend.test.tspackages/n4s/src/__tests__/extendAsync.test.tspackages/n4s/src/eager.tspackages/n4s/src/eager/eagerTypes.tspackages/n4s/src/eager/ruleCallGenerator.tspackages/n4s/src/n4sTypes.tspackages/n4s/src/rules/__tests__/asyncEnforce.test.tsplans/async_enforcements_summary.md
🚀 Benchmark Results
Raw OutputPREVIOUS_RESULTS🚀 Benchmark Results
Raw Output🚀 Benchmark Results
Raw Output |
…nc-rule-support-in-enforce-library
- 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
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
packages/n4s/src/__tests__/asyncEnforce.types.test.tspackages/n4s/src/eager.tspackages/n4s/src/eager/ruleCallGenerator.tspackages/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
| 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()).`, | ||
| ); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n packages/n4s/src/extendLogic.ts | head -80Repository: ealush/vest
Length of output: 2788
🏁 Script executed:
fd -t f "\.test\.ts$" packages/n4s/src/ -x basename {} | head -20Repository: 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.tsRepository: ealush/vest
Length of output: 766
🏁 Script executed:
cat -n packages/n4s/src/__tests__/extend.test.tsRepository: ealush/vest
Length of output: 50367
🏁 Script executed:
grep -n "lazy.*[Pp]romise\|[Pp]romise.*lazy" packages/n4s/src/__tests__/extend.test.tsRepository: ealush/vest
Length of output: 37
🏁 Script executed:
fd -t f "enforceContext" packages/n4s/src/ | head -5Repository: ealush/vest
Length of output: 89
🏁 Script executed:
cat -n packages/n4s/src/enforceContext.tsRepository: ealush/vest
Length of output: 2289
🏁 Script executed:
rg "ctx\.run" packages/n4s/src/ -A 2 -B 2 | head -40Repository: ealush/vest
Length of output: 2734
🏁 Script executed:
rg "export.*invariant|function invariant" -A 3 packages/n4s/src/ | head -20Repository: 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 -20Repository: ealush/vest
Length of output: 184
🏁 Script executed:
grep -n "Async rules are only supported" packages/n4s/src/ -rRepository: ealush/vest
Length of output: 266
🏁 Script executed:
cat packages/n4s/src/__tests__/extendAsync.test.tsRepository: ealush/vest
Length of output: 640
🏁 Script executed:
grep -n "lazy.*async\|async.*lazy" packages/n4s/src/__tests__/extend.test.tsRepository: ealush/vest
Length of output: 37
🏁 Script executed:
grep -n "\.catch\|\.finally" packages/n4s/src/extendLogic.tsRepository: 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.
| 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)
Summary
Adds async rule support to the eager
enforce(value)API. Custom rules registered viaenforce.extend()can now return Promises. The chain becomes awaitable once an async rule is invoked.Key behaviors
.then/.catch/.finallyare only exposed at runtime after an async rule is called (no breaking change for existing sync usage).passis reliable — set totrueoptimistically, updated tofalseif the promise rejects.finally()support — full Promise-like ergonomics on async chainsenforce.asyncRule().run(value)throws a clear error directing users to the eager APIAsyncModetype parameter propagates through the chain;ThenableEnforce(with.then/.catch/.finally) is only intersected whenAsyncMode extends truetransformResultruns insidectx.run()so message functions usingenforce.context()work correctly in both sync and async pathsTest coverage (50 tests)
{ pass }results, boolean results, delayed rules.message()override.passproperty: optimistic true, stays true on resolve, set false on reject.then/.catch/.finally,try/catch.run()and.test()throw with rule name and guidanceBenchmarks (16 benchmarks across 5 groups)
.message()overrideTest plan
.thenat type levelSummary by CodeRabbit
New Features
Behavior Changes
Tests