# R2 object storage — establishment discipline

R2 buckets follow the **same establishment discipline as D1 databases** (`d1-data-capture.md` § Establishing a store): a canonical name derived from the account, one bucket per purpose, and a provision breadcrumb. This reference states those rules for R2; it does not add a new capture mechanism.

Pair this with `api.md` for the token discipline that authenticates every `wrangler` call.

---

## Naming — `<accountId>-<purpose>`

Every bucket is named `<accountId>-<purpose>`, computed with the helper, never hand-built:

```bash
BUCKET="$(bash bin/cf-store-name.sh "<accountId>" "<purpose>")"   # e.g. acc9f2-uploads
```

The helper lowercases each segment, which also satisfies R2's lowercase-only bucket-name rule. The name identifies the bucket's owner and purpose at a glance — the same property that keeps one client's bucket from colliding with another's on a multi-tenant install.

## One bucket per purpose

A purpose gets exactly **one** bucket. Before creating one, list what already exists (`wrangler r2 bucket list`); if a bucket already serves the purpose, **reuse it** rather than creating a second. A new object *kind* within a purpose is a key prefix inside the one bucket, not a second bucket.

## Create (or reuse) the bucket

```bash
BUCKET="$(bash bin/cf-store-name.sh "<accountId>" "<purpose>")"
wrangler r2 bucket list | grep -qx "$BUCKET" \
  || wrangler r2 bucket create "$BUCKET"
```

The token must carry an R2-scoped permission; resolve it the same reuse-a-stable-per-scope-token way `api.md` prescribes. The token is never written into a project tree or echoed into chat.

## Store-provision breadcrumb

When a bucket is created or reused, log the establishment decision so a reuse is visible and a create is attributable to a purpose that genuinely had none:

```
op=storage-provision resource=r2 purpose=<purpose> account=<accountId> name=<canonical-name> action=create|reuse
```

## Bind the bucket in wrangler.toml

The binding name (`BUCKET` below) is the variable the Pages Function reads off `env`. Keep `wrangler.toml` in the site's project root; it carries no secret, only the bucket's name.

```toml
name = "<project>"
pages_build_output_dir = "public"

[[r2_buckets]]
binding = "BUCKET"
bucket_name = "<accountId>-<purpose>"    # from bin/cf-store-name.sh
```

**R2 binds by name and has no `database_id` analogue.** That is the one asymmetry with the `[[d1_databases]]` block in `d1-data-capture.md` § Bind the database, where a `database_id` from `wrangler d1 create` must also be recorded. An R2 bucket needs no id, so there is no second value to carry into the tree.

The deploy-time token needs **no** R2 scope: the binding is declarative config carried in the deploy payload, covered by Pages Write. `wrangler r2 bucket create` **does** need `Workers R2 Storage Write` — the `storage` scope from `bin/cf-token.sh`. Reaching for an R2 token at deploy time is a sign the binding is being confused with the provision.

## The Pages Function

The bound bucket arrives on `env` and exposes `put` / `get` / `delete` / `head`. Keep the handler a thin wrapper over a pure function taking the env injected, so the logic is verifiable without the Pages runtime — the shape `d1-data-capture.md` § The Pages Function prescribes for D1.

```ts
interface R2Bucket {
  put: (key: string, value: ArrayBuffer | Uint8Array) => Promise<unknown>
  get: (key: string) => Promise<{ body: ReadableStream | null; size: number } | null>
  delete: (key: string) => Promise<void>
  head: (key: string) => Promise<{ size: number } | null>
}

export async function storeObject(
  env: { BUCKET: R2Bucket },
  key: string,
  bytes: Uint8Array,
  log: (line: string) => void,
): Promise<boolean> {
  await env.BUCKET.put(key, bytes)
  // Re-read rather than assume: a put that stored nothing emits no error, and
  // an object with no manifest row is invisible to whatever later reads it.
  const present = (await env.BUCKET.head(key)) !== null
  log(`[r2-store] op=put key=${key} present=${present}`)
  return present
}
```

Declaring the interface structurally, rather than importing `@cloudflare/workers-types`, is what lets a test inject a fake bucket. No Workers-specific test tooling ships in this repo and none is needed for a Function shaped this way.
