# Capability Spec Delta: timing (timing-completion) **Package**: `@suaveplan/timing` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.) ## ADDED Requirements ### Requirement: Sliding-window rate limit The package SHALL expose `rateLimit(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 `fn` with `rateLimit(fn, { maxCalls: 3, windowMs: 1000 })` and invokes the wrapper 3 times within 100ms (under a fake clock) - **THEN** `fn` runs 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_LIMITED` AND `fn` is not invoked #### Scenario: window slides - **WHEN** the fake clock advances past `windowMs` AND the wrapper is invoked again - **THEN** the oldest FIFO entry is evicted AND `fn` runs (counter = 4) #### Scenario: invalid options throw - **WHEN** a caller invokes `rateLimit(fn, { maxCalls: 0, windowMs: 1000 })` - **THEN** the factory throws `TimingError` with code `TIMING.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 of `fn: () => void` - **THEN** TypeScript narrows the discriminant correctly AND a wrapper of `fn: () => void` whose call slot is full returns `RATE_LIMITED`, not `undefined` **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(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()` with `once(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 `fn` throws AND the wrapper is invoked a second time with `fn` now succeeding - **THEN** the second invocation runs `fn` AND returns the new value (not `undefined`) **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(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 * 2` with `times(fn, 3)` and invokes the wrapper 5 times with values `1, 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//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"` AND `import { once } from "@suaveplan/timing/throttle/once"` AND `import { 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"`