Skip to content

Storage: KV, D1 and R2

Cloudflare gives you three places to keep things, and forge-effect gives each the same shape:

  • KV for small values you read a lot: a counter, a session, a cached page.
  • D1 for rows you query with SQL: a guestbook, an index, an order history.
  • R2 for files: uploads, images, anything with a content type.

Each one is a class. Declare it, bind it, and yield* it in a handler:

storage.ts
import { D1, Kv, R2 } from '@forge-effect/infra';
export class Cache extends Kv.Namespace<Cache>()('cache') {}
export class Db extends D1.Database<Db>()('main', {
migrations: ['CREATE TABLE notes (id TEXT PRIMARY KEY, body TEXT)'],
}) {}
export class Files extends R2.Bucket<Files>()('files') {}

That’s three resources. When you ship, each is made in your account (named after your app, so notes-cache in the app notes), and each Worker that binds one gets it. If you haven’t met this pattern yet, How it thinks explains it in a page.

Here’s the visits counter from example/app.ts. It reads a number, adds one and writes it back:

example/app.ts
export class Cache extends Kv.Namespace<Cache>()('cache') {}
const fetch = Effect.gen(function* () {
// …
const cache = yield* Cache;
const visits = Number(Option.getOrElse(yield* cache.get('visits'), () => '0')) + 1;
yield* cache.put('visits', String(visits));
// …
});

get gives you an Option<string>: None when the key isn’t there, so “missing” is a value you handle, not a null that surprises you later. The rest of the client:

sessions.ts
export class Sessions extends Kv.Namespace<Sessions>()('sessions') {}
// …in a handler:
const sessions = yield* Sessions;
// Gone by itself in an hour.
yield* sessions.put(`session/${id}`, user, { ttl: 3600 });
// Every key with the prefix, across as many pages as it takes.
const open = yield* sessions.keys('session/');
yield* sessions.delete(`session/${id}`);

keys follows Cloudflare’s pages for you and hands back the whole list. Values are strings; for JSON, encode it on the way in (an Effect Schema is good at that) and decode on the way out.

Every method fails with a KvError, which says what it was doing and to which key. Handle it like any tagged error:

sessions.ts
const greeting = yield* sessions.get('greeting').pipe(
// KV is having a bad moment: log it, and carry on as if the key were missing.
Effect.catchTag('KvError', (error) => Effect.as(Effect.logWarning(error.message), Option.none())),
);

Or don’t: a failure your handler leaves alone answers 500 and is logged.

A D1 database is SQLite, run by Cloudflare. Its client has three methods you’ll use all the time:

notes.ts
const db = yield* Db;
const { changes } = yield* db.run('INSERT INTO notes (id, body, at) VALUES (?, ?, ?)', id, body, DateTime.formatIso(yield* DateTime.now));
// Option: None when there's no such row.
const note = yield* db.first<{ body: string }>('SELECT body FROM notes WHERE id = ?', id);
const recent = yield* db.all<{ id: string; body: string }>('SELECT id, body FROM notes ORDER BY at DESC LIMIT 10');
  • all gives every row, first the first as an Option, and run runs a statement and says how many rows it changes.
  • Values go in with ? placeholders, never pasted into the SQL.
  • Each fails with a D1Error, carrying the SQL that failed.
  • exec runs SQL with no values, and binding is the D1 binding itself, for what the client doesn’t cover, such as batch.

You don’t create tables by hand. A database declares its migrations, in order, and that list is its schema:

notes.ts
export class Db extends D1.Database<Db>()('main', {
migrations: [
'CREATE TABLE notes (id TEXT PRIMARY KEY, body TEXT)',
['ALTER TABLE notes ADD COLUMN at TEXT', 'CREATE INDEX notes_at ON notes (at)'],
],
}) {}

Each migration is one statement, or a list of statements that go together. Each ship applies the ones the database hasn’t had yet: after the database exists, before the new version of your Worker runs, so new code never meets an old schema. Each migration is applied in one batch with its record (kept in the table _forge_effect_migrations), so it’s applied and recorded, or neither. If one fails, the ship stops with nothing deployed:

migration 2 failed, and nothing of it was applied: no such table: nowhere

infra plan tells you what’s coming. You saw it in your first app:

~ the D1 database guestbook-book: the next ship applies migration 1: migration-pending

To change the schema, add a migration to the end. Never edit one that’s shipped. Say you want a pinned column:

