# Capability Spec: storage-uri **Package**: `@suaveplan/storage-uri` > Every requirement carries a `**Verified by:**` line. ## Requirements ### Requirement: URI scheme dispatch The resolver SHALL select a registered `UriPlugin` by the scheme component of the input URI and SHALL throw `STORAGE_URI.UNKNOWN_SCHEME` when no plugin is registered for that scheme. The lookup SHALL be case-insensitive on the scheme per RFC 3986 §3.1. #### Scenario: registered scheme dispatches to its plugin - **WHEN** `read("s3://bucket/key")` is called against a resolver with an `s3` plugin - **THEN** the `s3` plugin's `parse` is invoked exactly once and the resulting `adapter.get(key)` call returns the stored bytes #### Scenario: unregistered scheme throws UNKNOWN_SCHEME - **WHEN** `read("ftp://host/path")` is called against a resolver with no `ftp` plugin - **THEN** the call rejects with `StorageUriError` having `code === "STORAGE_URI.UNKNOWN_SCHEME"` and `scheme === "ftp"` #### Scenario: scheme matching is case-insensitive - **WHEN** `read("S3://bucket/key")` is called against a resolver registered with the lowercase `s3` scheme - **THEN** dispatch succeeds without throwing `UNKNOWN_SCHEME` **Verified by:** `src/resolver/resolver.test.ts::"dispatches by scheme to the registered plugin"` AND `src/resolver/resolver.test.ts::"throws UNKNOWN_SCHEME for unregistered scheme"` AND `src/resolver/resolver.test.ts::"matches scheme case-insensitively"` --- ### Requirement: Plugin registration is immutable and conflict-detected The `createStorageUriResolver(plugins)` factory SHALL freeze the registry after construction and SHALL throw `STORAGE_URI.SCHEME_CONFLICT` when two distinct plugin references claim the same scheme. Registering the same plugin reference twice for the same scheme SHALL be a no-op. #### Scenario: duplicate scheme with distinct plugins is rejected - **WHEN** `createStorageUriResolver([s3PluginA, s3PluginB])` is called where both declare `scheme: "s3"` and `s3PluginA !== s3PluginB` - **THEN** the call throws `StorageUriError` with `code === "STORAGE_URI.SCHEME_CONFLICT"` and `scheme === "s3"` #### Scenario: identical plugin reference registered twice is accepted - **WHEN** `createStorageUriResolver([fileUriPlugin, fileUriPlugin])` is called with the same reference twice - **THEN** the resolver is constructed without throwing and dispatch behaves identically to single registration **Verified by:** `src/resolver/resolver.test.ts::"rejects conflicting scheme registrations"` AND `src/resolver/resolver.test.ts::"accepts idempotent reference re-registration"` --- ### Requirement: Streaming reads with buffered fallback The `open(uri, signal?)` method SHALL return a `ReadableStream` of the resource's bytes. When the underlying adapter exposes `createReadStream`, the resolver SHALL forward it. When the adapter exposes only `get`, the resolver SHALL wrap the buffered bytes in a one-chunk `ReadableStream` and SHALL increment the `storage_uri.stream_fallback_total{scheme}` counter. #### Scenario: adapter with createReadStream is forwarded - **WHEN** `open("https://host/path")` is called against a plugin whose adapter implements `createReadStream` - **THEN** the returned stream is the same reference returned by `adapter.createReadStream`, and `stream_fallback_total` is NOT incremented #### Scenario: adapter without createReadStream falls back to single-chunk stream - **WHEN** `open("memory://x/y")` is called against a plugin whose adapter implements only `get` - **THEN** the returned stream emits exactly one chunk equal to `adapter.get` result, then closes; `stream_fallback_total{scheme:"memory"}` is incremented by 1 #### Scenario: open of a missing resource throws NOT_FOUND - **WHEN** `open("file:///does/not/exist")` is called and `adapter.get` returns `undefined` - **THEN** the call rejects with `StorageUriError` having `code === "STORAGE_URI.NOT_FOUND"` **Verified by:** `src/resolver/resolver.streams.test.ts::"forwards adapter.createReadStream when available"` AND `src/resolver/resolver.streams.test.ts::"falls back to one-chunk stream and increments counter"` AND `src/resolver/resolver.streams.test.ts::"throws NOT_FOUND when adapter returns undefined"` --- ### Requirement: Streaming writes with buffered fallback The `write(uri, body, signal?)` method SHALL accept `Uint8Array` or `ReadableStream` for `body`. When the underlying adapter exposes `createWriteStream`, the resolver SHALL forward it. When the adapter exposes only `set`, the resolver SHALL drain the stream into a single `Uint8Array`, call `set` once, and SHALL increment `storage_uri.buffered_write_total{scheme}`. When `adapter.set` throws, the resolver SHALL apply the same pass-through rule as `Adapter error re-wrapping` (below): `SuaveplanError` subclasses SHALL pass through unchanged with their original code; non-`SuaveplanError` throwables SHALL be wrapped as `StorageUriError` with `code === "STORAGE_URI.WRITE_UNSUPPORTED"` and the original preserved as `cause`. #### Scenario: Uint8Array body is forwarded directly - **WHEN** `write("file:///a.bin", new Uint8Array([1,2,3]))` is called - **THEN** `adapter.set` is invoked exactly once with the exact byte array #### Scenario: stream body is drained into a single set call - **WHEN** `write("file:///a.bin", stream)` is called where `stream` yields three chunks - **THEN** `adapter.set` is invoked exactly once with the concatenated bytes; `buffered_write_total{scheme:"file"}` is incremented by 1 #### Scenario: non-Suaveplan throw on set wraps as WRITE_UNSUPPORTED - **WHEN** `write("memory://x/y", bytes)` is called against a plugin whose `adapter.set` throws `new Error("read only mount")` - **THEN** the call rejects with `StorageUriError` having `code === "STORAGE_URI.WRITE_UNSUPPORTED"` and `cause.message === "read only mount"` #### Scenario: SuaveplanError on set passes through with its original code - **WHEN** `write("drive://primary/x", bytes)` is called against a plugin whose `adapter.set` throws `new DriveUriError("WRITE_UNSUPPORTED", { uri, scheme: "drive" })` - **THEN** the rejection is that same `DriveUriError` instance, `err.code === "DRIVE_URI.WRITE_UNSUPPORTED"`, and the resolver does NOT re-wrap it as `STORAGE_URI.WRITE_UNSUPPORTED` **Verified by:** `src/resolver/resolver.writes.test.ts::"forwards Uint8Array body directly to adapter.set"` AND `src/resolver/resolver.writes.test.ts::"drains ReadableStream into single set call"` AND `src/resolver/resolver.writes.test.ts::"wraps non-Suaveplan throw on set as WRITE_UNSUPPORTED"` AND `src/resolver/resolver.writes.test.ts::"passes through thrown SuaveplanError subclasses on write"` --- ### Requirement: Delegated metadata, existence, and deletion The `stat(uri)`, `exists(uri)`, and `remove(uri)` methods SHALL delegate to the underlying adapter after URI parse. `stat` SHALL return `undefined` when the adapter cannot answer without a full read. `exists` SHALL forward `StorageLike.exists`. `remove` SHALL forward `StorageLike.delete` and SHALL return the boolean from that call. #### Scenario: exists delegates to StorageLike.exists - **WHEN** `exists("file:///a.bin")` is called and `adapter.exists("a.bin")` returns `true` - **THEN** the resolver returns `true` without invoking any other adapter method #### Scenario: remove returns the adapter's boolean - **WHEN** `remove("s3://bucket/missing")` is called and `adapter.delete` returns `false` - **THEN** the resolver returns `false`; no error is raised #### Scenario: stat returns undefined when adapter offers no metadata path - **WHEN** `stat("memory://x/y")` is called against an adapter with no `getMetadata`/`HEAD` surface - **THEN** the call resolves with `undefined` **Verified by:** `src/resolver/resolver.test.ts::"exists delegates to adapter.exists"` AND `src/resolver/resolver.test.ts::"remove returns adapter.delete boolean"` AND `src/resolver/resolver.test.ts::"stat returns undefined for adapters without metadata path"` --- ### Requirement: Prefix listing with URI re-serialisation The `list(uri, options?)` method SHALL treat the input URI's key portion as a prefix, delegate to `StorageLike.list({ prefix, limit, continuationToken })`, and re-serialise each returned key back to a URI via `plugin.build(key, meta)`. Plugins without a `build` function SHALL cause `list` to throw `STORAGE_URI.LIST_UNSUPPORTED`. #### Scenario: list re-serialises keys to URIs via build() - **WHEN** `list("s3://bucket/raw/", { limit: 10 })` is called and `adapter.list({ prefix: "raw/", limit: 10 })` returns `{ keys: ["raw/a.mov", "raw/b.mov"], hasMore: false }` - **THEN** the resolver returns `{ uris: ["s3://bucket/raw/a.mov", "s3://bucket/raw/b.mov"], hasMore: false }` #### Scenario: plugin without build() throws LIST_UNSUPPORTED - **WHEN** `list("memory://x/y/")` is called against a plugin that omits `build` - **THEN** the call rejects with `StorageUriError` having `code === "STORAGE_URI.LIST_UNSUPPORTED"` #### Scenario: continuation token is round-tripped opaquely - **WHEN** `list("s3://bucket/x/", { continuationToken: "abc" })` is called and the adapter returns `{ keys: [...], hasMore: true, continuationToken: "def" }` - **THEN** the resolver returns the same `continuationToken: "def"` unchanged **Verified by:** `src/resolver/resolver.list.test.ts::"re-serialises keys to URIs"` AND `src/resolver/resolver.list.test.ts::"throws LIST_UNSUPPORTED when plugin omits build"` AND `src/resolver/resolver.list.test.ts::"round-trips continuation token opaquely"` --- ### Requirement: URI parsing (RFC 3986 subset) The `parseUri(uri)` helper SHALL accept any string of the form `:` where `` matches `/^[A-Za-z][A-Za-z0-9+\-.]*$/`. It SHALL throw `STORAGE_URI.INVALID_URI` for inputs missing a scheme, containing embedded null bytes (` `), or longer than 4096 characters. #### Scenario: well-formed URIs parse without throwing - **WHEN** `parseUri("s3://bucket/key")`, `parseUri("file:///abs/path")`, and `parseUri("https://host/p?q=1")` are called - **THEN** all three return a parsed object with the expected scheme #### Scenario: malformed inputs throw INVALID_URI - **WHEN** `parseUri("bare-string")`, `parseUri("scheme:")`, `parseUri("s3://bucket /key")`, or `parseUri("x".repeat(4097))` is called - **THEN** each call throws `StorageUriError` with `code === "STORAGE_URI.INVALID_URI"` **Verified by:** `src/parse-uri/parse-uri.test.ts::"parses well-formed URIs"` AND `src/parse-uri/parse-uri.test.ts::"throws INVALID_URI for malformed inputs"` --- ### Requirement: AbortSignal propagation Every public method of the resolver (`open`, `read`, `write`, `stat`, `exists`, `remove`, `list`) SHALL accept an `AbortSignal` and SHALL forward it unchanged to the underlying adapter call. When the signal is already aborted at the time of the call, the resolver SHALL reject with `STORAGE_URI.ABORTED` without invoking the adapter. #### Scenario: pre-aborted signal short-circuits - **WHEN** `read("file:///a", AbortSignal.abort())` is called - **THEN** the call rejects with `code === "STORAGE_URI.ABORTED"` and `adapter.get` is NEVER invoked #### Scenario: signal is forwarded to adapter - **WHEN** `open("file:///a", signal)` is called with a live signal - **THEN** the adapter receives the same signal reference **Verified by:** `src/resolver/resolver.test.ts::"short-circuits on pre-aborted signal"` AND `src/resolver/resolver.test.ts::"forwards AbortSignal to adapter unchanged"` --- ### Requirement: Adapter error re-wrapping Errors thrown by the underlying adapter SHALL be inspected before re-wrap. Any throwable that is an instance of `SuaveplanError` (the runtime carrier for `defineCodes`-based error namespaces) SHALL be re-thrown unchanged — the resolver MUST NOT wrap a namespaced error inside its own envelope. Throwables that are NOT `SuaveplanError` instances (plain `Error`, foreign objects, strings) SHALL be wrapped in `StorageUriError` with `code === "STORAGE_URI.ADAPTER_ERROR"`, the original preserved as `cause`, and `uri` + `scheme` discriminants populated. This includes `StorageUriError` itself (which `extends SuaveplanError`) — the pre-existing pass-through is preserved as a special case of the new rule. #### Scenario: arbitrary non-Suaveplan throwable is re-wrapped as ADAPTER_ERROR - **WHEN** `read("file:///a")` is called and `adapter.get` throws `new Error("disk fault")` - **THEN** the rejection is a `StorageUriError` with `code === "STORAGE_URI.ADAPTER_ERROR"`, `cause.message === "disk fault"`, `uri === "file:///a"`, and `scheme === "file"` #### Scenario: SuaveplanError subclass passes through with its original code - **WHEN** `read("drive://primary/x")` is called and `adapter.get` throws `new DriveUriError("DRIVE_ID_MISMATCH", { uri, expected, actual })` (a `SuaveplanError` subclass with `code === "DRIVE_URI.DRIVE_ID_MISMATCH"`) - **THEN** the rejection is that same `DriveUriError` instance, `err.code === "DRIVE_URI.DRIVE_ID_MISMATCH"`, and the resolver does NOT wrap it inside a `StorageUriError` #### Scenario: existing StorageUriError is re-thrown unchanged - **WHEN** the adapter (via a nested resolver) throws `new StorageUriError("NOT_FOUND", ...)` - **THEN** the same instance is propagated; no re-wrap occurs **Verified by:** `src/resolver/resolver.test.ts::"wraps non-SuaveplanError throwables as ADAPTER_ERROR"` AND `src/resolver/resolver.test.ts::"passes through thrown SuaveplanError subclasses unchanged"` AND `src/resolver/resolver.test.ts::"re-throws StorageUriError unchanged"` --- ### Requirement: OpenTelemetry instrumentation Every public operation SHALL emit a span on the `@suaveplan/storage-uri` tracer and SHALL record `storage_uri.op_duration_ms{op, scheme, outcome}` on the meter. Errors SHALL increment `storage_uri.errors_total{scheme, code}` with the `STORAGE_URI.*` code (or `ADAPTER_ERROR` for re-wrapped). Fallback paths SHALL increment `storage_uri.stream_fallback_total` and `storage_uri.buffered_write_total` respectively. #### Scenario: successful read records duration histogram and counter - **WHEN** `read("file:///a")` succeeds - **THEN** the meter records one observation on `storage_uri.op_duration_ms{op:"read", scheme:"file", outcome:"success"}` and one increment of `storage_uri.reads_total{scheme:"file", outcome:"success"}` #### Scenario: error emits errors_total with the resolved code - **WHEN** `read("ftp://x/y")` rejects with `UNKNOWN_SCHEME` - **THEN** `storage_uri.errors_total{scheme:"ftp", code:"STORAGE_URI.UNKNOWN_SCHEME"}` is incremented by 1 #### Scenario: query strings are stripped from span attributes - **WHEN** `read("https://host/path?token=secret")` is called - **THEN** the emitted span's `storage.uri` attribute is `"https://host/path"` (no `?token=...`) **Verified by:** `src/otel/otel.test.ts::"records duration histogram on success"` AND `src/otel/otel.test.ts::"increments errors_total with resolved code"` AND `src/otel/otel.test.ts::"strips query string from span attributes"` --- ### Requirement: Plugin conformance harness The package SHALL export `runUriPluginConformance(plugin, fixtures)` that adapter packages call from their own test suites to verify their `uriPlugin` satisfies the resolver's contract. The harness SHALL assert that `parse` accepts every URI in `fixtures.validUris`, rejects every URI in `fixtures.invalidUris` with `INVALID_URI`, and (when `build` is implemented) round-trips every entry in `fixtures.buildRoundTrip`. #### Scenario: harness passes for a well-formed plugin - **WHEN** `runUriPluginConformance(fileUriPlugin, { validUris: ["file:///a"], invalidUris: ["bad"], buildRoundTrip: [{ key: "a", uri: "file:///a" }] })` is invoked - **THEN** all assertions pass without throwing #### Scenario: harness fails for a plugin with broken round-trip - **WHEN** the harness is invoked against a plugin whose `build` does not invert `parse` - **THEN** the assertion fails with a message naming the failing fixture **Verified by:** `src/conformance/conformance.test.ts::"passes for a well-formed plugin"` AND `src/conformance/conformance.test.ts::"fails on broken round-trip"`