24 KiB
Capability Spec: storage-uri-plugin-fallback
Package: @suaveplan/storage-uri-plugin-fallback
Every requirement carries a
**Verified by:**line.
Purpose
The storage-uri-plugin-fallback capability defines the fallback://
URI scheme: an ordered, recursion-safe multi-source resolution layer
that lets any @suaveplan/storage-uri consumer try several candidate
URIs in sequence (or in parallel) and return the first that resolves.
The plugin reuses the resolver it is registered into for per-candidate
dispatch, with cycle detection and a depth cap preventing self-reference
DoS.
Requirements
Requirement: URI grammar — query-param form with ordered ?u= candidates
The package SHALL define the fallback:// URI grammar as an empty authority and empty path followed by a query string containing one or more ordered u=<pct-encoded-uri> parameters. The exported parseFallbackUri(uri) helper SHALL return { candidates: readonly string[]; canonicalUri: string } where candidate order matches the input parameter order, and the exported buildFallbackUri(uris) helper SHALL produce a canonical fallback://?u=…&u=… string using URLSearchParams. Inputs missing the fallback: scheme, carrying a non-empty authority, longer than 4096 characters total, or containing fewer than one u= parameter SHALL be rejected with FALLBACK_URI.NO_CANDIDATES (zero candidates) or FALLBACK_URI.INVALID_URI (any other malformed input). Candidate count above policy.maxCandidates (default 16, hard ceiling 64) SHALL be rejected with FALLBACK_URI.INVALID_URI.
Scenario: well-formed two-candidate URI parses preserving order
- WHEN
parseFallbackUri("fallback://?u=https%3A%2F%2Fcdn.example.com%2Ffoo&u=drive%3A%2F%2Fprimary%2Ffoo")is called - THEN the result is
{ candidates: ["https://cdn.example.com/foo", "drive://primary/foo"], canonicalUri: "fallback://?u=https%3A%2F%2Fcdn.example.com%2Ffoo&u=drive%3A%2F%2Fprimary%2Ffoo" }
Scenario: zero candidates throws NO_CANDIDATES
- WHEN
parseFallbackUri("fallback://")orparseFallbackUri("fallback://?")is called - THEN the call throws
FallbackUriErrorwithcode === "FALLBACK_URI.NO_CANDIDATES"
Scenario: non-empty authority throws INVALID_URI
- WHEN
parseFallbackUri("fallback://host/?u=https%3A%2F%2Fx")is called - THEN the call throws
FallbackUriErrorwithcode === "FALLBACK_URI.INVALID_URI"
Scenario: build round-trips through parse
- WHEN
parseFallbackUri(buildFallbackUri(["https://a/x", "drive://b/y"]))is called for any non-empty list - THEN the returned
candidatesarray deep-equals the input
Verified by: src/parse-uri/parse-fallback-uri.test.ts::"parses two-candidate URI preserving order" AND src/parse-uri/parse-fallback-uri.test.ts::"throws NO_CANDIDATES for empty candidate list" AND src/parse-uri/parse-fallback-uri.test.ts::"throws INVALID_URI for non-empty authority" AND src/parse-uri/parse-fallback-uri.test.ts::"build round-trips through parse"
Requirement: Plugin factory binds to fallback scheme and exposes full UriPlugin surface
The package SHALL export fallbackUriPlugin(options: FallbackPluginOptions): UriPlugin where options.resolver: StorageUriResolver is required and is the same resolver the returned plugin will be registered into. The returned plugin's scheme SHALL be "fallback", its parse SHALL delegate to parseFallbackUri, its build SHALL delegate to buildFallbackUri, and its adapter SHALL satisfy the full streaming-extended StorageLike surface (get, set, setPartial, has, exists, delete, list, getMetadata, createReadStream, createWriteStream). The plugin SHALL satisfy runUriPluginConformance against the storage-uri conformance fixtures.
Scenario: constructed plugin advertises fallback scheme
- WHEN
fallbackUriPlugin({ resolver })is called against any non-undefined resolver - THEN the returned plugin has
scheme === "fallback"andtypeof plugin.parse === "function"andtypeof plugin.build === "function"
Scenario: missing resolver throws on construction
- WHEN
fallbackUriPlugin({ resolver: undefined as never })is called - THEN the call throws an error (Zod validation failure) before returning a plugin
Scenario: storage-uri conformance harness passes
- WHEN
runUriPluginConformance(plugin, { validUris: [buildFallbackUri(["https://a/x"])], invalidUris: ["fallback://"], buildRoundTrip: [{ key: "<encoded>", uri: buildFallbackUri(["https://a/x"]) }] })is called - THEN the harness returns without throwing
Verified by: src/plugin/plugin.test.ts::"advertises the fallback scheme" AND src/plugin/plugin.test.ts::"throws when resolver option is missing" AND src/plugin/plugin.spec-conformance.test.ts::"passes runUriPluginConformance"
Requirement: Sequential read walk returns first successful candidate
When policy.maxParallel === 1 (default), the plugin SHALL attempt each candidate URI in declared order by dispatching it through the bound StorageUriResolver. The first candidate that resolves SHALL produce the returned value. Each transient failure SHALL emit storage-uri.fallback.attempt-failed on the configured EventBusLike (if provided) with { uri, candidateIndex, candidateUri, error, code }, increment storage_uri.fallback_total{candidate_index, scheme, outcome:"transient"}, and continue to the next candidate. Semantic failures classified per the package's classification table SHALL surface immediately without walking the remaining candidates.
Scenario: first candidate succeeds, second is not attempted
- WHEN
read(buildFallbackUri(["mem://a", "mem://b"]))is called and themem://acandidate returns bytes - THEN the returned bytes equal the
mem://aadapter's stored value AND themem://bcandidate is never dispatched (verified via spy on the second plugin'sparse)
Scenario: first candidate transient-fails, second succeeds
- WHEN
read(buildFallbackUri(["mem://missing", "mem://b"]))is called against a resolver wheremem://missingraisesSTORAGE_URI.NOT_FOUNDandmem://breturns bytes - THEN the returned bytes equal the
mem://badapter's stored value ANDEventBusLike.emitis invoked exactly once with topic"storage-uri.fallback.attempt-failed"carryingcandidateIndex === 0
Scenario: caller-bug semantic error surfaces without fall-through
- WHEN
read(buildFallbackUri(["xyz://nope", "mem://b"]))is called andxyzis unregistered, so the resolver throwsSTORAGE_URI.UNKNOWN_SCHEME - THEN the call rejects with
STORAGE_URI.UNKNOWN_SCHEME(NOTFALLBACK_URI.ALL_CANDIDATES_FAILED) ANDmem://bis never dispatched
Verified by: src/walk/sequential.test.ts::"returns first candidate success without dispatching subsequent candidates" AND src/walk/sequential.test.ts::"falls through on transient NOT_FOUND and emits attempt-failed event" AND src/walk/sequential.test.ts::"surfaces UNKNOWN_SCHEME from caller-bug candidate without fall-through"
Requirement: Parallel read race returns first-to-resolve
When policy.maxParallel > 1, the plugin SHALL dispatch up to maxParallel candidates concurrently via Promise.any-style race, return the first to resolve, and abort the pending candidates via an AbortSignal.any-composed signal so they may release resources. Candidate failures that arrive before the first success SHALL be collected; if every candidate fails, the plugin SHALL throw FALLBACK_URI.ALL_CANDIDATES_FAILED with candidateErrors.length === N.
Scenario: parallel race returns the fastest candidate
- WHEN
read(buildFallbackUri(["slow://a", "fast://b"]))is called against a resolver whereslow://adelays 50ms andfast://bresolves immediately, withpolicy.maxParallel: 2 - THEN the call resolves with the
fast://bbytes AND the pendingslow://acandidate sees its abort signal
Verified by: src/walk/parallel.test.ts::"resolves with the fastest candidate and aborts pending"
Requirement: Exhausted walk throws ALL_CANDIDATES_FAILED with per-candidate cause chain
When every candidate fails with a fall-through-eligible error, the plugin SHALL throw FALLBACK_URI.ALL_CANDIDATES_FAILED with candidateErrors: readonly { uri, error: SuaveplanError }[] containing one entry per candidate (in attempt order) and cause set to the final candidate's error. The error SHALL include the original fallback:// URI on its uri field. SuaveplanError subclasses thrown by candidates SHALL be preserved verbatim inside candidateErrors[i].error (no re-wrapping).
Scenario: every candidate fails — error carries all causes
- WHEN
read(buildFallbackUri(["mem://x", "mem://y", "mem://z"]))is called against a resolver where every candidate raisesSTORAGE_URI.NOT_FOUND - THEN the call rejects with
FallbackUriErrorhavingcode === "FALLBACK_URI.ALL_CANDIDATES_FAILED",candidateErrors.length === 3,candidateErrors[0].uri === "mem://x", everycandidateErrors[i].error.code === "STORAGE_URI.NOT_FOUND", andcause === candidateErrors[2].error
Verified by: src/walk/sequential.test.ts::"throws ALL_CANDIDATES_FAILED carrying every per-candidate error"
Requirement: Write fan-out mirrors to all candidates by default
When policy.writeMode === "all" (default), set and createWriteStream SHALL dispatch the write to every candidate URI via Promise.allSettled. If every write resolves, the operation SHALL resolve. If any write rejects, the plugin SHALL throw FALLBACK_URI.WRITE_MIRROR_FAILED with mirrorOutcomes: readonly { uri, status: "fulfilled" | "rejected", error?: SuaveplanError }[] describing each candidate's outcome. When policy.writeMode === "first-success", the plugin SHALL walk candidates sequentially and return on the first success, allowing remaining writes to settle in the background.
Scenario: write to two candidates fans out to both
- WHEN
write(buildFallbackUri(["mem://a", "mem://b"]), bytes)is called - THEN both
mem://aandmem://badapters receive the exact bytes (verified via direct reads after) AND the operation resolves successfully
Scenario: partial mirror failure throws WRITE_MIRROR_FAILED
- WHEN
write(buildFallbackUri(["mem://a", "ro-mem://b"]), bytes)is called against a resolver wherero-mem://bis read-only and raisesSTORAGE_URI.WRITE_UNSUPPORTED - THEN the call rejects with
FallbackUriErrorhavingcode === "FALLBACK_URI.WRITE_MIRROR_FAILED",mirrorOutcomes[0].status === "fulfilled",mirrorOutcomes[1].status === "rejected",mirrorOutcomes[1].error.code === "STORAGE_URI.WRITE_UNSUPPORTED"
Scenario: first-success mode returns on first ok and backgrounds remainder
- WHEN
write(buildFallbackUri(["mem://a", "mem://b"]), bytes)is called withpolicy.writeMode: "first-success" - THEN the call resolves as soon as
mem://acompletes;mem://bstill receives the write asynchronously (verified by a follow-up read after a microtask flush)
Verified by: src/ops/write.test.ts::"mirrors write to every candidate by default" AND src/ops/write.test.ts::"throws WRITE_MIRROR_FAILED with per-candidate outcomes" AND src/ops/write.test.ts::"first-success mode returns early and backgrounds remainder"
Requirement: List operation returns deduplicated union of candidate results
list({ prefix, limit }) SHALL invoke list on every candidate URI and return the union of their key sets, deduplicated by canonical URI string in stable first-occurrence order. hasMore SHALL be true if any candidate reports hasMore: true. continuationToken SHALL be undefined because per-adapter tokens cannot be merged. Under listMode: "concat" the plugin SHALL return the raw concatenation without dedup. Under listFailureMode: "throw" (default), any candidate list failure SHALL cause the call to throw FALLBACK_URI.LIST_UNION_FAILED with candidateErrors; under "partial", failures SHALL emit a warning event and be excluded from the merged result.
Scenario: union list dedupes overlapping URIs
- WHEN
list(buildFallbackUri(["mem://a/", "mem://b/"]))is called wheremem://a/returns["mem://a/x", "mem://a/y"]andmem://b/returns["mem://b/x"], and an alias makes the second candidate also yield"mem://a/x" - THEN the returned
uriscontain["mem://a/x", "mem://a/y", "mem://b/x"]with"mem://a/x"appearing exactly once
Scenario: candidate list failure under throw mode raises LIST_UNION_FAILED
- WHEN
list(buildFallbackUri(["mem://a/", "fail-mem://b/"]))is called and the second candidate raises an adapter error - THEN the call rejects with
FallbackUriErrorhavingcode === "FALLBACK_URI.LIST_UNION_FAILED"andcandidateErrors.length === 1
Verified by: src/ops/list.test.ts::"returns deduplicated union of candidate keys in first-occurrence order" AND src/ops/list.test.ts::"throws LIST_UNION_FAILED on per-candidate failure under throw mode"
Requirement: Recursive fallback:// is supported with depth cap and cycle detection
The plugin SHALL accept candidates that are themselves fallback:// URIs by recursively dispatching through the bound resolver. Recursion depth SHALL be tracked via a DepthContext value threaded through call options; exceeding policy.maxDepth (default 4, hard ceiling 8) SHALL throw FALLBACK_URI.MAX_DEPTH_EXCEEDED. Every recursion frame SHALL append the canonicalised fallback:// URI to a visited: Set<string>; when every candidate has been visited the plugin SHALL throw FALLBACK_URI.CIRCULAR_REFERENCE with cycleChain: readonly string[] populated.
Scenario: two-level nested fallback resolves correctly
- WHEN
read(buildFallbackUri(["fallback://?u=mem%3A%2F%2Fa", "mem://b"]))is called against a resolver wheremem://asucceeds - THEN the call resolves with the
mem://abytes AND depth never exceeds 2
Scenario: depth cap exceeded throws MAX_DEPTH_EXCEEDED
- WHEN the resolver dispatches a
fallback://whose only candidate is itself afallback://chain longer thanpolicy.maxDepth - THEN the call rejects with
FallbackUriErrorhavingcode === "FALLBACK_URI.MAX_DEPTH_EXCEEDED"anddepth === policy.maxDepth + 1
Scenario: cycle yields CIRCULAR_REFERENCE
- WHEN
read(uri)is called whereuri = buildFallbackUri([uri])(self-reference, hand-crafted via the visited-set canonicalisation key) - THEN the call rejects with
FallbackUriErrorhavingcode === "FALLBACK_URI.CIRCULAR_REFERENCE"andcycleChain[0]equal to the canonicalised input URI
Verified by: src/recursion/depth-context.test.ts::"two-level nested fallback resolves correctly" AND src/recursion/depth-context.test.ts::"throws MAX_DEPTH_EXCEEDED past maxDepth" AND src/walk/sequential.test.ts::"throws CIRCULAR_REFERENCE on self-reference"
Requirement: AbortSignal propagation and stopOnAbort policy
Every public operation on the plugin's adapter SHALL accept an AbortSignal and SHALL forward an AbortSignal.any-composed signal (caller + per-candidate timeout, if any) to each candidate dispatch. When policy.stopOnAbort === true (default), an aborted caller signal SHALL cut the walk immediately; the plugin SHALL throw FALLBACK_URI.ABORTED without dispatching further candidates. When policy.stopOnAbort === false, the plugin SHALL let the in-flight candidate complete and SHALL then throw FALLBACK_URI.ABORTED. A pre-aborted signal SHALL short-circuit before dispatching the first candidate.
Scenario: pre-aborted signal short-circuits before any dispatch
- WHEN
read(buildFallbackUri(["mem://a"]), AbortSignal.abort())is called - THEN the call rejects with
FallbackUriErrorhavingcode === "FALLBACK_URI.ABORTED"AND themem://acandidate is never dispatched
Scenario: mid-walk abort under stopOnAbort=true cuts immediately
- WHEN
read(buildFallbackUri(["slow-mem://a", "mem://b"]), signal)is called and the caller firessignal.abort()after the first candidate is dispatched but before it completes - THEN the call rejects with
FALLBACK_URI.ABORTEDANDmem://bis never dispatched
Verified by: src/plugin/plugin.test.ts::"short-circuits on pre-aborted signal" AND src/plugin/plugin.test.ts::"cuts walk immediately on mid-walk abort under stopOnAbort=true"
Requirement: Error classification governs fall-through eligibility
The plugin SHALL classify every error raised by a candidate dispatch as "transient", "semantic", or "abort" per the table in design.md §7. "transient" errors SHALL trigger fall-through to the next candidate. "semantic" errors SHALL surface immediately without walking remaining candidates, except that STORAGE_URI.NOT_FOUND SHALL be treated as fall-through-eligible by default. "abort" errors SHALL terminate the walk per the AbortSignal requirement. Setting policy.errorClass: "any" SHALL re-classify every semantic error (except MAX_DEPTH_EXCEEDED and CIRCULAR_REFERENCE) as fall-through-eligible.
Scenario: NOT_FOUND from first candidate falls through
- WHEN
read(buildFallbackUri(["mem://missing", "mem://present"]))is called where the first raisesNOT_FOUNDand the second succeeds - THEN the call resolves with the second candidate's bytes
Scenario: UNKNOWN_SCHEME surfaces immediately under default policy
- WHEN
read(buildFallbackUri(["xyz://unregistered", "mem://present"]))is called with default policy - THEN the call rejects with
STORAGE_URI.UNKNOWN_SCHEME(the namespaced error from the resolver passes through unchanged)
Scenario: errorClass=any treats UNKNOWN_SCHEME as fall-through
- WHEN the same call is made with
policy.errorClass: "any" - THEN the call resolves with the second candidate's bytes AND an
attempt-failedevent recordsUNKNOWN_SCHEMEforcandidateIndex === 0
Verified by: src/classify/classify.test.ts::"classifies NOT_FOUND as fall-through-eligible" AND src/walk/sequential.test.ts::"surfaces UNKNOWN_SCHEME under default policy" AND src/walk/sequential.test.ts::"falls through UNKNOWN_SCHEME under errorClass=any"
Requirement: OpenTelemetry instrumentation per candidate
The plugin SHALL emit a parent span storage-uri.fallback.<op> for every public operation and a child span storage-uri.fallback.attempt per candidate attempt with attributes fallback.candidate.index, fallback.candidate.scheme, fallback.candidate.outcome ∈ {ok, transient, semantic, abort}. The plugin SHALL increment the counter storage_uri.fallback_total{candidate_index, scheme, outcome} per candidate attempt and SHALL record the histogram storage_uri.fallback_walk_ms{outcome} per top-level operation.
Scenario: successful walk records counter for both candidates plus histogram
- WHEN
read(buildFallbackUri(["mem://miss", "mem://hit"]))is called against fixture adapters where the first misses and the second hits - THEN
storage_uri.fallback_total{candidate_index:0, scheme:"mem", outcome:"transient"}is incremented by 1 ANDstorage_uri.fallback_total{candidate_index:1, scheme:"mem", outcome:"ok"}is incremented by 1 ANDstorage_uri.fallback_walk_ms{outcome:"ok"}is recorded once
Scenario: parent span carries policy attributes
- WHEN any public operation is invoked with
policy.maxParallel: 2 - THEN the parent span carries attribute
fallback.policy.max_parallel === 2
Verified by: src/observability/otel.test.ts::"increments fallback_total per candidate and records walk_ms" AND src/observability/otel.test.ts::"records policy attributes on parent span"
Requirement: EventBusLike emissions for observability hooks
When options.events: EventBusLike is provided, the plugin SHALL emit events on the topics storage-uri.fallback.attempt-failed, storage-uri.fallback.attempt-succeeded, storage-uri.fallback.write-mirror-partial, storage-uri.fallback.cycle-detected, and storage-uri.fallback.depth-exceeded. Each event payload SHALL include uri (the original fallback:// URI), candidateIndex (where applicable), candidateUri, and a code or cause discriminant per topic. When no events are provided the plugin SHALL be a no-op on the bus path.
Scenario: attempt-failed event payload contains candidate metadata
- WHEN
read(buildFallbackUri(["mem://miss", "mem://hit"]))is called with anEventBusLikeprovided and the first candidate raisesNOT_FOUND - THEN
events.emitis invoked with topic"storage-uri.fallback.attempt-failed"and payload containingcandidateIndex: 0,candidateUri: "mem://miss",code: "STORAGE_URI.NOT_FOUND"
Scenario: omitted events option is a no-op
- WHEN the plugin is constructed without
options.eventsand a candidate fails - THEN no error is raised by the missing-bus path AND the operation otherwise behaves identically
Verified by: src/observability/events.test.ts::"emits attempt-failed with candidate metadata" AND src/observability/events.test.ts::"is a no-op when no EventBusLike is provided"
Requirement: Fallback-specific conformance harness
The package SHALL export runFallbackConformance(resolver, fixtures) that consumers call from their own tests to verify their wiring exercises the fallback contract end-to-end. The harness SHALL assert that:
- With N candidates where the first K fail with fall-through-eligible errors and the (K+1)th succeeds, the result equals the (K+1)th candidate's value and K events are emitted on the provided bus.
- All-candidates-fail throws
FALLBACK_URI.ALL_CANDIDATES_FAILEDwithcandidateErrors.length === N. - A pre-aborted signal short-circuits with
FALLBACK_URI.ABORTEDand zero dispatches occur. - A self-referencing
fallback://URI yieldsFALLBACK_URI.CIRCULAR_REFERENCE. - A chain longer than
policy.maxDepthyieldsFALLBACK_URI.MAX_DEPTH_EXCEEDED. - Mirror writes under
writeMode: "all"hit every candidate (verified via direct reads).
Scenario: harness passes against the in-package plugin
- WHEN
runFallbackConformance(resolver, fixtures)is invoked with a resolver carrying the fallback plugin plus twoMemoryStorage-backed plugins - THEN every assertion passes without throwing
Scenario: harness fails when the resolver omits the fallback plugin
- WHEN
runFallbackConformance(resolver, fixtures)is invoked against a resolver missing the fallback plugin - THEN the harness throws with a message naming the missing
fallbackscheme
Verified by: src/conformance/fallback-conformance.test.ts::"passes against the in-package plugin" AND src/conformance/fallback-conformance.test.ts::"fails when the resolver omits the fallback plugin"
Requirement: Tier purity — universal, no Node/Bun/DOM dependencies
The package SHALL be universal-tier. The implementation SHALL NOT import from node:*, bun:*, or any DOM-only API. The grammar parser SHALL use the Web-standard URL and URLSearchParams only. AbortSignal.any SHALL be feature-detected with a runtime polyfill fallback for environments lacking ES2024 support. Time-keeping SHALL go through the injected Clock from @suaveplan/types. The package's package.json SHALL declare no node:*-only peer dependencies and SHALL maintain "sideEffects": false.
Scenario: source tree contains no Node or Bun specifier imports
- WHEN the verification command
grep -rnE "from ['\"](node:|bun:)" packages/storage/storage-uri-plugin-fallback/src/is executed - THEN the command produces no matches
Scenario: AbortSignal.any shim activates on runtimes without native support
- WHEN the package is loaded under a runtime where
AbortSignal.anyis undefined - THEN the exported shim provides equivalent composition behaviour validated by the unit test suite
Verified by: src/parse-uri/parse-fallback-uri.test.ts::"abort-any shim composes signals identically to native" AND src/plugin/plugin.test.ts::"tier-purity grep produces no matches"