12 KiB
Add @suaveplan/storage-uri — URI-routed dispatch over StorageLike adapters
Package: @suaveplan/storage-uri
Status: PROPOSED
Type: new-package (genesis-original)
- Design: ./design.md
- Spec delta: ./specs/storage-uri/spec.md
Why
Genesis now ships seven StorageLike-conformant adapters — storage-file, storage-http, storage-cdn, storage-cdn-rackspace, storage-cdn-bunnycdn, storage-s3, and three storage-browser-* flavours — but every consumer has to pick one adapter at construction time and address its keys directly. Real workloads cross adapters: a media pipeline reads source from s3://uploads/raw/clip.mov, a poster from file:///var/cache/posters/clip.jpg, and a delivery copy from https://cdn.example.com/clip/master.m3u8 — all in the same call site. Today that means three injected adapters, three key conventions, and bespoke fan-out logic.
storage-uri collapses that to one string in, one stream out. A consumer hands read("s3://bucket/key") to a resolver that owns a scheme → { adapter, parseUri } registry, gets a ReadableStream<Uint8Array> back, and never has to know which adapter was selected. The registry is composed once at boot from any subset of the existing storage packages; each adapter package ships its own uriPlugin so the resolver stays scheme-agnostic and dependency-light.
The package is a router, not a new storage tier. It performs no I/O of its own — every operation delegates to a registered StorageLike after URI parsing.
What changes
- ADDED:
StorageUriResolverclass — owns aMap<scheme, UriPlugin>registry, exposesopen/read/write/stat/exists/remove/listkeyed by URI string. - ADDED:
createStorageUriResolver(plugins)factory — accepts an array ofUriPluginand produces a frozen resolver. Idempotent re-registration is a no-op when the plugin reference is identical; conflicting registrations throwSTORAGE_URI.SCHEME_CONFLICT. - ADDED:
UriPlugininterface —{ scheme: string; adapter: StorageLike; parse(uri): { key: string; meta?: Record<string, string> }; build?(key, meta?): string }. Theadapterfield isStorageLike(notAbstractStorage) so the resolver works against the structural contract from@suaveplan/types. - ADDED:
parseUri(uri)standalone helper — RFC 3986 scheme + authority + path split that adapter plugins compose with their own scheme-specific addressing (s3://bucket/key,file:///abs/path,bunny://zone/path,https://host/path). - ADDED:
open(uri, signal?): Promise<ReadableStream<Uint8Array>>— single-shot streaming read. When the underlying adapter exposescreateReadStream, it is forwarded; otherwise the resolver falls back to wrappingadapter.get(key)in a one-chunkReadableStream. The fallback path is observable viastorage_uri.stream_fallback_total. - ADDED:
read(uri, signal?): Promise<Uint8Array>— buffered read. Delegates straight toadapter.get(key); throwsSTORAGE_URI.NOT_FOUNDwhen the adapter returnsundefined. - ADDED:
write(uri, body, signal?): Promise<void>— acceptsUint8ArrayorReadableStream<Uint8Array>. Streams are drained into a singlesetcall when the adapter lackscreateWriteStream; the buffered fallback is observable viastorage_uri.buffered_write_total. - ADDED:
stat(uri, signal?): Promise<{ size?: number; etag?: string; lastModified?: Date; contentType?: string } | undefined>— surfaces adapter metadata when available; returnsundefinedfor adapters that cannot answer without a full read. - ADDED:
exists(uri, signal?): Promise<boolean>andremove(uri, signal?): Promise<boolean>— direct delegates toStorageLike.existsandStorageLike.deleteafter parse. - ADDED:
list(uri, options?, signal?): Promise<{ uris: string[]; hasMore: boolean; continuationToken?: string }>— the URI is treated as a prefix; results are re-serialised back to URIs viaplugin.build(). Plugins without abuild()function throwSTORAGE_URI.LIST_UNSUPPORTED. - ADDED: Sibling
uriPluginexports in every storage adapter package —storage-fileshipsfileUriPlugin(rootDir),storage-httpshipshttpsUriPlugin(httpStorage)andhttpUriPlugin(httpStorage),storage-cdn-rackspaceshipsrackspaceUriPlugin(adapter, container),storage-cdn-bunnycdnshipsbunnyUriPlugin(adapter, zone),storage-s3shipss3UriPlugin(adapter, bucket). Each owns its scheme parser; the resolver knows nothing about S3 buckets, CDN zones, or filesystem roots. - ADDED:
STORAGE_URI_CODESregistry viadefineCodes("STORAGE_URI", [...])— 8 codes:UNKNOWN_SCHEME,INVALID_URI,SCHEME_CONFLICT,NOT_FOUND,LIST_UNSUPPORTED,WRITE_UNSUPPORTED,ABORTED,ADAPTER_ERROR. - ADDED:
StorageUriError extends SuaveplanError<StorageUriErrorCode>withuriandschemediscriminants; adapter-originated errors are re-wrapped withADAPTER_ERRORpreserving the original ascause. - ADDED: OTel surface —
storage_uri.op_duration_ms{op,scheme,outcome}histogram;storage_uri.{reads,writes,deletes,errors,stream_fallback,buffered_write}_total{scheme,outcome}counters; per-op span on@suaveplan/storage-uritracer withstorage.uri.scheme,storage.uri.key,storage.uriattributes. - ADDED:
runUriPluginConformance(plugin, fixtures)test harness — every adapter package'suriPluginSHALL pass this conformance suite (round-trip parse↔build, key boundary forwarding, list re-serialisation, error mapping).
Out of scope
- A new storage adapter.
storage-uriperforms zero I/O; every byte goes through an existingStorageLike. Reason: this is a routing concern, not a storage tier. - Cross-scheme copy/move.
copy("s3://a/x", "file:///b/x")is tempting but commits the resolver to streaming semantics, transactional rollback, and bandwidth governance that belong in a dedicatedstorage-pipelinepackage. Reason: scope creep; the consumer can composeread+writetoday. - URI signing. Signed URLs (S3 presigned, CDN token-auth) are produced by the underlying adapters; the resolver MUST NOT introduce its own signing layer. Reason: every provider's signing scheme is different and already lives in the adapter package.
- Caching. Repeat reads of the same URI hit the adapter every time. A caching resolver belongs in a separate
storage-uri-cache(compose withcache-storage). Reason: cache invalidation policy is application-specific. - Schema federation across resolvers. A single resolver owns one plugin per scheme. Multiple Rackspace containers behind one
rackspace://scheme need to be addressed by including the container in the authority (rackspace://container-a/key) — handled insiderackspaceUriPlugin. Reason: keeps the resolver O(1) on dispatch. - Browser-tier auto-registration. The resolver is universal-tier; consumers register plugins explicitly. No global mutable registry. Reason: mutable singletons break testability and tree-shaking.
Source mapping
This is a genesis-original package; there is no upstream in ../suaveplan/. All requirements derive from the StorageLike contract in @suaveplan/types (openspec/specs/types/spec.md) and the existing adapter surface.
Requirements with no source line
| Requirement | Justification |
|---|---|
Type against StorageLike, not AbstractStorage |
Keeps the resolver in the universal tier with a zero-runtime dep on @suaveplan/types. Adapters that extend AbstractStorage satisfy StorageLike structurally. |
Plugins live in each adapter package, not in storage-uri |
Avoids forcing the resolver to depend on every storage provider. Consumers pull in only the adapter packages they actually use; the resolver itself stays tiny. |
open() returns ReadableStream<Uint8Array> |
Web Streams is the universal cross-runtime stream type (Node ≥18, Bun, browsers, Deno). Matches Response.body from storage-http and existing createReadStream signatures. |
stream_fallback_total + buffered_write_total counters |
Quietly upgrading a buffered adapter to a "streaming" surface is a memory-pressure hazard. Operators alert on these counters to identify mis-composed adapters. |
parse(uri) → { key, meta } rather than { key } alone |
CDN zones, S3 buckets, Rackspace containers all need an extra addressing dimension; meta is the documented escape hatch so the resolver doesn't grow a separate "authority" field per scheme. |
LIST_UNSUPPORTED distinct from WRITE_UNSUPPORTED |
Listing requires plugin.build(); write requires adapter write capability. They're independently optional and need distinct error codes for capability probing. |
| Idempotent registration is a no-op only when the plugin reference is identical | Reference equality is the cheapest correct test; deep-equality would mask configuration drift between modules that import the same scheme. |
Verification approach
Every requirement in the spec delta carries a **Verified by:** line referencing concrete it(...) titles in these files:
src/resolver/resolver.test.ts— registry construction, scheme dispatch, conflict detection, idempotent re-registration,UNKNOWN_SCHEMEfor unregistered scheme, AbortSignal propagation to every operation.src/resolver/resolver.streams.test.ts—openforwards nativecreateReadStreamwhen present, falls back to one-chunk wrap when absent,stream_fallback_totalincrements on fallback path.src/resolver/resolver.writes.test.ts—writeacceptsUint8Arraystraight, drainsReadableStreaminto singlesetwhen adapter lackscreateWriteStream,buffered_write_totalincrements on drain path.src/resolver/resolver.spec-conformance.test.ts— type-level conformance againstStorageLike, everySTORAGE_URIcode reachable, OTel instruments observed via real in-memoryMeterProvider.src/parse-uri/parse-uri.test.ts— RFC 3986 happy paths (s3://bucket/key,file:///abs/path,https://host/path?q=1), invalid forms (bare-string,scheme:, embedded null),INVALID_URImapping.src/error-codes/error-codes.test.ts— exhaustive code registry,StorageUriErrordiscriminant fields, adapter-error re-wrap preservescause.src/otel/otel.test.ts— metrics via real in-memoryMeterProvider, error span via in-memorySpanExporter, scheme attribute present on every emission.src/conformance/conformance.test.ts—runUriPluginConformanceagainst a fixture in-memoryStorageLikeand a fixtureparse/buildpair.tests/integration/cross-scheme.test.ts— composes a resolver withstorage-file+storage-httpreal adapters; readsfile:///tmp/...andhttps://...round-trip without code path divergence.
Impact
Affected dependents
The package is new — no dependents currently. After adopting, the obvious upstream beneficiaries are drive-template (URI-addressed hydration sources), enterprise-media, the audio/video pipelines, and any consumer that currently injects multiple StorageLikes side-by-side.
- No dependents (verified by
bunx turbo ls --filter=...@suaveplan/storage-urireturning the package itself).
Affected upstreams (sibling adapter packages — additive uriPlugin export)
| Package | Action | Breaking? |
|---|---|---|
@suaveplan/storage-file |
ADD fileUriPlugin(rootDir) named export |
no |
@suaveplan/storage-http |
ADD httpsUriPlugin(adapter) and httpUriPlugin(adapter) named exports |
no |
@suaveplan/storage-cdn-rackspace |
ADD rackspaceUriPlugin(adapter, container) named export |
no |
@suaveplan/storage-cdn-bunnycdn |
ADD bunnyUriPlugin(adapter, zone) named export |
no |
@suaveplan/storage-s3 |
ADD s3UriPlugin(adapter, bucket) named export |
no |
All additions are purely additive named exports — no existing surface changes. Each adapter package gets a separate spec delta in this change folder ONLY if its uriPlugin introduces non-trivial requirements; otherwise the export is documented in the adapter's existing spec and verified by the conformance harness.