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.