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

14 KiB

Capability Spec Delta: storage-uri (add-storage-uri)

Package: @suaveplan/storage-uri Status: PROPOSED

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

ADDED 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<Uint8Array> 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<Uint8Array> 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}. Adapters that throw on set SHALL surface STORAGE_URI.WRITE_UNSUPPORTED re-wrapping the original error 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: read-only adapter surfaces WRITE_UNSUPPORTED

  • WHEN write("https://cdn/x", bytes) is called against a plugin whose adapter throws STORAGE_HTTP.READ_ONLY
  • THEN the call rejects with StorageUriError having code === "STORAGE_URI.WRITE_UNSUPPORTED" and cause set to the original StorageHttpError

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 read-only adapter error as WRITE_UNSUPPORTED"


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 <scheme>:<rest> where <scheme> 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 that are NOT StorageUriError SHALL be wrapped in StorageUriError with code === "STORAGE_URI.ADAPTER_ERROR", the original preserved as cause, and uri + scheme discriminants populated.

Scenario: arbitrary adapter error is re-wrapped

  • 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: 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 adapter errors as ADAPTER_ERROR" 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"