Files
2026-05-27 23:29:53 +01:00

98 lines
6.4 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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<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 `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<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()` 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<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 * 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/<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"` 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"`
<!-- markdownlint-enable MD024 -->