@pegma/storage-blobs
v0.2.0
Published
Provider-neutral object (blob) storage: opaque keys, streaming put/get, conditionals, and a conformance suite.
Readme
@pegma/storage-blobs
Provider-neutral object (blob) storage for Pegma components: opaque keys, streaming put/get, conditional writes, bounded prefix listing, and a conformance suite that every adapter must pass.
[!IMPORTANT] Early
0.x(0.2.0on npm). Pin exact versions; the public API is not frozen. See PROJECT_PLAN.md.
Install
npm install @pegma/[email protected]Server-side composition
Hosts construct one BlobStore per container or bucket and inject it into
application services. Clients never talk to Azure, R2, or S3 directly; the host
API authorizes, rate-limits, and streams through the store.
import { createMemoryBlobStore, type BlobStore } from "@pegma/storage-blobs";
// Local / tests. Production hosts inject an adapter for one bucket.
const blobs: BlobStore = createMemoryBlobStore({
maxObjectBytes: 16 * 1024 * 1024,
});
const key = "support-desk/attachments/01JEXAMPLE";
const put = await blobs.put(key, requestBodyStream, {
contentType: "application/pdf",
cacheControl: "public, max-age=31536000, immutable", // optional
ifNoneMatch: "*", // create-only
userMetadata: { ticket_id: "t_123" },
});
if (!put.ok) {
// "exists" — another writer won the create-only race
throw conflict(put.reason);
}
const object = await blobs.get(key);
if (object === null) {
return notFound();
}
// Stream object.body to the HTTP response with safe Content-Disposition.Conditionals
Expected races return a result, not an exception:
| Call | Condition | Refusal |
| -------- | ------------------ | --------------------------------------- |
| put | ifNoneMatch: "*" | { ok: false, reason: "exists" } |
| put | ifMatch: etag | "missing" or "changed" |
| delete | ifMatch: etag | "missing" or "changed" |
| delete | (none) | "missing" if the key was already free |
Infrastructure failures still throw (BlobStoreError and subclasses).
Listing for GC
let cursor: string | undefined;
do {
const page = await blobs.list({
prefix: "support-desk/",
limit: 100,
cursor,
});
for (const entry of page.objects) {
await blobs.delete(entry.key, { ifMatch: entry.etag });
}
cursor = page.nextCursor ?? undefined;
} while (cursor !== undefined);Listing is not a snapshot. Always delete with the etag you observed.
Cache-control
put accepts an optional cacheControl string stored with the object and
returned by head/get (undefined when the put omitted it; a replacing put
without one clears it). It is in the port because every first-class backend
keeps Cache-Control as native object state served with the bytes — unlike
signed URLs or lifecycle rules, which stay out. The store never interprets the
value; whether a host response honours or overrides it is host policy, like
Content-Disposition.
Conformance (v1 specification)
The exported suite is the v1 contract. A behaviour not asserted there is
not something a component may rely on. New BlobStore methods require suite
cases in the same change. See ADAPTER_AUTHORING.md.
Every adapter must pass the suite against a real empty backend:
import { describe, it } from "vitest";
import { conformanceCases } from "@pegma/storage-blobs/conformance";
import { createMemoryBlobStore } from "@pegma/storage-blobs";
describe("my adapter", () => {
for (const testCase of conformanceCases) {
it(testCase.name, () => testCase.run(() => createMyStore()));
}
});Size-limit and concurrent create-only cases are exported separately when the adapter needs a dedicated small ceiling or parallel writers:
import {
concurrentConformanceCases,
dualStoreConformanceCases,
sizeLimitConformanceCases,
} from "@pegma/storage-blobs/conformance";What this package is not
- Not an access-control system. Authorization and rate limits belong on the host.
- Not structured persistence. Ticket rows and attachment lifecycle live in
@pegma/storage-core. - Not client-facing signed URLs. The default path is
Client → host API → BlobStore → provider.
See ARCHITECTURE.md and APPLICATION_PATTERNS.md.
License
MIT © 2026 RetireGolden, LLC