notes.ts
migrations: [
'CREATE TABLE notes (id TEXT PRIMARY KEY, body TEXT)',
['ALTER TABLE notes ADD COLUMN at TEXT', 'CREATE INDEX notes_at ON notes (at)'],
'ALTER TABLE notes ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0',
],

The database remembers each migration it applied, word for word, so it notices when the story changes. These stop a ship before anything happens:

  • migration-changed: one already applied was edited. It would never run again, so the database and your code would quietly disagree. Put it back, and add a new one after it.
  • migration-ahead: the database has more migrations than your code declares. Newer code migrated it, and this is older code, or another copy of the app.

And infra plan (or infra check --account) warns about migration-destructive: a migration that drops a table or a column, or deletes rows with no WHERE, such as migration 2 of the D1 database drops-main drops data: DROP TABLE t. It’s allowed, since sometimes it’s what you mean. It’s just never undoable.

infra explain migration-changed (or any code) says more about each.

An R2 bucket keeps objects by key. Here’s the upload API from the media example, which stores whatever you PUT and serves it back:

example/media/media/api.ts
const uploads = yield* Uploads;
switch (request.method) {
case 'PUT': {
const type = request.headers.get('content-type') ?? 'application/octet-stream';
const stored = yield* uploads.put(key, yield* Effect.promise(() => request.arrayBuffer()), { contentType: type });
// …
return Response.json({ key, size: stored.size }, { status: 201 });
}
case 'DELETE':
yield* uploads.delete(key);
return new Response(null, { status: 204 });
default:
return Option.match(yield* uploads.get(key), {
onNone: () => new Response('no such file\n', { status: 404 }),
// The body streams straight through to the response.
onSome: (file) => new Response(file.body, { headers: { 'content-type': file.httpMetadata?.contentType ?? 'application/octet-stream' } }),
});
}

The whole client, in one place:

files.ts
const files = yield* Files;
yield* files.put('notes/a.txt', 'buy milk', { contentType: 'text/plain' });
const text = yield* files.text('notes/a.txt'); // Option<string>
const head = yield* files.head('notes/a.txt'); // Option<R2ObjectInfo>: size, etag, uploaded, content type
const listed = yield* files.list('notes/'); // every object's metadata, across pages
yield* files.delete('notes/a.txt', 'notes/b.txt');
  • get gives the object with its body, which you can read once (it’s a stream). text reads it for you.
  • put takes a string, bytes, a Blob or a stream, and gives back the stored object’s info.
  • keys(prefix) lists keys; list(prefix) lists objects with their metadata, content type included, in the same listing. Both follow the pages to the end.
  • delete takes any number of keys. A key with no object is no error.
  • Everything fails with an R2Error. binding is the bucket’s binding itself, for what the client doesn’t cover.

A bucket can also choose its storage class: R2.Bucket<Archive>()('archive', { storageClass: 'InfrequentAccess' }).

Cloudflare deletes only an empty bucket. So when infra destroy or infra prune deletes one (and only when it’s allowed to delete data), it empties it first, a thousand objects a request, before it deletes any Worker, and says how many went. If something keeps objects there, such as a lock or an unfinished upload, it stops and tells you, with the Worker still standing.

Some data has to stay in a place: the EU, say. KV, D1, R2 and queues can each declare a jurisdiction, and a database or bucket can hint at the region it’s made near:

ledger.ts
class Ledger extends D1.Database<Ledger>()('ledger', { jurisdiction: 'eu', locationHint: 'weur' }) {}
class Vault extends R2.Bucket<Vault>()('vault', { jurisdiction: 'eu', locationHint: 'weur' }) {}
class Notes extends Kv.Namespace<Notes>()('notes', { jurisdiction: 'eu' }) {}
class Audit extends Queue.Make<Audit>()('audit', { schema, jurisdiction: 'eu' }) {}
jurisdiction locationHint
KV namespace eu, us, fedramp
D1 database eu, fedramp wnam, enam, weur, eeur, apac, oc
R2 bucket eu, fedramp wnam, enam, weur, eeur, apac, oc
Queue eu, us, fedramp

