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:
- Audio-IR loader (parallel session) — primary
bundle://ir-pack-v3/halls/concert.wavwith fallback tohttps://cdn.example.com/irs/halls/concert.wav. The loader becomes scheme-agnostic — it no longer needs manifest-level multi-source logic. - Drive + CDN pairing — read from
cdn://zone-eu/avatars/alice.jpg; if the CDN returns NOT_FOUND or a transient 5xx, fall back todrive://primary/avatars/alice.jpg(the authoritative origin). Same primitive used bystorage-plugin-cachefor the URI tier. - Generic CDN multi-origin failover at the URI tier —
cdn-bunnycdn://…first,cdn-rackspace://…second.storage-muxalready 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). - Test fixtures — primary points at a local
file:///tmp/fixtures/..., fallback at a network fixture URL, second fallback at an inlinedata:URI. Lets test setups degrade gracefully when fixture servers are unavailable. - 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. - Cache warm-from-cold —
storage-plugin-cachereads fromcache://local/<key>; on miss, fall back to the cold-storage URI. Mirrors the same composition story asstorage-mux'sshieldmode 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-fallbackpackage (universal tier, zero browser/Node API dependencies). - ADDED:
fallbackUriPlugin(options): UriPluginfactory taking{ resolver: StorageUriResolver, policy?: FallbackPolicy, logger?: LoggerLike, events?: EventBusLike, clock?: Clock }and returning aUriPluginbound to thefallbackscheme. - 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 indesign.md§1. - ADDED:
parseFallbackUri(uri)helper exporting the canonical candidate list withINVALID_URI/NO_CANDIDATESrejection. - ADDED:
FallbackPolicydiscriminated 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
StorageUriResolverreference; each candidate URI is resolved through that resolver so nestedfallback://is supported (depth-tracked via anAsyncLocalStorage-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 atpolicy.maxDepth(default 4, hard ceiling 8); exceeding throwsFALLBACK_URI.MAX_DEPTH_EXCEEDED. - ADDED: Full
StorageLikeadapter 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: truecuts the walk immediately on caller abort;falselets the current candidate complete. - ADDED: All-failed reporting — when every candidate errors, throws
FALLBACK_URI.ALL_CANDIDATES_FAILEDwithcandidateErrors: readonly { uri: string; error: SuaveplanError }[]preserved on the structured error options and cross-linked viacauseto the last failure. - ADDED: Write-mirror semantics — write fan-out uses
Promise.allSettled; underwriteMode: "all"any rejection throwsFALLBACK_URI.WRITE_MIRROR_FAILEDwith 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_CODESviadefineCodes("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>withuri,candidateIndex,candidateUri,candidateErrorsdiscriminants. - 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 spanstorage-uri.fallback.attemptper candidate (attrsfallback.candidate.index,fallback.candidate.scheme,fallback.candidate.outcome ∈ {ok, transient, semantic, abort}). EventBusLikeemissions:storage-uri.fallback.attempt-failed,.attempt-succeeded,.write-mirror-partial,.cycle-detected,.depth-exceeded.
- OTel counter
- ADDED:
runUriPluginConformancepass (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_FAILEDwithcandidateErrors.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://...anddrive://...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 likechunks://<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 — seedesign.md§6.
Source mapping
Genesis-original; no upstream in ../suaveplan/. References:
@suaveplan/storage-uri—UriPlugin,StorageUriResolver,runUriPluginConformance, error-wrap semantics.@suaveplan/storage-muxproposal (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-fallbackreturning 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 viaStorageUriResolver,UriPlugin,runUriPluginConformance. No augmentation required. [x] no action needed.@suaveplan/error—defineCodes,SuaveplanError. [x] no action needed.@suaveplan/events—EventBusLikeconsumer 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.