Files
2026-05-27 23:29:53 +01:00

11 KiB

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.