Initial Commit
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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 (` | ||||