S3-compatible object storage, accessed via @aws-sdk/client-s3. See the db-files skill for storage patterns.
Why was it chosen?
- Zero egress fees — the primary differentiator over AWS S3.
- S3-compatible API — the same SDK code ports to any S3 provider.
See vendors.md for pricing, free tier limits, and provider alternatives.
Known limitations
- No versioning, object locking, bucket notifications, or replication.
- No KMS encryption (SSE-C only); no Glacier-equivalent cold tier.
Connection
Uses the S3-compatible API via @aws-sdk/client-s3. The endpoint is derived from the account ID — no separate endpoint env var needed.
Environment variables:
| Variable | Purpose |
|---|---|
R2_ACCOUNT_ID |
Cloudflare account ID (endpoint computed: https://{id}.r2.cloudflarestorage.com) |
R2_ACCESS_KEY_ID |
S3 API token access key |
R2_SECRET_ACCESS_KEY |
S3 API token secret |
R2_BUCKET_NAME |
Target bucket name |
Client setup: $lib/server/store/index.ts creates an S3Client with region: 'auto' and endpoint derived from the account ID. Returns null when credentials are missing (graceful degradation via feature flags).
Implementation
The store layer lives at $lib/server/store/ with the same structure as the db and graph layers:
src/lib/server/store/
├── index.ts # S3Client setup, BUCKET export
├── types.ts # ObjectInfo, ObjectDetail, BucketStats, PresignedUrlResult, etc.
├── errors.ts # StoreError class, classifyS3Error()
└── showcase/
├── queries.ts # verifyConnection, listShowcaseObjects, getObjectDetail, generateDownloadUrl, getObjectRange
├── mutations.ts # generateUploadUrl, confirmUpload, deleteShowcaseObject
├── seed.ts # reseedBucket() — 11 seed objects
└── guards.ts # assertShowcaseKey, checkObjectLimit
Error handling: classifyS3Error() normalizes AWS SDK errors into typed StoreError kinds: credentials, not_found, forbidden, timeout, limit, unavailable, unknown.
Safety guards:
- All operations scoped to
showcase/prefix viaassertShowcaseKey() - Max 20 objects in showcase namespace via
checkObjectLimit() - Upload validation: allowlisted MIME types, 2 MB size cap
- UUID-based key generation prevents collisions
Upload flow:
Client Server R2
│ │ │
├── Request upload URL ──▶│ │
│ ├── Validate + presign ─▶│
│◀── Presigned PUT URL ───┤ │
│ │ │
├── PUT file directly ────┼───────────────────────▶│
│ │ │
├── Confirm upload ──────▶│ │
│ ├── HeadObject verify ──▶│
│◀── Upload result ───────┤ │
Content-Type is locked into the presigned URL signature — the client must send the exact MIME type that was validated server-side.
Latency tiers
R2 is always-on — no cold starts like Neon (PostgreSQL) or Aura (Neo4j). Round-trip time reflects only network distance to the R2 edge:
| Tier | Latency | Meaning |
|---|---|---|
| Fast | < 150ms | Low network distance to the R2 edge |
| Slow | 150–500ms | Higher network distance, or listing many objects |
| Degraded | > 500ms | Network issues or R2 service degradation |
Live at /showcases/db/storage/connection.
Related
- postgres.md - Structured data
- neo4j.md - Graph data
- drizzle.md - ORM (PostgreSQL only)
- ../core/podman.md - Local MinIO setup