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

24 KiB

Capability Spec Delta: storage-uri-plugin-fallback (storage-uri-plugin-fallback)

Package: @suaveplan/storage-uri-plugin-fallback Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. No exceptions.

ADDED 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://") or parseFallbackUri("fallback://?") is called
  • THEN the call throws FallbackUriError with code === "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 FallbackUriError with code === "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 candidates array 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" and typeof plugin.parse === "function" and typeof 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 the mem://a candidate returns bytes
  • THEN the returned bytes equal the mem://a adapter's stored value AND the mem://b candidate is never dispatched (verified via spy on the second plugin's parse)

Scenario: first candidate transient-fails, second succeeds

  • WHEN read(buildFallbackUri(["mem://missing", "mem://b"])) is called against a resolver where mem://missing raises STORAGE_URI.NOT_FOUND and mem://b returns bytes
  • THEN the returned bytes equal the mem://b adapter's stored value AND EventBusLike.emit is invoked exactly once with topic "storage-uri.fallback.attempt-failed" carrying candidateIndex === 0

Scenario: caller-bug semantic error surfaces without fall-through

  • WHEN read(buildFallbackUri(["xyz://nope", "mem://b"])) is called and xyz is unregistered, so the resolver throws STORAGE_URI.UNKNOWN_SCHEME
  • THEN the call rejects with STORAGE_URI.UNKNOWN_SCHEME (NOT FALLBACK_URI.ALL_CANDIDATES_FAILED) AND mem://b is 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 where slow://a delays 50ms and fast://b resolves immediately, with policy.maxParallel: 2
  • THEN the call resolves with the fast://b bytes AND the pending slow://a candidate 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 raises STORAGE_URI.NOT_FOUND
  • THEN the call rejects with FallbackUriError having code === "FALLBACK_URI.ALL_CANDIDATES_FAILED", candidateErrors.length === 3, candidateErrors[0].uri === "mem://x", every candidateErrors[i].error.code === "STORAGE_URI.NOT_FOUND", and cause === 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://a and mem://b adapters 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 where ro-mem://b is read-only and raises STORAGE_URI.WRITE_UNSUPPORTED
  • THEN the call rejects with FallbackUriError having code === "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 with policy.writeMode: "first-success"
  • THEN the call resolves as soon as mem://a completes; mem://b still 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 where mem://a/ returns ["mem://a/x", "mem://a/y"] and mem://b/ returns ["mem://b/x"], and an alias makes the second candidate also yield "mem://a/x"
  • THEN the returned uris contain ["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 FallbackUriError having code === "FALLBACK_URI.LIST_UNION_FAILED" and candidateErrors.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 where mem://a succeeds
  • THEN the call resolves with the mem://a bytes AND depth never exceeds 2

Scenario: depth cap exceeded throws MAX_DEPTH_EXCEEDED

  • WHEN the resolver dispatches a fallback:// whose only candidate is itself a fallback:// chain longer than policy.maxDepth
  • THEN the call rejects with FallbackUriError having code === "FALLBACK_URI.MAX_DEPTH_EXCEEDED" and depth === policy.maxDepth + 1

Scenario: cycle yields CIRCULAR_REFERENCE

  • WHEN read(uri) is called where uri = buildFallbackUri([uri]) (self-reference, hand-crafted via the visited-set canonicalisation key)
  • THEN the call rejects with FallbackUriError having code === "FALLBACK_URI.CIRCULAR_REFERENCE" and cycleChain[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 FallbackUriError having code === "FALLBACK_URI.ABORTED" AND the mem://a candidate 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 fires signal.abort() after the first candidate is dispatched but before it completes
  • THEN the call rejects with FALLBACK_URI.ABORTED AND mem://b is 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 raises NOT_FOUND and 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-failed event records UNKNOWN_SCHEME for candidateIndex === 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 AND storage_uri.fallback_total{candidate_index:1, scheme:"mem", outcome:"ok"} is incremented by 1 AND storage_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 an EventBusLike provided and the first candidate raises NOT_FOUND
  • THEN events.emit is invoked with topic "storage-uri.fallback.attempt-failed" and payload containing candidateIndex: 0, candidateUri: "mem://miss", code: "STORAGE_URI.NOT_FOUND"

Scenario: omitted events option is a no-op

  • WHEN the plugin is constructed without options.events and 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_FAILED with candidateErrors.length === N.
  • A pre-aborted signal short-circuits with FALLBACK_URI.ABORTED and zero dispatches occur.
  • A self-referencing fallback:// URI yields FALLBACK_URI.CIRCULAR_REFERENCE.
  • A chain longer than policy.maxDepth yields FALLBACK_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 two MemoryStorage-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 fallback scheme

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.any is 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"