Skip to content

Storage Architecture ​

How Go Bananas! stores and retrieves data across R2, D1, and KV.

Storage Layers ​

Storage Layers

R2 for binary images, D1 for metadata, KV for fast lookups

R2 Object Storage ​

Purpose ​

Store binary image data with CDN delivery:

  • Full-resolution generated images
  • Compressed thumbnails
  • Product reference images

Key Structure ​

R2 Key Structure

Path format: tenant_id/date/operation-random.format

{tenant_id}/{date}/{operation}-{random}.{format}

Examples:

acme-corp/2024-01-15/generate-a1b2c3d4.png
acme-corp/2024-01-15/generate-a1b2c3d4-thumb.jpg
acme-corp/2024-01-15/edit-e5f6g7h8.png
acme-corp/2024-01-10/product-i9j0k1l2.jpg

Upload Pattern ​

typescript
interface ImageUpload {
  tenantId: string;
  operation: 'generate' | 'edit' | 'product';
  imageBuffer: ArrayBuffer;
  format: 'png' | 'jpeg' | 'webp';
}

async function uploadImage(env: Env, upload: ImageUpload) {
  const date = new Date().toISOString().split('T')[0];
  const randomId = crypto.randomUUID().slice(0, 8);
  const key = `${upload.tenantId}/${date}/${upload.operation}-${randomId}.${upload.format}`;

  await env.R2_IMAGES.put(key, upload.imageBuffer, {
    httpMetadata: {
      contentType: `image/${upload.format}`,
    },
    customMetadata: {
      tenantId: upload.tenantId,
      operation: upload.operation,
    },
  });

  return {
    r2Key: key,
    publicUrl: `${env.R2_PUBLIC_URL}/${key}`,
  };
}

Retrieval Pattern ​

typescript
async function downloadImage(env: Env, r2Key: string): Promise<ArrayBuffer> {
  const object = await env.R2_IMAGES.get(r2Key);

  if (!object) {
    throw new Error('Image not found');
  }

  return object.arrayBuffer();
}

Public URL Configuration ​

R2 buckets expose public URLs via:

  • Custom domain: https://images.yourdomain.com/{key}
  • R2 public URL: https://pub-{id}.r2.dev/{key}

Set in wrangler.jsonc:

json
{
  "vars": {
    "R2_PUBLIC_URL": "https://pub-xxx.r2.dev"
  }
}

D1 Database ​

Purpose ​

Store relational metadata with fast queries:

  • Image metadata and relationships
  • Tenant and user data
  • Session state
  • Character and product references
  • Usage analytics

Schema Overview ​

D1 Schema Overview

ER diagram: tenants, images, sessions, characters, and more

Key Tables ​

tenants ​

sql
CREATE TABLE tenants (
    tenant_id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    encrypted_gemini_key BLOB,           -- legacy column, kept for back-compat
    iv BLOB,                              -- legacy IV
    allowed_models TEXT,                  -- JSON array, e.g. ["gemini-flash-lite-image","openai-gpt-image-2"]
    default_model_id TEXT DEFAULT 'gemini-flash-lite-image',
    monthly_quota_mb INTEGER DEFAULT 1024,
    rate_limit_per_minute INTEGER DEFAULT 60,
    is_active BOOLEAN DEFAULT 1,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
);

tenant_provider_credentials (migration 0034) ​

Per-provider keys, encrypted independently. A tenant can have a Gemini key, an OpenAI key, both, or neither — allowed_models only includes providers whose key is present and active.

sql
CREATE TABLE tenant_provider_credentials (
    tenant_id TEXT NOT NULL,
    provider TEXT NOT NULL,               -- 'gemini' or 'openai'
    encrypted_key BLOB NOT NULL,
    iv BLOB NOT NULL,
    is_active BOOLEAN DEFAULT 1,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (tenant_id, provider),
    FOREIGN KEY (tenant_id) REFERENCES tenants(tenant_id) ON DELETE CASCADE
);

images ​

sql
CREATE TABLE images (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    tenant_id TEXT NOT NULL,
    session_id TEXT,
    r2_key TEXT NOT NULL,
    r2_thumbnail_key TEXT,
    public_url TEXT NOT NULL,
    thumbnail_url TEXT,
    width INTEGER,
    height INTEGER,
    size_bytes INTEGER,
    format TEXT,
    prompt TEXT,
    negative_prompt TEXT,
    operation_type TEXT DEFAULT 'generate',
    parent_image_id INTEGER,
    edit_depth INTEGER DEFAULT 0,
    edit_prompt TEXT,
    model_id TEXT,
    aspect_ratio_hint TEXT,
    has_synthid BOOLEAN DEFAULT 0,
    gemini_file_id TEXT,
    gemini_file_expires_at TEXT,
    style_preset_id INTEGER,
    character_id INTEGER,
    product_reference_id INTEGER,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,

    FOREIGN KEY (tenant_id) REFERENCES tenants(tenant_id) ON DELETE CASCADE,
    FOREIGN KEY (parent_image_id) REFERENCES images(id),
    FOREIGN KEY (style_preset_id) REFERENCES style_presets(id),
    FOREIGN KEY (character_id) REFERENCES characters(id),
    FOREIGN KEY (product_reference_id) REFERENCES product_references(id)
);

sessions ​