Ship makes each one there. Cloudflare can’t move a resource once it’s made, so this is a decision you make up front:

  • A resource found in another jurisdiction than the one declared stops the ship before anything is made.
  • Taking a jurisdiction out of a declaration leaves a namespace, database or queue where it is.
  • An R2 bucket in a jurisdiction is a different bucket from one of the same name outside it. It’s found, bound, emptied and deleted in its jurisdiction, so taking the jurisdiction out stops the ship too, rather than losing track of your files.

This was checked live: an app with each kind in the EU shipped (the database’s primary in western Europe, migrated), ran, and was destroyed, its EU bucket emptied first. Durable Objects have jurisdictions too, on the client rather than the declaration: see Durable Objects.

Sometimes one app owns the data and others use it. In the media example, media owns the bucket uploads, and catalog reads from it. Both import the same class; the owner binds it, the user binds it with .from:

example/media/catalog/indexer.ts
export default Worker.make('indexer', { fetch, queues: [index] }).bind(Index, Uploads.from('media'));

media makes it and deletes it; catalog only binds it, in the same environment as itself. catalog’s plan checks that media has made it there (external-missing otherwise), and media can’t be destroyed while catalog still uses it (resource-still-bound). One made outside forge-effect altogether is bound by its name in the account: Files.external('old-files'). A database another app owns is that app’s to migrate.

Every storage resource has a stand-in, a Layer that provides the same client over memory. Here’s a real test of the visits app, from test/runtime.test.ts:

test/runtime.test.ts
import { Kv, Worker } from '@forge-effect/infra';
import { sqlite } from '@forge-effect/infra/testing';
import { api, Cache, Db } from '../example/app.ts';
const get = (path: string) => new Request(`https://visits.example.dev${path}`);
test('a handler runs with in-memory resources in place of its bindings', async () => {
// KV starts with visits at 41; the database starts with its migrations applied.
const fakes = Layer.mergeAll(Kv.memory(Cache, [['visits', '41']]), sqlite(Db));
const response = await Effect.runPromise(Worker.handle(api, get('/hello')).pipe(Effect.provide(fakes)));
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { visits: 42, logged: 1 });
});
  • Kv.memory(Cache, entries?): a namespace in memory, optionally filled. It refuses the keys Cloudflare refuses (empty, ., .., or over 512 bytes).

  • sqlite(Db, sql?), from @forge-effect/infra/testing: real SQLite (Node’s built-in one, the engine D1 runs on), with your migrations applied, then sql if you pass some, to seed it:

    test/notes.test.ts
    const seeded = sqlite(Db, "INSERT INTO notes (id, body, at) VALUES ('n0', 'hello', '2026-10-17')");

    Each time the Layer is built, it’s a fresh database. Wrap it in a ManagedRuntime, as the first app does, to keep one across requests.

  • sqliteBinding(Db): the same, as a D1 binding, for when you hand a Worker its env yourself, through Worker.serve. (Kv.memoryBinding() and R2.memoryBinding() are the others’ bindings.)

  • R2.memory(Files): one bucket per call. Provide the same Layer to several Workers and they share it, as they would in your account. The media test does exactly that: an upload through media’s API, then catalog reading it from the same bucket.

And when you want the real runtime, infra dev gives every KV namespace, D1 database and R2 bucket a local one, kept on disk in .infra/dev beside your app, with migrations applied (and applied again when you add one). More in Testing and Local dev.

  • Names. A resource’s name in your account is the app’s name, the environment’s (unless it’s production), and the name you gave it: notes-cache, or notes-pr-7-cache. Its binding is that name in capitals, dashes as underscores: cache is CACHE, signups-failed is SIGNUPS_FAILED.
  • Clients. Cache.layer builds the client from the Worker’s binding, once per isolate. You never write that wiring; binding the class does it.
  • Ownership. The first ship makes each resource and records it in the state database as the app’s. Later ships bind KV and D1 by that id. A resource you stop declaring is unbound and kept, until infra prune. More in Environments and state.
  • KV is eventually consistent. That’s Cloudflare’s design: a write can take a while (up to about a minute) to be seen in other locations. Use it for things that can be a little stale; use D1 for things that can’t.
  • The clients cover the common path. Each has binding, the binding itself, for the rest. Its type covers only what the client uses (D1’s batch is there; KV’s metadata and R2’s multipart uploads aren’t typed yet), so beyond that you’re reaching past the types.
  • A bucket can tell a queue when its objects change. That’s in More bindings.
  • Queues: hand work to a consumer, typed by a schema.
  • Durable Objects: state with an address, and its own SQLite.