Skip to content

Store blobs in a bucket

A provider that needs to keep files — generated reports, downloaded attachments, cached renderings — declares its own bucket with defineBucket and reaches it through BucketService. A bucket is an S3-shaped object store: blobs addressed by /-separated keys, each with a size, a content type, and user metadata. This guide covers declaring a bucket, using it from an action, and what the host has to supply.

Like a database, a bucket is not one of the primitives. By default it is internal storage. Expose what is in it through actions, entities, and events — a caller asks for “the latest report”, not for a key in your bucket. That keeps you free to change the layout later.

Bytes are the exception the primitives handle badly. A client that needs to download a PDF or upload an image wants the blob itself, not a base64 field inside an action result — so a bucket can opt into being served directly. That is a deliberate choice per bucket, not the default, and it publishes the key layout you would otherwise be free to change.

defineBucket takes an id and returns the definition unchanged. Call it once, at module scope, and export the definition.

import { defineBucket } from '@grundlag/core';
const reportsBucket = defineBucket({ id: 'reports' });
export { reportsBucket };

The definition object is the bucket’s identity, not just its id. Asking BucketService for an id that another definition already claimed throws BucketIdConflictError, so two providers cannot end up sharing storage by accident. Two providers that should share a bucket share the definition.

Register the definition on the provider instance so the bucket is known before anything touches it:

setup: async ({ services }) => {
const instance = new ProviderInstance('reporting');
instance.buckets.register(reportsBucket);
return instance;
};

BucketService only learns of a bucket when someone asks for an instance, so a bucket used solely inside an action stays invisible until that action first runs — too late for a host that mounts its HTTP routes at boot. Registering is also what makes the bucket visible to anything that enumerates a provider’s storage.

Registering publishes nothing on its own: unlike actions and entities, a registered bucket is still private until it is marked exposed.

The storage backend is supplied by the host, not the provider — the host sets a bucketCreator on the Services container (see Services and dependency injection). Your provider asks BucketService for the bucket and gets an object it can read and write.

import { BucketService, defineAction, z } from '@grundlag/core';
import { reportsBucket } from '../buckets/buckets.js';
const saveReport = defineAction({
id: 'saveReport',
name: 'Save report',
description: 'Renders a report and stores it for later download.',
input: z.object({ month: z.string(), body: z.string() }),
output: z.object({ key: z.string(), size: z.number() }),
execute: async ({ input, services }) => {
const bucket = services.get(BucketService).getInstance(reportsBucket);
const object = await bucket.putText(`monthly/${input.month}.md`, input.body, {
contentType: 'text/markdown',
metadata: { generatedBy: 'saveReport' },
});
return { key: object.key, size: object.size };
},
});

getInstance is cached by id and the backend is opened lazily on first read or write, so it is cheap to call from every action.

await bucket.put('a/b.bin', bytes, { contentType: 'application/octet-stream' });
await bucket.putText('a/b.txt', 'hello'); // defaults to text/plain; charset=utf-8
const bytes = await bucket.get('a/b.bin'); // Uint8Array
const text = await bucket.getText('a/b.txt'); // string
const object = await bucket.stat('a/b.txt'); // BucketObject | undefined
const there = await bucket.exists('a/b.txt'); // boolean
await bucket.delete('a/b.txt'); // idempotent
const objects = await bucket.list({ prefix: 'a/' }); // ordered by key

The two shapes of “missing” are deliberate: get and getText throw BucketObjectNotFoundError because a caller that asked for a blob by key expects one, while stat and exists report absence by return value because asking is the point. delete on a key that is not there is not an error.

put replaces whatever was at the key, metadata included — a write that passes no metadata clears the previous write’s rather than merging into it.

Keys are relative, /-separated paths: reports/2026/06.md. A key that is empty, starts or ends with /, contains an empty segment, a . or .. segment, a backslash, or a null byte is rejected with InvalidBucketKeyError. The filesystem backend maps keys onto paths, so these rules are what stops a key built from tool input from writing outside its bucket. Keys are validated on every call, get and delete included.

exposed decides one thing: whether the host serves the bucket over HTTP. It defaults to false.

Private (the default) exposed: true
Provider code using it Unchanged Unchanged
HTTP routes None — the bucket is unreachable from outside The full object API below
Who can read and write Only code holding the definition Anyone who can reach the host
Reached by callers Indirectly, through actions and entities Directly, by key

The flag changes nothing about the bucket itself. A private bucket is not a lesser bucket: same storage, same operations, same backend. It simply has no door from the outside, so the only way in is code that already has the definition. Flipping the flag adds that door — it does not migrate anything, and a bucket can be made private again by removing it.

Reach for it when a caller needs the bytes themselves — downloading a PDF, uploading an avatar — which is the case the primitives handle badly, since an action would have to base64 the payload into a JSON field. Everything else is better served by an action that happens to read from the bucket.

const reportsBucket = defineBucket({ id: 'reports', exposed: true });

The routes are flat, keyed by bucket id rather than by owning provider, because bucket ids are already globally unique:

Method Route Does
GET /api/buckets Lists the exposed buckets
GET /api/buckets/{id} Lists objects, optionally ?prefix=
GET /api/buckets/{id}/{key} Reads the raw bytes
HEAD /api/buckets/{id}/{key} Size, content type, and metadata as headers
PUT /api/buckets/{id}/{key} Writes the request body verbatim
DELETE /api/buckets/{id}/{key} Deletes the object

The key is the whole trailing path, so /api/buckets/reports/monthly/june.md addresses the key monthly/june.md. On a write, Content-Type becomes the object’s content type and x-bucket-meta-<name> headers become its metadata, S3-style; both are replaced wholesale by each write. A missing object is a 404 and a malformed key a 400. Request bodies are capped at 32 MiB — blobs are held whole in memory on both sides.

Only registered buckets can be exposed — the host enumerates what providers registered at boot, so a definition that is never registered has no routes no matter what its exposed flag says.

From the other side of the wire, @grundlag/client offers the same operations against an exposed bucket — see Call actions and search entities.

A bucketCreator maps a bucket id to a BucketStorage. Hosts that pass nothing get the in-memory backend, which is what makes buckets work in tests without touching disk. For a real deployment, hand the container the filesystem creator:

import { Services } from '@grundlag/core';
import { createFileSystemCreator } from '@grundlag/core/filesystem';
const services = new Services({
bucketCreator: createFileSystemCreator((id) => join(dataLocation, 'buckets', id)),
});

Each bucket becomes a directory: blobs under objects/<key>, and — only for objects that have a content type or metadata — a sidecar at meta/<key>.json. Walking objects/ gives exactly the keys in the bucket.

Implementing BucketStorage yourself is how another backend (S3, a blob service) gets plugged in; nothing in a provider changes when the host swaps one in.

  • Keep the bucket private by default. Expose its contents through actions, entities, and events; reach for exposed when a client genuinely needs the bytes themselves.
  • Register during setup. It is what makes the bucket known before first use.
  • Define once, share the definition. The definition object is the identity; a duplicate id from a different definition is an error.
  • Blobs are whole values. put and get move an entire object; buckets are for files you can hold in memory, not for streaming multi-gigabyte media.
  • Let the host own the creator. The provider asks BucketService; the host decides where the bytes land.