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:
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.
KV: small values, read everywhere
Section titled “KV: small values, read everywhere”Here’s the visits counter from
example/app.ts.
It reads a number, adds one and writes it back:
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:
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:
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.
D1: rows and SQL
Section titled “D1: rows and SQL”A D1 database is SQLite, run by Cloudflare. Its client has three methods you’ll use all the time:
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');allgives every row,firstthe first as anOption, andrunruns a statement and says how many rows itchanges.- Values go in with
?placeholders, never pasted into the SQL. - Each fails with a
D1Error, carrying the SQL that failed. execruns SQL with no values, andbindingis the D1 binding itself, for what the client doesn’t cover, such asbatch.
Your migrations are your schema
Section titled “Your migrations are your schema”You don’t create tables by hand. A database declares its migrations, in order, and that list is its schema:
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: nowhereinfra 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-pendingThe one rule: only append
Section titled “The one rule: only append”To change the schema, add a migration to the end. Never edit one that’s
shipped. Say you want a pinned column:
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.
R2: files
Section titled “R2: files”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:
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:
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 typeconst listed = yield* files.list('notes/'); // every object's metadata, across pagesyield* files.delete('notes/a.txt', 'notes/b.txt');getgives the object with its body, which you can read once (it’s a stream).textreads it for you.puttakes a string, bytes, aBlobor 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.deletetakes any number of keys. A key with no object is no error.- Everything fails with an
R2Error.bindingis 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' }).
Buckets and teardown
Section titled “Buckets and teardown”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.
Where your data lives
Section titled “Where your data lives”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:
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.
Using another app’s storage
Section titled “Using another app’s storage”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:
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.
Testing without Cloudflare
Section titled “Testing without Cloudflare”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:
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, thensqlif 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 itsenvyourself, throughWorker.serve. (Kv.memoryBinding()andR2.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 throughmedia’s API, thencatalogreading 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.
How it works, briefly
Section titled “How it works, briefly”- 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, ornotes-pr-7-cache. Its binding is that name in capitals, dashes as underscores:cacheisCACHE,signups-failedisSIGNUPS_FAILED. - Clients.
Cache.layerbuilds 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.
Good to know
Section titled “Good to know”- 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’sbatchis 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.