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

7.2 KiB

Capability Spec: Timing

Package: @suaveplan/timing Version: 0.0.0 (genesis) Status: ADDED


Requirements

TM-01 — Injectable clock

Every module that reads wall-clock time MUST accept { clock?: Clock } from @suaveplan/types. No Date.now() or setTimeout at module scope. Default clock uses Date.now().

Verified by: src/stopwatch/stopwatch.test.ts AND src/throttle/throttle.test.ts AND src/timer/timer.test.ts AND src/timing.spec-conformance.test.ts AND tests/no-disallowed.test.ts

TM-02 — AbortSignal support

Scheduler.drain(signal?) and streaming/iterative helpers MUST honour AbortSignal. Abort resolves the operation cleanly without throwing unless the signal fires mid-operation, in which case an AbortError-compatible rejection is emitted.

Verified by: src/scheduler/scheduler.test.ts AND src/timing.spec-conformance.test.ts

TM-03 — Stopwatch

createStopwatch MUST return a Stopwatch whose .elapsed is monotonically non-decreasing between .start() and .stop(). .lap() MUST record the split time relative to the previous lap start (or the stopwatch start). .reset() MUST clear all laps and reset elapsed to 0.

Verified by: src/stopwatch/stopwatch.test.ts AND src/timing.spec-conformance.test.ts

TM-04 — Throttle / debounce

throttle MUST emit at most one invocation per wait window (leading or trailing edge, configurable). debounce MUST delay invocation until wait ms after the last call; .flush() MUST invoke immediately; .cancel() MUST suppress the pending invocation. maxWait option on debounce caps total delay.

Verified by: src/throttle/throttle.test.ts AND src/throttle/debounce.test.ts AND src/timing.spec-conformance.test.ts

TM-05 — Decay

createDecay MUST accept a DecayFunction (or preset name 'exponential' | 'linear'). .tick(elapsed) advances the decay by elapsed milliseconds. The returned .value MUST be bounded in [target, initial] (or [initial, target] for growth). .reset() MUST restore initial.

Verified by: src/decay/decay.test.ts

TM-06 — Scheduler

createScheduler MUST execute scheduled callbacks at or after their requested delay using the injected clock. .cancel(id) MUST prevent the callback from firing. .drain(signal?) MUST flush all pending callbacks in delay order. After .dispose(), any operation MUST throw TimingError with code TIMING.SCHEDULER.DISPOSED.

Verified by: src/scheduler/scheduler.test.ts AND src/timing.spec-conformance.test.ts

TM-07 — Timer and countdown

createTimer MUST fire its tick event at each interval. createCountdown MUST validate that duration > 0; otherwise throw TimingError with code TIMING.COUNTDOWN.INVALID_DURATION. .pause() / .resume() MUST preserve .remaining across pause/resume cycles.

Verified by: src/timer/timer.test.ts AND src/timing.spec-conformance.test.ts

TM-08 — Vector clock

createVectorClock MUST maintain a Map<nodeId, number> counter. .increment() MUST increment the local node's counter. .update(remote) MUST take the component-wise maximum. vectorClockCompare MUST return 'before' | 'after' | 'concurrent' | 'equal' correctly for all pairs. .toJSON() / .fromJSON() MUST produce a lossless round-trip.

Verified by: src/vector-clock/vector-clock.test.ts AND src/timing.spec-conformance.test.ts

TM-09 — Structured errors

All thrown errors MUST extend TimingError which extends SuaveplanError. Codes under TIMING.* namespace.

Verified by: src/errors/timing-error.test.ts

TM-10 — Zod schemas

Every public options type MUST have a corresponding Zod schema in a sibling *.schemas.ts file.

Verified by: src/*/*.schemas.ts AND tests/dep-graph.test.ts

TM-11 — OTel instrumentation

Stopwatch elapsed, scheduler queue depth, and timer tick counter MUST emit via @opentelemetry/api. All instruments are no-ops when no exporter is registered.

Verified by: src/otel/timing-otel.test.ts AND src/timing.spec-conformance.test.ts

TM-12 — sideEffects: false + coverage 100%

"sideEffects": false with tree-shake test. Vitest thresholds at 100/100/100/100. No .skip / .only in src/.

Verified by: tests/tree-shake.test.ts AND tests/no-disallowed.test.ts AND coverage gate

TM-13 — Sliding-window rate limit

The package SHALL expose rateLimit<TArgs extends unknown[], TReturn>(fn, options): (...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.

Verified by: src/throttle/rate-limit/rate-limit.test.ts AND src/throttle/rate-limit/rate-limit.spec-conformance.test.ts

TM-14 — 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.

Verified by: src/throttle/rate-limit/rate-limit.spec-conformance.test.ts

TM-15 — once-only invocation with throw-and-retry

The package SHALL expose once<TArgs extends unknown[], TReturn>(fn): (...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. When fn throws, the wrapper SHALL re-throw the exception AND remain in the not-yet-run state so the next call retries.

Verified by: src/throttle/once/once.test.ts AND src/throttle/once/once.spec-conformance.test.ts

TM-16 — bounded-times invocation

The package SHALL expose times<TArgs extends unknown[], TReturn>(fn, maxCalls): (...args: TArgs) => TReturn | undefined. The wrapper SHALL invoke fn for the first maxCalls calls and return undefined for every subsequent call without invoking fn.

Verified by: src/throttle/times/times.test.ts AND src/throttle/times/times.spec-conformance.test.ts

TM-17 — Throttle-family subpath exports

The package SHALL expose subpath exports ./throttle/rate-limit, ./throttle/once, and ./throttle/times in package.json::exports. 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.

Verified by: src/throttle/rate-limit/rate-limit.spec-conformance.test.ts AND src/throttle/once/once.spec-conformance.test.ts AND src/throttle/times/times.spec-conformance.test.ts


Error codes

  • TIMING.SCHEDULER.DISPOSED
  • TIMING.COUNTDOWN.INVALID_DURATION
  • TIMING.VECTOR_CLOCK.UNKNOWN_NODE
  • TIMING.RATE_LIMIT_INVALID_WINDOW