Files
suaveplan/openspec/specs/storage-uri/spec.md
2026-05-27 23:29:53 +01:00

16 KiB

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<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}.

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 <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 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"