sql
CREATE TABLE sessions (
    session_id TEXT NOT NULL,
    tenant_id TEXT NOT NULL,
    last_image_id INTEGER,
    total_images INTEGER DEFAULT 0,
    total_edits INTEGER DEFAULT 0,
    is_active BOOLEAN DEFAULT 1,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
    last_activity_at TEXT DEFAULT CURRENT_TIMESTAMP,

    PRIMARY KEY (session_id, tenant_id),
    FOREIGN KEY (tenant_id) REFERENCES tenants(tenant_id) ON DELETE CASCADE,
    FOREIGN KEY (last_image_id) REFERENCES images(id)
);

Index Strategy ​

sql
-- Primary access patterns
CREATE INDEX idx_images_tenant ON images(tenant_id);
CREATE INDEX idx_images_session ON images(session_id);
CREATE INDEX idx_images_created ON images(created_at DESC);
CREATE INDEX idx_images_parent ON images(parent_image_id);

-- Search patterns
CREATE INDEX idx_images_prompt ON images(prompt);
CREATE INDEX idx_images_operation ON images(operation_type);

-- Reference lookups
CREATE INDEX idx_images_character ON images(character_id);
CREATE INDEX idx_images_product ON images(product_reference_id);
CREATE INDEX idx_images_preset ON images(style_preset_id);

-- Usage analytics
CREATE INDEX idx_usage_tenant_time ON usage_logs(tenant_id, timestamp);
CREATE INDEX idx_usage_operation ON usage_logs(operation);

Query Patterns ​

List Images with Filters ​

sql
SELECT
    id, r2_key, public_url, thumbnail_url,
    width, height, prompt, operation_type,
    created_at
FROM images
WHERE tenant_id = ?
  AND (? IS NULL OR prompt LIKE '%' || ? || '%')
  AND (? IS NULL OR operation_type = ?)
  AND (? IS NULL OR created_at >= ?)
ORDER BY created_at DESC
LIMIT ? OFFSET ?;

Session History ​

sql
SELECT
    id, prompt, operation_type, edit_depth,
    parent_image_id, created_at
FROM images
WHERE tenant_id = ? AND session_id = ?
ORDER BY created_at DESC
LIMIT ?;

Usage Aggregation ​

sql
SELECT
    strftime('%Y-%m-%d', timestamp) as bucket,
    COUNT(*) as operations,
    SUM(images_generated) as images,
    SUM(total_size_bytes) as bytes
FROM usage_logs
WHERE tenant_id = ?
  AND timestamp >= datetime('now', '-30 days')
GROUP BY bucket
ORDER BY bucket DESC;

KV Store ​

Purpose ​

Fast key-value lookups for:

  • API key to tenant mappings
  • Tenant configuration cache
  • Rate limit counters

Namespaces ​

NamespacePurposeTTL
API_KEYSsha256:<hex> of the key → key metadata (tenant, active flag, usage)—
TENANT_CONFIGTenant config cache5 min

Access Patterns ​

API Key Lookup ​

typescript
// Keys are stored only as SHA-256 hashes (src/auth/api-key-storage.ts)
async function lookupApiKey(apiKey: string, env: Env): Promise<string | null> {
  // KV is keyed by "sha256:<hex>", never by the key itself
  const metadata = await env.API_KEYS.get(await apiKeyKvName(apiKey), 'json');
  if (metadata) return metadata.isActive ? metadata.tenantId : null;

  // D1 stores "sha256:<hex>:<preview>"; the plain value only matches pre-hashing keys
  const record = await env.DB.prepare(
    'SELECT tenant_id FROM api_keys WHERE api_key IN (?, ?) AND is_active = 1'
  ).bind(...(await apiKeyLookupValues(apiKey))).first();

  return record?.tenant_id ?? null;
}

Tenant Config Cache ​

typescript
async function getTenantConfig(tenantId: string, env: Env): Promise<Tenant> {
  const cacheKey = `tenant:${tenantId}`;

  // Check cache
  const cached = await env.TENANT_CONFIG.get(cacheKey, 'json');
  if (cached) return cached as Tenant;

  // Load from D1
  const tenant = await env.DB.prepare(
    'SELECT * FROM tenants WHERE tenant_id = ?'
  ).bind(tenantId).first();

  // Cache (excluding encrypted key)
  const cacheable = {
    tenantId: tenant.tenant_id,
    name: tenant.name,
    monthlyQuotaMb: tenant.monthly_quota_mb,
    rateLimitPerMinute: tenant.rate_limit_per_minute,
  };

  await env.TENANT_CONFIG.put(cacheKey, JSON.stringify(cacheable), {
    expirationTtl: 300
  });

  return tenant;
}

Storage Cost Optimization ​

R2 Strategies ​

  1. Thumbnail compression: 85% quality JPEG
  2. Format optimization: Use WebP where supported
  3. Deduplication: Hash-based duplicate detection (future)

D1 Strategies ​

  1. Selective columns: Only fetch needed fields
  2. Pagination: Always use LIMIT/OFFSET
  3. Index usage: Query by indexed columns

KV Strategies ​

  1. Short TTL: 5 minutes for most caches
  2. Lazy invalidation: Update on write
  3. Selective caching: Only hot data

Data Lifecycle ​

Data Lifecycle

Created → Active → Archived → Deleted cleanup flow

Backup and Recovery ​

D1 Backup ​

bash
# Export database
wrangler d1 export DB --output backup.sql

# Import to restore
wrangler d1 execute DB --file backup.sql

R2 Considerations ​

  • R2 provides 99.999999999% durability
  • Consider cross-region replication for critical data
  • Use lifecycle rules for automatic cleanup

Next Steps ​

Released under the MIT License.