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,106 @@
# Add @suaveplan/storage-uri-plugin-fallback — multi-source URI resolution via `fallback://` meta-scheme
**Package**: `@suaveplan/storage-uri-plugin-fallback`
**Status**: PROPOSED
**Type**: new-package (genesis-original)
- **Design**: ./design.md
- **Spec delta**: ./specs/storage-uri-plugin-fallback/spec.md
## Why
Consumers of `@suaveplan/storage-uri` increasingly need **multi-source resolution** — try several URIs in order (or in parallel), return the first that resolves. The parallel audio-IR loader session almost shipped this as ad-hoc manifest data (a plain `uris: string[]` field on each impulse-response manifest); that solution only benefits the audio loader and forces every future multi-source consumer to reinvent the same control flow on top of the resolver.
A **URI-scheme** approach generalises the capability: any callsite that already accepts a single URI gains failover by changing one string. The resolver dispatch table does not change. Every existing `UriPlugin` (`drive`, `file`, `https`, `bundle`, `s3`, `cdn`, …) becomes a valid candidate. This is the URI-layer analogue of `@suaveplan/storage-mux` (which composes `StorageLike` backends at the adapter layer) — they are complementary, not competing (see `design.md` §6).
The plugin reuses the resolver it is registered into for per-candidate dispatch — a `fallback://` URI is resolved by recursively calling the **same** `StorageUriResolver`, with cycle detection and a hard depth cap to prevent self-reference DoS.
### ≥2-consumer justification (per `openspec/AGENTS.md` §0 step 6)
Forward-declared consumers that share the `fallback://` primitive:
1. **Audio-IR loader (parallel session)** — primary `bundle://ir-pack-v3/halls/concert.wav` with fallback to `https://cdn.example.com/irs/halls/concert.wav`. The loader becomes scheme-agnostic — it no longer needs manifest-level multi-source logic.
2. **Drive + CDN pairing** — read from `cdn://zone-eu/avatars/alice.jpg`; if the CDN returns NOT_FOUND or a transient 5xx, fall back to `drive://primary/avatars/alice.jpg` (the authoritative origin). Same primitive used by `storage-plugin-cache` for the URI tier.
3. **Generic CDN multi-origin failover at the URI tier** — `cdn-bunnycdn://…` first, `cdn-rackspace://…` second. `storage-mux` already covers this at the adapter layer when you control the storage instance; `fallback://` covers it when you only have URI strings (HTTP redirects, manifests, config).
4. **Test fixtures** — primary points at a local `file:///tmp/fixtures/...`, fallback at a network fixture URL, second fallback at an inline `data:` URI. Lets test setups degrade gracefully when fixture servers are unavailable.
5. **Drive-template hydration** — a template descriptor names multiple chunk-origin URIs; the consumer wraps them in a `fallback://` so the chunk-puller can keep its existing single-URI interface.
6. **Cache warm-from-cold** — `storage-plugin-cache` reads from `cache://local/<key>`; on miss, fall back to the cold-storage URI. Mirrors the same composition story as `storage-mux`'s `shield` mode but expressed at the URI scheme tier.
All six scenarios share the same primitive (ordered candidate list + error classification + abort propagation + mirror-write semantics); without the shared plugin each consumer reinvents the same walker and the `FALLBACK_URI.*` error registry fragments.
## What changes
- ADDED: `@suaveplan/storage-uri-plugin-fallback` package (universal tier, zero browser/Node API dependencies).
- ADDED: `fallbackUriPlugin(options): UriPlugin` factory taking `{ resolver: StorageUriResolver, policy?: FallbackPolicy, logger?: LoggerLike, events?: EventBusLike, clock?: Clock }` and returning a `UriPlugin` bound to the `fallback` scheme.
- ADDED: `fallback://` URI grammar — **query-param form** with embedded URIs in `?u=` repeated parameters. Example: `fallback://?u=https%3A%2F%2Fcdn.example.com%2Ffoo&u=drive%3A%2F%2Fprimary%2Ffoo`. Decision matrix and rationale in `design.md` §1.
- ADDED: `parseFallbackUri(uri)` helper exporting the canonical candidate list with `INVALID_URI` / `NO_CANDIDATES` rejection.
- ADDED: `FallbackPolicy` discriminated bundle: `errorClass: "transient" | "any"` (default `"transient"` + NOT_FOUND), `maxParallel: number` (default 1; race mode when > 1), `stopOnAbort: boolean` (default true), `writeMode: "all" | "first-success"` (default `"all"` — mirror writes keep candidates in sync), `listMode: "union" | "concat"` (default `"union"` with dedup), `maxDepth: number` (default 4, hard ceiling 8).
- ADDED: Recursive resolver dispatch — the plugin holds a `StorageUriResolver` reference; each candidate URI is resolved through that resolver so nested `fallback://` is supported (depth-tracked via an `AsyncLocalStorage`-equivalent injection token passed via options).
- ADDED: **Cycle detection** — every recursion frame appends the original URI to a visited set; re-encountering the same URI throws `FALLBACK_URI.CIRCULAR_REFERENCE`.
- ADDED: **Depth cap** — recursion through nested `fallback://` URIs is capped at `policy.maxDepth` (default 4, hard ceiling 8); exceeding throws `FALLBACK_URI.MAX_DEPTH_EXCEEDED`.
- ADDED: Full `StorageLike` adapter surface attached to the plugin:
- `get(key)` / `createReadStream(key, signal)` — sequential or parallel walk; first success returns.
- `exists(key)` / `getMetadata(key, signal)` — sequential walk; first success returns.
- `set(key, bytes)` / `createWriteStream(key, body, signal)` — mirror to all candidates by default; configurable to first-success-only.
- `delete(key)` — mirror delete to all candidates; success = any candidate deletes (configurable).
- `list({ prefix, limit })` — union with dedup by URI string.
- ADDED: **Range read** support — `{ offset, length }` propagated to each candidate via the resolver's existing surface.
- ADDED: **AbortSignal composition** — caller signal + per-candidate timeout signal composed via `AbortSignal.any`. `stopOnAbort: true` cuts the walk immediately on caller abort; `false` lets the current candidate complete.
- ADDED: **All-failed reporting** — when every candidate errors, throws `FALLBACK_URI.ALL_CANDIDATES_FAILED` with `candidateErrors: readonly { uri: string; error: SuaveplanError }[]` preserved on the structured error options and cross-linked via `cause` to the last failure.
- ADDED: **Write-mirror semantics** — write fan-out uses `Promise.allSettled`; under `writeMode: "all"` any rejection throws `FALLBACK_URI.WRITE_MIRROR_FAILED` with per-candidate outcomes; under `"first-success"` the first success returns and pending writes are allowed to settle in the background.
- ADDED: **List-union dedup** — concatenated key sets are deduped by canonical URI string; collisions where two candidates yield the same URI count once (caller's responsibility to verify content parity).
- ADDED: `FALLBACK_URI_CODES` via `defineCodes("FALLBACK_URI", […])` — `NO_CANDIDATES`, `MAX_DEPTH_EXCEEDED`, `ALL_CANDIDATES_FAILED`, `INVALID_URI`, `ABORTED`, `WRITE_MIRROR_FAILED`, `LIST_UNION_FAILED`, `CIRCULAR_REFERENCE` (8 codes total; each reachable from a real call site exercised by tests).
- ADDED: `FallbackUriError extends SuaveplanError<FallbackUriErrorCode>` with `uri`, `candidateIndex`, `candidateUri`, `candidateErrors` discriminants.
- ADDED: **Observability**:
- OTel counter `storage_uri.fallback_total{candidate_index, scheme, outcome}` per candidate attempt.
- OTel histogram `storage_uri.fallback_walk_ms{outcome}` per top-level operation.
- Parent span `storage-uri.fallback.<op>` with child span `storage-uri.fallback.attempt` per candidate (attrs `fallback.candidate.index`, `fallback.candidate.scheme`, `fallback.candidate.outcome ∈ {ok, transient, semantic, abort}`).
- `EventBusLike` emissions: `storage-uri.fallback.attempt-failed`, `.attempt-succeeded`, `.write-mirror-partial`, `.cycle-detected`, `.depth-exceeded`.
- ADDED: `runUriPluginConformance` pass (inherited contract from `@suaveplan/storage-uri/conformance`).
- ADDED: **Fallback-specific conformance harness** — `runFallbackConformance(resolver, fixtures)` that asserts:
- With N candidates where the first K fail and the (K+1)th succeeds, the result is the (K+1)th value and K events are emitted on the bus.
- All-candidates-fail throws `ALL_CANDIDATES_FAILED` with `candidateErrors.length === N`.
- Pre-aborted signal short-circuits with `ABORTED`.
- Cycle yields `CIRCULAR_REFERENCE`.
- Depth cap yields `MAX_DEPTH_EXCEEDED`.
## Out of scope
- **Authentication / signing across candidates.** Each candidate URI's scheme owns its own credentials; the plugin does not propagate auth state between candidates.
- **Content verification across candidates.** If `cdn://...` and `drive://...` disagree on byte content, the plugin does NOT detect or reconcile that — first-success-wins. Content parity is a manifest-layer concern (use a content-addressed scheme like `chunks://<hash>`).
- **Per-candidate retry tuning.** The walk is one attempt per candidate. Inner retry (backoff per candidate) is composed by wrapping each candidate's underlying plugin in `@suaveplan/retry`; the fallback walk is orthogonal.
- **Adapter-layer multi-backend.** That is `@suaveplan/storage-mux`'s remit — see `design.md` §6.
## Source mapping
Genesis-original; no upstream in `../suaveplan/`. References:
- `@suaveplan/storage-uri` — `UriPlugin`, `StorageUriResolver`, `runUriPluginConformance`, error-wrap semantics.
- `@suaveplan/storage-mux` proposal (`openspec/changes/complete-storage-mux/`) — error classification table, fan-out write semantics, span hierarchy.
- `@suaveplan/retry` — `RetryStrategy` (not consumed directly; consumers wrap candidates).
- RFC 3986 §3.4 (query component) for the URI grammar choice.
## Verification approach
Every requirement in `specs/storage-uri-plugin-fallback/spec.md` carries a `**Verified by:**` line naming planned test files. Coverage gate: 100/100/100/100. The conformance harness runs `runUriPluginConformance` against the fallback plugin (registered alongside an in-memory plugin in test fixtures), plus the fallback-specific `runFallbackConformance` exercising sequential walk, parallel race, cycle detection, depth cap, write-mirror fan-out, and abort short-circuit.
## Migration
Not applicable — net-new package.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/storage-uri-plugin-fallback` to enumerate. The package is new — no current dependents.
- No current dependents (verified by `bunx turbo ls --filter=...@suaveplan/storage-uri-plugin-fallback` returning the package itself).
- Forward-declared consumers in §"≥2-consumer justification" each land their own OpenSpec change to wire the plugin in.
### Affected upstreams
- `@suaveplan/storage-uri` — consumed via `StorageUriResolver`, `UriPlugin`, `runUriPluginConformance`. No augmentation required. [x] no action needed.
- `@suaveplan/error` — `defineCodes`, `SuaveplanError`. [x] no action needed.
- `@suaveplan/events` — `EventBusLike` consumer only. [x] no action needed.
- `@suaveplan/types` — `Clock`, `LoggerLike`, `StorageLike` (only the read/write/list shape). [x] no action needed.
- `@suaveplan/storage` — devDep for in-memory test backends only. [x] no action needed.