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,256 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: storage-mux (storage-mux)
**Package**: `@suaveplan/storage-mux`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: StorageMux extends AbstractStorage
The package SHALL provide a `StorageMux` class extending `AbstractStorage` from `@suaveplan/storage`. The class SHALL accept `StorageMuxOptions` containing an ordered non-empty `backends: BackendEntry[]` and a `policy: MuxPolicy`. The class SHALL implement every method of the `StorageLike` surface (`get`, `set`, `setPartial`, `has`, `delete`, `list`, `stat`, `getStream`, `setStream`) by delegating to a `BackendSelector` chosen from the configured policy.
#### Scenario: subclass is an instance of AbstractStorage
- **WHEN** a `StorageMux` is constructed with at least one `BackendEntry`
- **THEN** the instance is `instanceof AbstractStorage` AND exposes every `StorageLike` method
#### Scenario: empty backend list is rejected at construction
- **WHEN** a caller invokes `new StorageMux({ backends: [], policy: "failover" })`
- **THEN** the constructor throws `StorageMuxError` with `code === "STORAGE_MUX.NO_BACKENDS_CONFIGURED"`
**Verified by:** `src/mux/mux.test.ts::"is instance of AbstractStorage and exposes StorageLike surface"` AND `src/mux/mux.test.ts::"rejects empty backend list with NO_BACKENDS_CONFIGURED"`
---
### Requirement: MuxPolicy discriminated union with four policies
The package SHALL declare `MuxPolicy` as the string union `"failover" | "round-robin" | "sticky-by-key" | "shield"`. Each policy SHALL be implemented as a `BackendSelector` strategy class in its own subdirectory under `src/policies/`. The `StorageMuxOptions.policy` field SHALL be validated by a Zod schema that rejects any value outside this whitelist with `STORAGE_MUX.INVALID_POLICY`.
#### Scenario: unknown policy string is rejected
- **WHEN** a caller invokes `new StorageMux({ backends, policy: "magic" as MuxPolicy })`
- **THEN** the constructor throws `StorageMuxError` with `code === "STORAGE_MUX.INVALID_POLICY"`
**Verified by:** `src/mux/mux.test.ts::"rejects unknown policy with INVALID_POLICY"`
---
### Requirement: failover policy falls through on transient errors and surfaces semantic errors
Under the `failover` policy the mux SHALL invoke `backends[0]` first. On a transient classified error (per the error-classification table in `design.md` §4) the mux SHALL advance to `backends[1]`, then `backends[2]`, and so on. On a semantic error (`STORAGE.KEY_NOT_FOUND`, `STORAGE.INVALID_KEY`, 4xx other than 408/429, `CREDENTIAL_EXPIRED`) the mux SHALL surface the error directly without advancing. When every backend has been tried and the last attempt was transient, the mux SHALL throw `StorageMuxError` with `code === "STORAGE_MUX.ALL_BACKENDS_DOWN"`, the prior error as `cause`, and `attemptCount` equal to the number of backends tried.
#### Scenario: transient error advances to next backend
- **WHEN** `backends[0].get(key)` rejects with a 503 and `backends[1].get(key)` resolves with bytes
- **THEN** the mux returns `backends[1]`'s bytes AND emits a `storage-mux.failover` event naming both backends
#### Scenario: semantic KEY_NOT_FOUND is not retried across backends
- **WHEN** `backends[0].get(key)` throws `STORAGE.KEY_NOT_FOUND`
- **THEN** the mux rethrows `KEY_NOT_FOUND` without invoking `backends[1]`
#### Scenario: exhausting all backends throws ALL_BACKENDS_DOWN with cause chain
- **WHEN** every backend rejects with a transient error
- **THEN** the mux throws `StorageMuxError` with `code === "STORAGE_MUX.ALL_BACKENDS_DOWN"` AND `cause` is the last backend's error AND `attemptCount === backends.length`
**Verified by:** `src/policies/failover/failover.test.ts::"falls through to next backend on transient 503"` AND `src/policies/failover/failover.test.ts::"surfaces semantic KEY_NOT_FOUND without fall-through"` AND `src/policies/failover/failover.test.ts::"throws ALL_BACKENDS_DOWN after exhausting list"`
---
### Requirement: round-robin policy distributes across healthy backends
Under the `round-robin` policy the mux SHALL maintain a monotonic counter and select `healthyBackends[counter mod healthyBackends.length]` for each call. Backends marked down by `BackendHealth` SHALL be skipped. When zero backends are healthy the mux SHALL throw `STORAGE_MUX.ALL_BACKENDS_DOWN`.
#### Scenario: ten calls across three healthy backends distribute evenly
- **WHEN** the caller issues 9 `get` calls against a mux with 3 healthy backends
- **THEN** each backend receives exactly 3 calls
#### Scenario: down backend is skipped
- **WHEN** `backends[1]` is marked down and the caller issues 4 calls
- **THEN** `backends[0]` and `backends[2]` each receive 2 calls AND `backends[1]` receives 0
**Verified by:** `src/policies/round-robin/round-robin.test.ts::"distributes nine calls evenly across three backends"` AND `src/policies/round-robin/round-robin.test.ts::"skips backends marked down"`
---
### Requirement: sticky-by-key policy is deterministic and skips down backends
Under the `sticky-by-key` policy the mux SHALL compute `index = fnv1a(utf8(key)) mod healthyBackends.length` and route every operation for that key to the same backend. The hash SHALL be pure-JS FNV-1a (no native or crypto dep). When a backend goes down its keys SHALL be redistributed across the remaining healthy backends via the same formula recomputed against the shrunken set.
#### Scenario: same key resolves to same backend across 100 calls
- **WHEN** the caller issues 100 `get("alpha")` calls against a mux with 3 healthy backends
- **THEN** exactly one backend receives all 100 calls
#### Scenario: removed backend triggers redistribution
- **WHEN** the backend currently serving `"alpha"` is marked down
- **THEN** subsequent `get("alpha")` calls land on one of the remaining healthy backends AND the choice is stable until the health set changes again
**Verified by:** `src/policies/sticky-by-key/sticky-by-key.test.ts::"routes the same key to the same backend across 100 calls"` AND `src/policies/sticky-by-key/sticky-by-key.test.ts::"redistributes keys when a backend goes down"`
---
### Requirement: shield policy cascades reads and fans out writes
Under the `shield` policy the mux SHALL treat `backends[0]` as the origin and `backends[1..n]` as mirrors. For read operations (`get`, `has`, `stat`, `getStream`) the mux SHALL invoke `backends[0]` first and on transient error or timeout fall through to mirrors in order. For write operations (`set`, `setPartial`, `delete`, `setStream`) the mux SHALL invoke `backends[0]` AND every mirror via `Promise.allSettled`. If the origin write rejects the mux SHALL throw with the origin error as `cause`. If the origin write succeeds but one or more mirror writes reject the mux SHALL emit `storage-mux.fanout-partial` carrying every failed mirror name AND return successfully (the operation is considered durable once the origin accepts it). The mux SHALL additionally surface a `StorageMuxError` with `code === "STORAGE_MUX.WRITE_FANOUT_PARTIAL"` when the caller opts in via `StorageMuxOptions.throwOnFanoutPartial === true`.
#### Scenario: read falls through to mirror after origin timeout
- **WHEN** `backends[0].get(key)` hangs past `backendTimeoutMs` and `backends[1].get(key)` resolves with bytes
- **THEN** the mux returns `backends[1]`'s bytes
#### Scenario: write succeeds when origin accepts and mirror rejects
- **WHEN** `backends[0].set` resolves and `backends[1].set` rejects with a transient error
- **THEN** the mux returns successfully AND emits `storage-mux.fanout-partial` naming `backends[1]`
#### Scenario: write fails when origin rejects
- **WHEN** `backends[0].set` rejects with any error
- **THEN** the mux throws AND `cause` is the origin error AND mirror outcomes are not consulted
**Verified by:** `src/policies/shield/shield.test.ts::"reads fall through to mirror after origin timeout"` AND `src/policies/shield/shield.test.ts::"write succeeds when origin accepts and one mirror rejects"` AND `src/policies/shield/shield.test.ts::"write fails when origin rejects regardless of mirror outcomes"`
---
### Requirement: backend isolation — slow primary cannot block fallback
The mux SHALL wrap every backend call in a `Promise.race` against a `Clock`-driven timeout of `backendTimeoutMs` (default 30000, configurable per-mux). When the race rejects due to timeout the mux SHALL classify the timeout as transient AND proceed to the next backend per policy. The underlying backend promise MAY remain pending; the mux SHALL NOT await it. The mux SHALL link the user `AbortSignal` and the timeout signal via `AbortSignal.any` and pass the combined signal to every backend call so well-behaved backends abort promptly.
#### Scenario: hung primary does not delay fallback beyond backendTimeoutMs
- **WHEN** `backends[0].get` returns a never-resolving promise and `backends[1].get` resolves in 10ms
- **THEN** the caller receives `backends[1]`'s value in less than `backendTimeoutMs + 50ms` measured against the injected `FakeClock`
**Verified by:** `src/mux/mux.test.ts::"hung primary does not block fallback past backendTimeoutMs"`
---
### Requirement: AbortSignal propagation surfaces BACKEND_ABORTED
When the caller's `AbortSignal` aborts during a mux operation, the mux SHALL surface a `StorageMuxError` with `code === "STORAGE_MUX.BACKEND_ABORTED"` rather than falling through to subsequent backends. The mux SHALL NOT classify a caller-driven abort as a transient backend failure.
#### Scenario: caller abort surfaces BACKEND_ABORTED and does not advance
- **WHEN** the caller passes a pre-aborted signal to `mux.get(key, signal)`
- **THEN** the mux throws `StorageMuxError` with `code === "STORAGE_MUX.BACKEND_ABORTED"` AND `backends[1].get` is NEVER invoked
**Verified by:** `src/mux/mux.test.ts::"caller abort surfaces BACKEND_ABORTED without falling through"`
---
### Requirement: BackendHealth — passive degrade with exponential cooldown
The package SHALL maintain a `BackendHealth` tracker per backend. A backend SHALL transition from `up` to `down` on the first classified-transient error. The cooldown SHALL be `cooldownMs * 2 ** min(consecutiveFailures, 6)` (exponential, capped at 64× base, default `cooldownMs` 1000). A `down` backend SHALL be skipped by every selector. Once `Clock.now() >= cooldownUntil` the next operation SHALL probe the backend; success transitions it back to `up` and resets `consecutiveFailures`.
#### Scenario: failing backend cools down for 2000ms after second failure
- **WHEN** a backend rejects twice in succession (default `cooldownMs` 1000)
- **THEN** the backend is `down` AND remains skipped until `FakeClock` advances by 2000ms
#### Scenario: successful probe after cooldown restores backend and emits recovered event
- **WHEN** `FakeClock` advances past `cooldownUntil` AND the next probe succeeds
- **THEN** the backend is `up` AND a `storage-mux.backend.recovered` event is emitted AND `consecutiveFailures` is reset to 0
**Verified by:** `src/health/health.test.ts::"exponential cooldown after consecutive failures"` AND `src/health/health.test.ts::"successful probe after cooldown restores backend and emits recovered"`
---
### Requirement: Optional active healthchecks via injected Clock
When `BackendEntry.healthcheck` is provided AND `StorageMuxOptions.healthcheckIntervalMs` is set, the mux SHALL schedule the healthcheck via the injected `Clock.setTimeout` (or `Clock.setInterval`) and invoke it with an `AbortSignal`. A rejecting healthcheck SHALL mark the backend `down` AND emit `storage-mux.backend.down` carrying `STORAGE_MUX.HEALTHCHECK_FAILED` as the cause. The mux SHALL stop scheduling when the mux is disposed.
#### Scenario: rejecting active healthcheck marks backend down
- **WHEN** an active healthcheck rejects with a network error
- **THEN** the backend transitions to `down` AND a `storage-mux.backend.down` event is emitted AND the failure cause is `STORAGE_MUX.HEALTHCHECK_FAILED`
**Verified by:** `src/health/health.test.ts::"rejecting active healthcheck marks backend down with HEALTHCHECK_FAILED"`
---
### Requirement: Retry composition via @suaveplan/retry
Every per-backend invocation SHALL be wrapped by `runWithRetry(retryStrategy, ...)` from `@suaveplan/retry` using the strategy supplied in `StorageMuxOptions.retry` (defaulting to `exponentialBackoff()` from `@suaveplan/retry/strategies/exponential/exponential`). The mux SHALL NOT reimplement backoff, jitter, or attempt counting; all retry behavior MUST be expressed by composing the retry strategy.
#### Scenario: backend that fails twice then succeeds is retried in place before fall-through
- **WHEN** `backends[0].get` rejects twice then resolves on the third attempt under a 3-attempt retry strategy
- **THEN** the mux returns `backends[0]`'s value AND `backends[1].get` is NEVER invoked
**Verified by:** `src/policies/failover/failover.test.ts::"retries in place before falling through to next backend"`
---
### Requirement: STORAGE_MUX error registry — 8 codes reachable
The package SHALL declare `STORAGE_MUX_CODES` via `defineCodes("STORAGE_MUX", [...])` with exactly these 8 codes: `ALL_BACKENDS_DOWN`, `NO_BACKENDS_CONFIGURED`, `HEALTHCHECK_FAILED`, `BACKEND_TIMEOUT`, `INVALID_POLICY`, `INVALID_WEIGHT`, `WRITE_FANOUT_PARTIAL`, `BACKEND_ABORTED`. The package SHALL declare `StorageMuxError extends SuaveplanError<StorageMuxErrorCode>` with optional `backendName`, `attemptIndex`, and `attemptCount` discriminants. Every code SHALL be reachable from a real call site exercised by tests in this package.
#### Scenario: every declared code is reachable from a real call site
- **WHEN** the test suite is run
- **THEN** every code in `STORAGE_MUX_CODES` has at least one test that exercises the call site that throws or carries it
**Verified by:** `src/error-codes/error-codes.test.ts::"every STORAGE_MUX code is reachable from a real call site"`
---
### Requirement: Observability — EventBusLike events on lifecycle transitions
The mux SHALL emit events through an injected `EventBusLike` (from `@suaveplan/types`) on the following lifecycle transitions: `storage-mux.backend.down` (passive or active failure), `storage-mux.backend.recovered` (probe success after cooldown), `storage-mux.failover` (failover policy advances to a new backend), `storage-mux.fanout-partial` (shield-policy mirror write rejected while origin succeeded). When no `EventBusLike` is provided the mux SHALL operate normally without emitting (silent fallback).
#### Scenario: failover emits storage-mux.failover with both backend names
- **WHEN** the failover policy advances from `backends[0]` to `backends[1]` due to a 503
- **THEN** exactly one `storage-mux.failover` event is emitted carrying both backend names AND the underlying error
**Verified by:** `src/observability/events.test.ts::"failover emits storage-mux.failover with from and to backend names"`
---
### Requirement: Observability — OTel span attributes per backend call
Every backend call SHALL be wrapped in an OTel span carrying the attributes `mux.backend.name` (string), `mux.attempt.index` (number, 0-based), and `mux.policy` (string). When no tracer is provided (`TracerLike` from `@suaveplan/types`) the mux SHALL operate normally without recording spans.
#### Scenario: failover attempt records two spans with attempt indices 0 and 1
- **WHEN** the failover policy invokes `backends[0]` (which rejects) then `backends[1]` (which succeeds)
- **THEN** the test tracer records two spans with `mux.attempt.index` equal to `0` and `1` respectively AND matching `mux.backend.name`
**Verified by:** `src/observability/otel.test.ts::"records one span per backend attempt with attempt index and backend name"`
---
### Requirement: StorageLike conformance under every policy
The mux SHALL pass `runStorageConformance` (from `@suaveplan/storage`) when configured with three `MemoryStorage` backends under each of the four policies (`failover`, `round-robin`, `sticky-by-key`, `shield`). The conformance suite SHALL include `setPartial` sparse-extension and overwrite semantics, capability-honest reporting, and AbortSignal propagation.
#### Scenario: conformance suite passes under failover, round-robin, sticky-by-key, and shield
- **WHEN** `runStorageConformance(mux)` is invoked four times — once per policy — against the same three-MemoryStorage backend set
- **THEN** every conformance assertion passes for every policy
**Verified by:** `src/mux/mux.spec-conformance.test.ts::"passes runStorageConformance under failover"` AND `src/mux/mux.spec-conformance.test.ts::"passes runStorageConformance under round-robin"` AND `src/mux/mux.spec-conformance.test.ts::"passes runStorageConformance under sticky-by-key"` AND `src/mux/mux.spec-conformance.test.ts::"passes runStorageConformance under shield"`
---
### Requirement: Tier purity — universal, no Node or DOM imports
The package SHALL NOT import from `node:*`, `bun:*`, or any DOM-only API. Hashing SHALL be pure-JS (FNV-1a). Timeouts SHALL use the injected `Clock`. The package SHALL run unmodified under Node 18+, Bun, Deno, modern browsers, and Cloudflare Workers.
#### Scenario: tier-purity grep finds no Node or DOM imports
- **WHEN** `grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-mux/src/` is run
- **THEN** the output is empty
**Verified by:** `src/mux/mux.spec-conformance.test.ts::"src tree contains no node: or bun: imports"`