Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
# Timing completion — rateLimit / once / times helpers
**Package**: `@suaveplan/timing`
**Status**: PROPOSED
**Type**: feature
## Why
The 2026-04-29 `timing-spec-conformance` change deliberately archived the more elaborate timing constructs (`createHighPrecisionStopwatch`, `createHighConcurrencyScheduler`, `VectorClockManager`) on architectural grounds — those landed in higher-tier packages or were superseded by composable primitives. However, three smaller helpers from the legacy `throttle/throttle.ts` module never got the same treatment: `rateLimit`, `once`, and `times`. They are not architecturally suspect, they are not duplicated elsewhere on genesis, and they fall well within the scope CLAUDE.md describes for `@suaveplan/timing`. They were simply missed.
The 2026-05-02 audit found no replacement for any of the three:
- `rateLimit(fn, maxCalls, windowMs)` — different from `throttle` (which spaces calls evenly across an interval). `rateLimit` enforces *N calls per sliding window* and lets callers burst inside the window. This is the contract REST clients, webhook deliverers, and feed pollers need; throttle is the wrong tool. Genesis has no equivalent.
- `once(fn)` — wraps a function so it executes exactly once and caches the result. Used by initialization paths where a check-and-set guard is too verbose (e.g. lazy DB connection pool warm-up). Genesis has no equivalent. The closest pattern is wrapping in `Promise.resolve(...)` and stashing the promise, which loses the synchronous return signature for synchronous functions.
- `times(fn, maxCalls)` — wraps a function so it runs for the first `n` calls and becomes a no-op afterward. Useful for "log this warning the first 5 times then stop" patterns; the alternative — manual counters threaded through every call site — is clutter genesis already has too much of.
All three are 15-30 line implementations with a small surface and clear semantics. Adding them to the existing `throttle/` feature module (alongside `throttle` and `debounce`) is the lowest-friction path; renaming the module to `rate-control/` would have the same outcome but disturbs the existing tree-shake boundary. Keep `throttle/` and add the three siblings.
## What changes
### `@suaveplan/timing`
- ADDED: `rateLimit<TArgs, TReturn>(fn, options): (...args: TArgs) => TReturn | RateLimitedSentinel` — bounded calls per window. `options.maxCalls: number`, `options.windowMs: number`, `options.clock?: Clock` (injectable; defaults to system clock). When the window cap is exceeded, the wrapped function returns `RATE_LIMITED` (the exported sentinel) instead of invoking `fn`. The sliding window is implemented as a FIFO of timestamps; entries older than `windowMs` are evicted on every call.
- ADDED: `RATE_LIMITED` constant — the sentinel symbol returned by `rateLimit` when the cap is reached. Exported alongside `rateLimit`.
- ADDED: `RateLimitedSentinel` type alias — `typeof RATE_LIMITED`.
- ADDED: `once<TArgs, TReturn>(fn): (...args: TArgs) => TReturn` — wrapped function that runs `fn` exactly once and caches the result. Subsequent calls return the cached value with the same `TReturn` type (no `| undefined` widening). Throws of `fn` are *not* cached — a thrown call counts as not-yet-run, allowing retry.
- ADDED: `times<TArgs, TReturn>(fn, maxCalls): (...args: TArgs) => TReturn | undefined` — wrapped function that runs `fn` for the first `maxCalls` invocations and returns `undefined` thereafter.
- ADDED: subpath export `./throttle` already exists; the three helpers live in `src/throttle/rate-limit/rate-limit.ts`, `src/throttle/once/once.ts`, `src/throttle/times/times.ts`. Each gets its own subpath: `./throttle/rate-limit`, `./throttle/once`, `./throttle/times`.
- ADDED: OTel counter `timing.rate_limit.rejected` (no labels) incremented every time `rateLimit` returns the sentinel. Counter `timing.once.invocation` and `timing.times.invocation` similarly emitted for observability parity with `throttle` / `debounce`.
- ADDED: error code `TIMING.RATE_LIMIT_INVALID_WINDOW` — thrown by the `rateLimit` factory when `maxCalls < 1` or `windowMs <= 0`.
## Out of scope
- **`createHighPrecisionStopwatch`**, **`createHighConcurrencyScheduler`**, **`VectorClockManager`** — these were intentionally archived in `2026-04-29-timing-spec-conformance` and stay archived. This change does not revisit that decision.
- **Async `rateLimit` with queue-and-retry semantics.** The legacy implementation drops over-cap calls; genesis matches that behaviour. A queueing variant (await-until-slot-available) is a separate proposal — it has different cancellation, abort-signal, and back-pressure semantics and warrants its own design.
- **Distributed rate limiting.** The wrapped function is process-local. Cluster-wide rate limiting goes through `@suaveplan/idempotency` or an external token bucket; this change does not address it.
- **Memoization with eviction.** `once` is intentionally simple — no LRU, no TTL, no key-based memoization. Callers that need keyed memoization compose `once` per key OR reach for `@suaveplan/idempotency`.
- **Re-introducing `Date.now()` at module scope.** All three helpers accept an injectable `Clock` from `@suaveplan/types`; the default is a `() => Date.now()` clock injected at construction time, not module scope. This complies with CLAUDE.md determinism rules.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `rateLimit(func, maxCalls, windowMs, _options)` | ✅ | ⬆️ | Enhanced: signature changed to `(fn, options)` (object) for ergonomic parity with `throttle` / `debounce`. Injectable `Clock`. Returns `RATE_LIMITED` sentinel instead of `undefined` for clarity. |
| `once(func)` | ✅ | ⬆️ | Enhanced: exception-aware — if `fn` throws, the wrapper re-throws and stays "not yet run" so retries are possible (legacy cached the throw via the side-effecting `called = true; result = func(...)` order, which silently re-returned `undefined` on retry). |
| `times(func, maxCalls)` | ✅ | ✅ | Verbatim semantics. |
## Source mapping
This change derives from `../suaveplan/packages/universal/timing/src/throttle/throttle.ts`.
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `rateLimit(fn, maxCalls, windowMs)` semantics | `packages/universal/timing/src/throttle/throttle.ts` | L278-L309 | Verbatim semantics; signature reshaped to `(fn, options)` and return value changed to a sentinel. |
| `once(fn)` semantics | `packages/universal/timing/src/throttle/throttle.ts` | L314-L328 | Enhanced: throw-and-retry behaviour added (legacy cached the call attempt regardless). |
| `times(fn, maxCalls)` semantics | `packages/universal/timing/src/throttle/throttle.ts` | L333-L345 | Verbatim. |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `RATE_LIMITED` sentinel symbol | Genesis-wide preference for explicit sentinels over `undefined` returns — `undefined` ambiguously overlaps with functions whose `TReturn` is `void`. The legacy signature returned `TReturn \| undefined` and could not distinguish "rate-limited" from "function returned undefined". |
| Object-shaped `options` parameter for `rateLimit` | Genesis-wide ergonomic rule — every primitive helper in `@suaveplan/timing` (`throttle`, `debounce`) takes `(fn, options)`. `rateLimit` matching that shape is the lowest-friction call site. |
| Injectable `Clock` for `rateLimit` window math | Genesis-wide determinism rule — every time-dependent helper accepts a `Clock` from `@suaveplan/types`. |
| OTel counters `timing.rate_limit.rejected`, `timing.once.invocation`, `timing.times.invocation` | Genesis-wide observability rule. |
| Error code `TIMING.RATE_LIMIT_INVALID_WINDOW` | Genesis-wide policy that every thrown error route through `@suaveplan/error` with a stable code. Legacy threw nothing — it accepted `0` / negative inputs silently and returned a wrapper that always rate-limited. |
| Throw-and-retry semantics for `once` | Legacy `called = true` runs before `result = func(...)`, so a throw locks the wrapper out forever. Genesis fixes this — throwing leaves `called` at `false` so a subsequent call retries. Documented in `once.md`. |
## Verification approach
- `src/throttle/rate-limit/rate-limit.test.ts` — bursts inside window, eviction across window boundary, sentinel returned at cap, injectable clock advances drive eviction.
- `src/throttle/rate-limit/rate-limit.spec-conformance.test.ts` — `RATE_LIMITED` is a unique symbol; OTel counter increments on rejection; invalid `maxCalls` / `windowMs` throws `TIMING.RATE_LIMIT_INVALID_WINDOW`.
- `src/throttle/once/once.test.ts` — runs once, caches return, throw-and-retry flow.
- `src/throttle/once/once.spec-conformance.test.ts` — counter increments on first invocation only; type-level `TReturn` (not `TReturn | undefined`) on the wrapper.
- `src/throttle/times/times.test.ts` — runs `n` times, returns `undefined` thereafter, counter behaviour.
- `src/throttle/times/times.spec-conformance.test.ts` — counter increments per invocation up to `maxCalls`.
## Impact
### Affected dependents
`@suaveplan/timing` currently has zero downstream packages on genesis (verified by `bunx turbo ls --filter=...@suaveplan/timing` returning only the package itself).
- No dependents (verified by `grep -rln "@suaveplan/timing" /home/ubuntu/web/genesis/packages/ --include=package.json` returning only `packages/timing/timing/package.json`).
When new consumers land they import `@suaveplan/timing/throttle/rate-limit`, etc. The change is purely additive — no existing public surface is removed or renamed.
## Migration impact
None — additive only. `throttle` and `debounce` already exist at the same subpath structure; the three new helpers slot in alongside them.

