A storage backend Apograph does not ship is one factory function plus one call to
the contract suite. The port is deliberately small, and it lives in
media-server as a NestJS-free type, so an adapter imports no framework.
The port
interface StorageProvider {
id: string;
capabilities: StorageCapabilities;
put(...): Promise<PutResult>;
get(key: string): Promise<Readable>;
remove(key: string): Promise<void>;
directUrl?(...): Promise<string>;
verify?(): Promise<void>;
}id is the provider’s own name, recorded on every asset row. capabilities is
what stops the core assuming the weakest backend.
| Capability | Means |
|---|---|
directUrl | It can sign a short-lived URL with pinned response headers |
contentTypeMetadata | It can store a content type alongside the bytes |
streamingPut | The body is streamed rather than buffered |
A capability is a claim, not a wish
Declaring directUrl: true without implementing the method fails an eager check
at boot.
And declaring it when your backend cannot pin Content-Disposition and
Content-Type on the signed URL is worse than failing: a redirect discards the
app’s own headers, and the stored MIME type is the uploader’s unverified claim,
so an uploaded .html renders on your storage host’s origin.
Vercel Blob declares false for exactly this
reason, and Azure and
GCS compute it from how they were authenticated.
The contract suite
npm install --save-dev @apograph/media-provider-testkitdescribeStorageProvider('my-provider', {
create: () => createMyStorageProvider({ /* … */ }),
cleanup: (provider) => teardown(provider),
storedKeys: (provider) => listEverything(provider)
});create runs per case, so a provider holding state hands out a fresh one and
cases cannot leak into each other.
storedKeys is optional but wanted: it turns the all-or-nothing check from “the
promise rejected” into “and it left nothing behind”, which is the half that
matters. A failed put never handed its key back, so anything it leaves is
unreachable garbage no caller can reclaim.
What the suite asserts
Identity — a non-empty id, a complete capabilities, and directUrl()
present exactly when capabilities.directUrl says so.
put — true size and sha256; bytes round-trip through get; two assets
with the same file name get distinct keys; a derivative never collides with an
original called thumb.webp; a body that fails mid-stream leaves nothing.
get — rejects for a key that was never written, and for one that was
removed. Rejecting before the stream opens is the point: the download route
turns a rejection into a 404, and once bytes flow the response is a streaming
200 that can no longer become one.
remove — idempotent, and a no-op for a key that never existed. Reclaim is
post-commit and best-effort; a provider that threw here would add noise nobody
can act on.
Everything rejects rather than throwing synchronously — a synchronous throw
lands outside the try that was meant to reclaim the blobs.
The suite exists because these invariants used to live only in prose. Every one
is something the media core relies on and cannot check for itself: it reclaims
blobs by key, streams get straight into an HTTP response, and stores
derivatives beside originals. A provider written outside the repository had no
way to discover it broke one.
Registering it
MediaServerPlugin({
provider: createMyStorageProvider({ /* … */ }),
config: config.plugins.media
});There is no registry to add to and no name to declare anywhere else. The composition root is the single place a backend is selected — see the media plugin.
Read the in-memory provider first
It is a known-good implementation short enough to read in one sitting, and it is the one that meets the all-or-nothing rule by construction rather than by cleanup.