6.4 KiB
Capability Spec Delta: timing (timing-completion)
Package: @suaveplan/timing
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions. (Seeopenspec/AGENTS.mdSection 13.)
ADDED Requirements
Requirement: Sliding-window rate limit
The package SHALL expose rateLimit<TArgs extends unknown[], TReturn>(fn: (...args: TArgs) => TReturn, options: RateLimitOptions): (...args: TArgs) => TReturn | RateLimitedSentinel where RateLimitOptions = { maxCalls: number; windowMs: number; clock?: Clock }. The wrapped function SHALL maintain a FIFO of timestamps; on every call it SHALL evict entries older than now - windowMs (where now comes from options.clock if supplied, otherwise from the system clock injected at construction). When the FIFO size is below maxCalls, the wrapped function SHALL push the current timestamp and invoke fn. When the size equals maxCalls, the wrapper SHALL return the exported sentinel RATE_LIMITED instead of invoking fn.
The factory SHALL throw TimingError with code TIMING.RATE_LIMIT_INVALID_WINDOW when maxCalls < 1 or windowMs <= 0.
Scenario: bursts inside the window are allowed up to the cap
- WHEN a caller wraps a counter-incrementing
fnwithrateLimit(fn, { maxCalls: 3, windowMs: 1000 })and invokes the wrapper 3 times within 100ms (under a fake clock) - THEN
fnruns 3 times AND the counter equals 3
Scenario: cap exceeded returns the sentinel
- WHEN the wrapper is invoked a fourth time inside the same window
- THEN the call returns
RATE_LIMITEDANDfnis not invoked
Scenario: window slides
- WHEN the fake clock advances past
windowMsAND the wrapper is invoked again - THEN the oldest FIFO entry is evicted AND
fnruns (counter = 4)
Scenario: invalid options throw
- WHEN a caller invokes
rateLimit(fn, { maxCalls: 0, windowMs: 1000 }) - THEN the factory throws
TimingErrorwith codeTIMING.RATE_LIMIT_INVALID_WINDOW
Verified by: src/throttle/rate-limit/rate-limit.test.ts::"allows bursts up to maxCalls inside a single window" AND src/throttle/rate-limit/rate-limit.test.ts::"returns RATE_LIMITED when cap exceeded" AND src/throttle/rate-limit/rate-limit.test.ts::"slides the window when clock advances past windowMs" AND src/throttle/rate-limit/rate-limit.spec-conformance.test.ts::"throws TIMING.RATE_LIMIT_INVALID_WINDOW for non-positive windowMs" AND src/throttle/rate-limit/rate-limit.spec-conformance.test.ts::"increments timing.rate_limit.rejected on cap"
Requirement: RATE_LIMITED sentinel symbol
The package SHALL export a unique symbol RATE_LIMITED and a type alias RateLimitedSentinel = typeof RATE_LIMITED. Callers MUST be able to disambiguate "rate-limited" from "function returned undefined" via reference equality on the sentinel.
Scenario: sentinel is type-distinct from undefined
- WHEN a caller writes
if (result === RATE_LIMITED)against a wrapper offn: () => void - THEN TypeScript narrows the discriminant correctly AND a wrapper of
fn: () => voidwhose call slot is full returnsRATE_LIMITED, notundefined
Verified by: src/throttle/rate-limit/rate-limit.spec-conformance.test.ts::"RATE_LIMITED narrows the wrapper return type"
Requirement: once-only invocation with throw-and-retry
The package SHALL expose once<TArgs extends unknown[], TReturn>(fn: (...args: TArgs) => TReturn): (...args: TArgs) => TReturn. The wrapper SHALL invoke fn on the first successful call and cache its return value. Subsequent calls SHALL return the cached value WITHOUT re-invoking fn. Critically, when fn throws, the wrapper SHALL re-throw the exception AND remain in the not-yet-run state so the next call retries.
Scenario: first call runs and caches
- WHEN a caller wraps
fn = () => Math.random()withonce(fn)and invokes the wrapper twice - THEN both invocations return the same value (the cached first result)
Scenario: throw does not lock the wrapper
- WHEN the first invocation of
fnthrows AND the wrapper is invoked a second time withfnnow succeeding - THEN the second invocation runs
fnAND returns the new value (notundefined)
Verified by: src/throttle/once/once.test.ts::"runs fn once and caches the result" AND src/throttle/once/once.test.ts::"re-runs fn after a throw" AND src/throttle/once/once.spec-conformance.test.ts::"timing.once.invocation counter increments only on first successful run"
Requirement: bounded-times invocation
The package SHALL expose times<TArgs extends unknown[], TReturn>(fn: (...args: TArgs) => TReturn, maxCalls: number): (...args: TArgs) => TReturn | undefined. The wrapper SHALL invoke fn for the first maxCalls calls and return undefined for every subsequent call without invoking fn.
Scenario: first n calls run, n+1th returns undefined
- WHEN a caller wraps
fn = (n: number) => n * 2withtimes(fn, 3)and invokes the wrapper 5 times with values1, 2, 3, 4, 5 - THEN the returned values are
[2, 4, 6, undefined, undefined]
Verified by: src/throttle/times/times.test.ts::"runs fn for the first maxCalls invocations and returns undefined thereafter" AND src/throttle/times/times.spec-conformance.test.ts::"timing.times.invocation counter increments per invocation up to maxCalls"
Requirement: Subpath exports
The package SHALL expose three new subpath exports in package.json::exports: ./throttle/rate-limit, ./throttle/once, ./throttle/times. Each SHALL resolve to its co-located dist/throttle/<feature>/index.js plus .d.ts. Importing only once SHALL NOT pull in rateLimit or times.
Scenario: subpath imports resolve
- WHEN a caller writes
import { rateLimit, RATE_LIMITED } from "@suaveplan/timing/throttle/rate-limit"ANDimport { once } from "@suaveplan/timing/throttle/once"ANDimport { times } from "@suaveplan/timing/throttle/times" - THEN TypeScript resolves all three imports
Verified by: src/throttle/rate-limit/rate-limit.spec-conformance.test.ts::"subpath ./throttle/rate-limit resolves and tree-shakes" AND src/throttle/once/once.spec-conformance.test.ts::"subpath ./throttle/once resolves and tree-shakes" AND src/throttle/times/times.spec-conformance.test.ts::"subpath ./throttle/times resolves and tree-shakes"