View File

@@ -0,0 +1,97 @@
<!-- 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 -->

View File

@@ -0,0 +1,73 @@
# Tasks — Timing completion (rateLimit / once / times)
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened.
## Audit Note (2026-05-02)
Status: **DEFERRED**. None of `rateLimit`, `once`, `times`, or the `RATE_LIMITED` sentinel exist in `packages/timing/timing/src/throttle/` (currently only contains `throttle.ts`, `throttle.types.ts`, `throttle.schemas.ts`, plus `debounce.test.ts`). 0% of work is done.
## 1. Spec authoring
- [ ] 1.1 Read `../suaveplan/packages/universal/timing/src/throttle/throttle.ts` (rateLimit / once / times sections, L275-L346)
- [ ] 1.2 Read `openspec/specs/timing/spec.md` and the archived `2026-04-29-timing-spec-conformance` proposal
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
- [ ] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [ ] 1.5 Fill in `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
- [ ] 1.6 Draft `specs/timing/spec.md` from `openspec/templates/spec-delta.md`
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [ ] 2.1 Register error code `TIMING.RATE_LIMIT_INVALID_WINDOW` in `src/errors/`
- [ ] 2.2 Create `src/throttle/rate-limit/` feature module with `rate-limit.ts`, `rate-limit.types.ts`, `rate-limit.schemas.ts`, `rate-limit.md`, `index.ts`
- [ ] 2.3 Implement `rateLimit(fn, options)` with sliding-window FIFO and injectable `Clock`; export `RATE_LIMITED` sentinel and `RateLimitedSentinel` type alias
- [ ] 2.4 Create `src/throttle/once/` feature module with `once.ts`, `once.md`, `index.ts`
- [ ] 2.5 Implement `once(fn)` with throw-and-retry semantics
- [ ] 2.6 Create `src/throttle/times/` feature module with `times.ts`, `times.md`, `index.ts`
- [ ] 2.7 Implement `times(fn, maxCalls)`
- [ ] 2.8 Wire OTel counters `timing.rate_limit.rejected`, `timing.once.invocation`, `timing.times.invocation` via `src/otel/`
- [ ] 2.9 Add subpath exports `./throttle/rate-limit`, `./throttle/once`, `./throttle/times` to `package.json::exports`
- [ ] 2.10 Re-export the three helpers + `RATE_LIMITED` from the root barrel
## 3. Tests
- [ ] 3.1 Unit tests at `src/throttle/rate-limit/rate-limit.test.ts` — bursts inside window, eviction across window boundary, sentinel returned at cap, injectable clock drives eviction
- [ ] 3.2 Spec-conformance tests at `src/throttle/rate-limit/rate-limit.spec-conformance.test.ts` — `RATE_LIMITED` unique symbol pinned via `expectTypeOf`, counter increments on rejection (real `MeterProvider`), invalid options throw `TIMING.RATE_LIMIT_INVALID_WINDOW`
- [ ] 3.3 Unit tests at `src/throttle/once/once.test.ts` — runs once, caches return, throw-and-retry flow
- [ ] 3.4 Spec-conformance tests at `src/throttle/once/once.spec-conformance.test.ts` — counter increments on first invocation only, `TReturn` (not `TReturn | undefined`) on wrapper
- [ ] 3.5 Unit tests at `src/throttle/times/times.test.ts` — runs `n` times, returns `undefined` thereafter
- [ ] 3.6 Spec-conformance tests at `src/throttle/times/times.spec-conformance.test.ts` — counter increments per invocation up to `maxCalls`
- [ ] 3.7 Property-based tests via `fast-check` for `rateLimit` window invariant: at any instant, the count of timestamps in the FIFO is at most `maxCalls`
- [ ] 3.8 Every `it(...)` carries `{ timeout: 5000 }`
## 4. Documentation
- [ ] 4.1 `src/throttle/rate-limit/rate-limit.md` — sibling doc (≥200 words, basic + advanced, full API including `RATE_LIMITED` sentinel)
- [ ] 4.2 `src/throttle/once/once.md` — sibling doc (≥200 words, document throw-and-retry semantics explicitly, distinguish from legacy)
- [ ] 4.3 `src/throttle/times/times.md` — sibling doc (≥200 words)
- [ ] 4.4 README links all three `.md` files under `## Modules`
- [ ] 4.5 Update `CLAUDE.md` `@suaveplan/timing` entry to include `rateLimit`, `once`, `times`
## 5. Pre-archive verification (the 5-gate triple)
- [ ] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/timing` — green
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/timing` — 100/100/100/100
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/timing` — exit 0, no `error` findings
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/timing` — green
## 6. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [ ] 6.1 Generics threaded through every wrapper; `expectTypeOf` confirms `RATE_LIMITED` is a unique symbol
- [ ] 6.2 Zod schemas wired at `rateLimit` boundary (validates `maxCalls >= 1`, `windowMs > 0`)
- [ ] 6.3 `TIMING.RATE_LIMIT_INVALID_WINDOW` thrown by at least one production path
- [ ] 6.4 Strict-mode behaviors tested (e.g. `once` throw-and-retry vs throw-and-cache contrast)
- [ ] 6.5 OTel counters emitted from production paths and asserted with a real in-memory `MeterProvider`
- [ ] 6.6 Lifecycle dedup — `once` itself is the dedup primitive; tested directly
- [ ] 6.7 No retroactive tick — every box above reflects work done in this change
## 7. Archive
- [ ] 7.1 Fold spec delta into `openspec/specs/timing/spec.md`
- [ ] 7.2 Move `openspec/changes/timing-completion/` → `openspec/archive/<yyyy-mm-dd>-timing-completion/`
- [ ] 7.3 Refresh `MANIFEST.md` if dep set changed (no expected change)