252 lines
16 KiB
Markdown
252 lines
16 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|