Files
suaveplan/openspec/archive/2026-05-18-timing-completion/specs/timing/spec.md
2026-05-27 23:29:53 +01:00

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. (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"