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 ans3plugin - THEN the
s3plugin'sparseis invoked exactly once and the resultingadapter.get(key)call returns the stored bytes
Scenario: unregistered scheme throws UNKNOWN_SCHEME
- WHEN
read("ftp://host/path")is called against a resolver with noftpplugin - THEN the call rejects with
StorageUriErrorhavingcode === "STORAGE_URI.UNKNOWN_SCHEME"andscheme === "ftp"
Scenario: scheme matching is case-insensitive
- WHEN
read("S3://bucket/key")is called against a resolver registered with the lowercases3scheme - 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 declarescheme: "s3"ands3PluginA !== s3PluginB - THEN the call throws
StorageUriErrorwithcode === "STORAGE_URI.SCHEME_CONFLICT"andscheme === "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 implementscreateReadStream - THEN the returned stream is the same reference returned by
adapter.createReadStream, andstream_fallback_totalis 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 onlyget - THEN the returned stream emits exactly one chunk equal to
adapter.getresult, 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 andadapter.getreturnsundefined - THEN the call rejects with
StorageUriErrorhavingcode === "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.setis 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 wherestreamyields three chunks - THEN
adapter.setis 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 whoseadapter.setthrowsnew Error("read only mount") - THEN the call rejects with
StorageUriErrorhavingcode === "STORAGE_URI.WRITE_UNSUPPORTED"andcause.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 whoseadapter.setthrowsnew DriveUriError("WRITE_UNSUPPORTED", { uri, scheme: "drive" }) - THEN the rejection is that same
DriveUriErrorinstance,err.code === "DRIVE_URI.WRITE_UNSUPPORTED", and the resolver does NOT re-wrap it asSTORAGE_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 andadapter.exists("a.bin")returnstrue - THEN the resolver returns
truewithout invoking any other adapter method
Scenario: remove returns the adapter's boolean
- WHEN
remove("s3://bucket/missing")is called andadapter.deletereturnsfalse - 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 nogetMetadata/HEADsurface - 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 andadapter.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 omitsbuild - THEN the call rejects with
StorageUriErrorhavingcode === "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"), andparseUri("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"), orparseUri("x".repeat(4097))is called - THEN each call throws
StorageUriErrorwithcode === "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"andadapter.getis 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 andadapter.getthrowsnew Error("disk fault") - THEN the rejection is a
StorageUriErrorwithcode === "STORAGE_URI.ADAPTER_ERROR",cause.message === "disk fault",uri === "file:///a", andscheme === "file"
Scenario: SuaveplanError subclass passes through with its original code
- WHEN
read("drive://primary/x")is called andadapter.getthrowsnew DriveUriError("DRIVE_ID_MISMATCH", { uri, expected, actual })(aSuaveplanErrorsubclass withcode === "DRIVE_URI.DRIVE_ID_MISMATCH") - THEN the rejection is that same
DriveUriErrorinstance,err.code === "DRIVE_URI.DRIVE_ID_MISMATCH", and the resolver does NOT wrap it inside aStorageUriError
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 ofstorage_uri.reads_total{scheme:"file", outcome:"success"}
Scenario: error emits errors_total with the resolved code
- WHEN
read("ftp://x/y")rejects withUNKNOWN_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.uriattribute 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
builddoes not invertparse - 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"