@btravstack/storage
The object-storage port for
@btravstack/core: oneStoragean application depends on, an in-memory adapter, an S3-compatible one with presigned reads and writes and streamed ones in bounded memory, and a span, a count and a log line per operation.
📖 Documentation · API Reference
pnpm add @btravstack/storage @btravstack/core @btravstack/config @btravstack/di unthrown
Four peer dependencies, plus @aws-sdk/client-s3 and
@aws-sdk/s3-request-presigner — optional, and needed only if you compose the
S3 adapter. Instrumentation needs no peer of its own: the Logger, Tracer
and Meter it depends on are
the kernel's ports.
Node >=22.
A worked example
Storing a document, and reading it back through a URL that carries no credentials:
import {
Port as InvoicePort,
Provider as InvoiceProvider,
} from "@btravstack/di";
import { Storage } from "@btravstack/storage";
import type { AsyncResult } from "unthrown";
class Invoices extends InvoicePort("ReadmeInvoices")<{
readonly store: (
tenantId: string,
id: string,
pdf: Uint8Array,
) => AsyncResult<void, never>;
readonly link: (
tenantId: string,
id: string,
) => AsyncResult<string | undefined, never>;
}> {}
// The tenant is in the key because the port has no slot for one — the same
// rule a cache key follows.
const keyFor = (tenantId: string, id: string) =>
`invoices/${tenantId}/${id}.pdf`;
export const invoices = InvoiceProvider(Invoices)({
inject: { storage: Storage },
sync: ({ storage }) => ({
store: (tenantId, id, pdf) =>
storage
.put(keyFor(tenantId, id), pdf, { contentType: "application/pdf" })
// A document that failed to store is YOUR decision to make; here it
// is swallowed because an observer already recorded it and
// counted it one layer down.
.recoverErrCases((matcher) =>
matcher.with(P.tag("StorageUnavailable"), P.tag("InvalidStorageOptions"), P.tag("ChecksumMismatch"), () => undefined),
),
// Serving bytes through your own process is the anti-pattern this arm
// exists to avoid: the caller fetches the object directly, for a minute.
link: (tenantId, id) =>
storage
.presignedUrl(keyFor(tenantId, id), { ttlMs: 60_000 })
.recoverErrCases((matcher) =>
matcher
.with(P.tag("StorageUnavailable"), () => undefined)
.with(P.tag("PresignNotSupported"), () => undefined),
),
}),
});
The composition — the adapter is the only decision, decided here and nowhere else:
import { storage } from "@btravstack/storage";
import { s3Storage } from "@btravstack/storage/s3";
export const InvoicesApp = Module("InvoicesApp")({
imports: [
// Instrumented by default, which is why `observability()` and `otel()`
// Every call is reported through `Observers`. Drop the two modules
// below and this composition is unchanged — it just reports to nobody.
storage({ adapter: s3Storage() }),
observability(),
otel(),
],
provides: [invoices],
exports: [Invoices, Logger],
});
Options
| Option | Where | What it is |
|---|---|---|
sha256 / metadata | put / putIfAbsent and streams | verify the write digest and atomically describe the object |
checksum / metadata | copy | compute SHA-256 server side and replace the description |
kmsKeyId | writes, copy and presignedUpload | select per-object SSE-KMS; upload clients send both signed encryption headers |
adapter | storage({ adapter }) | the adapter module providing StorageBackend — required |
STORAGE_S3_ENDPOINT | environment, read by s3Storage() | the store's endpoint — required |
STORAGE_S3_BUCKET | environment | the bucket — required |
STORAGE_S3_ACCESS_KEY_ID / STORAGE_S3_SECRET_ACCESS_KEY | environment | the credentials — required |
STORAGE_S3_REGION | environment | default us-east-1 |
STORAGE_S3_MAX_SOCKETS / maxSockets | environment, or s3Storage({...}) | sockets every call but the streamed ones shares — default 50, the SDK's |
STORAGE_S3_STREAM_MAX_SOCKETS / streamMaxSockets | environment, or s3Storage({...}) | the streamed calls' own pool, and their bound — default 50 |
STORAGE_S3_CONNECTION_TIMEOUT_MS / connectionTimeoutMs | environment, or s3Storage({...}) | how long an attempt waits for a socket — default 0, none |
STORAGE_S3_SOCKET_TIMEOUT_MS / socketTimeoutMs | environment, or s3Storage({...}) | how long an attempt's socket may stay idle — default 0, none |
STORAGE_S3_REQUEST_TIMEOUT_MS / requestTimeoutMs | environment, or s3Storage({...}) | how long an attempt waits for the store to answer — default 0, none |
STORAGE_S3_STREAM_CONNECTION_TIMEOUT_MS / streamConnectionTimeoutMs | environment, or s3Storage({...}) | the stream pool's own connection timeout — default connectionTimeoutMs |
STORAGE_S3_STREAM_SOCKET_TIMEOUT_MS / streamSocketTimeoutMs | environment, or s3Storage({...}) | the stream pool's own socket timeout — default socketTimeoutMs |
STORAGE_S3_STREAM_REQUEST_TIMEOUT_MS / streamRequestTimeoutMs | environment, or s3Storage({...}) | the stream pool's own request timeout — default requestTimeoutMs |
The full table — defaults, semantics and the reasoning — lives on the reference page, which is this list's one detailed home.
What it decides, and what it does not
It decides that a missing object is an ordinary answer rather than a fault
(counted not_found, without a fault line), that a held key under putIfAbsent is an ordinary answer (ObjectExists, counted exists), that delete is idempotent, that copy and head never move the bytes through the process, that writes can verify SHA-256 and publish user metadata atomically, that copies replace metadata and can compute SHA-256 server side, that presigned uploads can require a per-object KMS key, that a streamed write never leaves a partial object (a multipart upload, aborted on any failure), that streamed transfers have a socket pool of their own and are refused (StreamPoolFull, counted saturated) rather than queued once it is full, that
presigning asks the store nothing — and that an adapter which cannot presign
says so instead of minting a URL that would fail only in production.
It does not decide what your keys look like, whether a failed write should
fail your request, or how long a link should live. There is no listing and no
bucket management — each reason is on
the reference page,
and the same reasoning is in AGENTS.md for contributors.
License
MIT
Presigned downloads accept contentDisposition, contentType and cacheControl
response overrides. See the storage reference
for signing and Unicode filename semantics.