Compare commits
3 Commits
a7dc96d1a5
...
047c924193
| Author | SHA1 | Date |
|---|---|---|
|
|
047c924193 | |
|
|
dbf82d2801 | |
|
|
e88617b430 |
|
|
@ -0,0 +1,607 @@
|
||||||
|
# Banatie Database Design
|
||||||
|
|
||||||
|
## 📊 Database Schema for AI Image Generation System
|
||||||
|
|
||||||
|
This document describes the complete database structure for Banatie - an AI-powered image generation service with support for named references, flows, and prompt URL caching.
|
||||||
|
|
||||||
|
**Version:** 2.0
|
||||||
|
**Last Updated:** 2025-10-26
|
||||||
|
**Status:** Approved for Implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architecture Overview
|
||||||
|
|
||||||
|
### Core Principles
|
||||||
|
|
||||||
|
1. **Dual Alias System**: Project-level (global) and Flow-level (temporary) scopes
|
||||||
|
2. **Technical Aliases Computed**: `@last`, `@first`, `@upload` are calculated programmatically
|
||||||
|
3. **Audit Trail**: Complete history of all generations with performance metrics
|
||||||
|
4. **Referential Integrity**: Proper foreign keys and cascade rules
|
||||||
|
5. **Simplicity First**: Minimal tables, JSONB for flexibility
|
||||||
|
|
||||||
|
### Scope Resolution Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Flow-scoped aliases (@hero in flow) → Project-scoped aliases (@logo global) → Technical aliases (@last, @first)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Existing Tables (Unchanged)
|
||||||
|
|
||||||
|
### 1. ORGANIZATIONS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
organizations {
|
||||||
|
id: UUID (PK)
|
||||||
|
name: TEXT
|
||||||
|
slug: TEXT UNIQUE
|
||||||
|
email: TEXT UNIQUE
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
updated_at: TIMESTAMP
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Top-level entity for multi-tenant system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. PROJECTS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
projects {
|
||||||
|
id: UUID (PK)
|
||||||
|
organization_id: UUID (FK -> organizations) CASCADE
|
||||||
|
name: TEXT
|
||||||
|
slug: TEXT
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
updated_at: TIMESTAMP
|
||||||
|
|
||||||
|
UNIQUE INDEX(organization_id, slug)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Container for all project-specific data (images, generations, flows)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. API_KEYS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
api_keys {
|
||||||
|
id: UUID (PK)
|
||||||
|
key_hash: TEXT UNIQUE
|
||||||
|
key_prefix: TEXT DEFAULT 'bnt_'
|
||||||
|
key_type: ENUM('master', 'project')
|
||||||
|
|
||||||
|
organization_id: UUID (FK -> organizations) CASCADE
|
||||||
|
project_id: UUID (FK -> projects) CASCADE
|
||||||
|
|
||||||
|
scopes: JSONB DEFAULT ['generate']
|
||||||
|
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
expires_at: TIMESTAMP
|
||||||
|
last_used_at: TIMESTAMP
|
||||||
|
is_active: BOOLEAN DEFAULT true
|
||||||
|
|
||||||
|
name: TEXT
|
||||||
|
created_by: UUID
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Authentication and authorization for API access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆕 New Tables
|
||||||
|
|
||||||
|
### 4. FLOWS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
flows {
|
||||||
|
id: UUID (PK)
|
||||||
|
project_id: UUID (FK -> projects) CASCADE
|
||||||
|
|
||||||
|
// Flow-scoped named aliases (user-assigned only)
|
||||||
|
// Technical aliases (@last, @first, @upload) computed programmatically
|
||||||
|
// Format: { "@hero": "image-uuid", "@product": "image-uuid" }
|
||||||
|
aliases: JSONB DEFAULT {}
|
||||||
|
|
||||||
|
meta: JSONB DEFAULT {}
|
||||||
|
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
// Updates on every generation/upload activity within this flow
|
||||||
|
updated_at: TIMESTAMP
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Temporary chains of generations with flow-scoped references
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
- No `status` field - computed from generations
|
||||||
|
- No `name`/`description` - flows are programmatic, not user-facing
|
||||||
|
- No `expires_at` - cleanup handled programmatically via `created_at`
|
||||||
|
- `aliases` stores only user-assigned aliases, not technical ones
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_flows_project ON flows(project_id, created_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. IMAGES
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
images {
|
||||||
|
id: UUID (PK)
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
project_id: UUID (FK -> projects) CASCADE
|
||||||
|
generation_id: UUID (FK -> generations) SET NULL
|
||||||
|
flow_id: UUID (FK -> flows) CASCADE
|
||||||
|
api_key_id: UUID (FK -> api_keys) SET NULL
|
||||||
|
|
||||||
|
// Storage (MinIO path format: orgSlug/projectSlug/category/YYYY-MM/filename.ext)
|
||||||
|
storage_key: VARCHAR(500) UNIQUE
|
||||||
|
storage_url: TEXT
|
||||||
|
|
||||||
|
// File metadata
|
||||||
|
mime_type: VARCHAR(100)
|
||||||
|
file_size: INTEGER
|
||||||
|
file_hash: VARCHAR(64) // SHA-256 for deduplication
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
width: INTEGER
|
||||||
|
height: INTEGER
|
||||||
|
aspect_ratio: VARCHAR(10)
|
||||||
|
|
||||||
|
// Focal point for image transformations (imageflow)
|
||||||
|
// Normalized coordinates: { "x": 0.5, "y": 0.3 } where 0.0-1.0
|
||||||
|
focal_point: JSONB
|
||||||
|
|
||||||
|
// Source
|
||||||
|
source: ENUM('generated', 'uploaded')
|
||||||
|
|
||||||
|
// Project-level alias (global scope)
|
||||||
|
// Flow-level aliases stored in flows.aliases
|
||||||
|
alias: VARCHAR(100) // @product, @logo
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
description: TEXT
|
||||||
|
tags: TEXT[]
|
||||||
|
meta: JSONB DEFAULT {}
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
updated_at: TIMESTAMP
|
||||||
|
deleted_at: TIMESTAMP // Soft delete
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Centralized storage for all images (uploaded + generated)
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
- `flow_id` enables flow-scoped uploads
|
||||||
|
- `alias` is for project-scope only (global across project)
|
||||||
|
- Flow-scoped aliases stored in `flows.aliases` table
|
||||||
|
- `focal_point` for imageflow server integration
|
||||||
|
- `api_key_id` for audit trail of who created the image
|
||||||
|
- Soft delete via `deleted_at` for recovery
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
```sql
|
||||||
|
CHECK (source = 'uploaded' AND generation_id IS NULL)
|
||||||
|
OR (source = 'generated' AND generation_id IS NOT NULL)
|
||||||
|
|
||||||
|
CHECK alias IS NULL OR alias ~ '^@[a-zA-Z0-9_-]+$'
|
||||||
|
|
||||||
|
CHECK file_size > 0
|
||||||
|
|
||||||
|
CHECK (width IS NULL OR (width > 0 AND width <= 8192))
|
||||||
|
AND (height IS NULL OR (height > 0 AND height <= 8192))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX idx_images_project_alias
|
||||||
|
ON images(project_id, alias)
|
||||||
|
WHERE alias IS NOT NULL AND deleted_at IS NULL AND flow_id IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_images_project_source
|
||||||
|
ON images(project_id, source, created_at DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_images_flow ON images(flow_id) WHERE flow_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_images_generation ON images(generation_id);
|
||||||
|
CREATE INDEX idx_images_storage_key ON images(storage_key);
|
||||||
|
CREATE INDEX idx_images_hash ON images(file_hash);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. GENERATIONS
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
generations {
|
||||||
|
id: UUID (PK)
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
project_id: UUID (FK -> projects) CASCADE
|
||||||
|
flow_id: UUID (FK -> flows) SET NULL
|
||||||
|
api_key_id: UUID (FK -> api_keys) SET NULL
|
||||||
|
|
||||||
|
// Status
|
||||||
|
status: ENUM('pending', 'processing', 'success', 'failed') DEFAULT 'pending'
|
||||||
|
|
||||||
|
// Prompts
|
||||||
|
original_prompt: TEXT
|
||||||
|
enhanced_prompt: TEXT // AI-enhanced version (if enabled)
|
||||||
|
|
||||||
|
// Generation parameters
|
||||||
|
aspect_ratio: VARCHAR(10)
|
||||||
|
width: INTEGER
|
||||||
|
height: INTEGER
|
||||||
|
|
||||||
|
// AI Model
|
||||||
|
model_name: VARCHAR(100) DEFAULT 'gemini-flash-image-001'
|
||||||
|
model_version: VARCHAR(50)
|
||||||
|
|
||||||
|
// Result
|
||||||
|
output_image_id: UUID (FK -> images) SET NULL
|
||||||
|
|
||||||
|
// Referenced images used in generation
|
||||||
|
// Format: [{ "imageId": "uuid", "alias": "@product" }, ...]
|
||||||
|
referenced_images: JSONB
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
error_message: TEXT
|
||||||
|
error_code: VARCHAR(50)
|
||||||
|
retry_count: INTEGER DEFAULT 0
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
processing_time_ms: INTEGER
|
||||||
|
cost: INTEGER // In cents (USD)
|
||||||
|
|
||||||
|
// Request context
|
||||||
|
request_id: UUID // For log correlation
|
||||||
|
user_agent: TEXT
|
||||||
|
ip_address: INET
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
meta: JSONB DEFAULT {}
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
updated_at: TIMESTAMP
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Complete audit trail of all image generations
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
- `referenced_images` as JSONB instead of M:N table (simpler, sufficient for reference info)
|
||||||
|
- No `parent_generation_id` - not needed for MVP
|
||||||
|
- No `final_prompt` - redundant with `enhanced_prompt` or `original_prompt`
|
||||||
|
- No `completed_at` - use `updated_at` when `status` changes to success/failed
|
||||||
|
- `api_key_id` for audit trail of who made the request
|
||||||
|
- Technical aliases resolved programmatically, not stored
|
||||||
|
|
||||||
|
**Referenced Images Format:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "imageId": "uuid-1", "alias": "@product" },
|
||||||
|
{ "imageId": "uuid-2", "alias": "@style" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
```sql
|
||||||
|
CHECK (status = 'success' AND output_image_id IS NOT NULL)
|
||||||
|
OR (status != 'success')
|
||||||
|
|
||||||
|
CHECK (status = 'failed' AND error_message IS NOT NULL)
|
||||||
|
OR (status != 'failed')
|
||||||
|
|
||||||
|
CHECK retry_count >= 0
|
||||||
|
|
||||||
|
CHECK processing_time_ms IS NULL OR processing_time_ms >= 0
|
||||||
|
|
||||||
|
CHECK cost IS NULL OR cost >= 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_generations_project_status
|
||||||
|
ON generations(project_id, status, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX idx_generations_flow
|
||||||
|
ON generations(flow_id, created_at DESC)
|
||||||
|
WHERE flow_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_generations_output ON generations(output_image_id);
|
||||||
|
CREATE INDEX idx_generations_request ON generations(request_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. PROMPT_URL_CACHE
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
prompt_url_cache {
|
||||||
|
id: UUID (PK)
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
project_id: UUID (FK -> projects) CASCADE
|
||||||
|
generation_id: UUID (FK -> generations) CASCADE
|
||||||
|
image_id: UUID (FK -> images) CASCADE
|
||||||
|
|
||||||
|
// Cache keys (SHA-256 hashes)
|
||||||
|
prompt_hash: VARCHAR(64)
|
||||||
|
query_params_hash: VARCHAR(64)
|
||||||
|
|
||||||
|
// Original request (for debugging/reconstruction)
|
||||||
|
original_prompt: TEXT
|
||||||
|
request_params: JSONB // { width, height, aspectRatio, template, ... }
|
||||||
|
|
||||||
|
// Cache statistics
|
||||||
|
hit_count: INTEGER DEFAULT 0
|
||||||
|
last_hit_at: TIMESTAMP
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
created_at: TIMESTAMP
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose:** Deduplication and caching for Prompt URL feature
|
||||||
|
|
||||||
|
**Key Design Decisions:**
|
||||||
|
- Composite unique key: `project_id + prompt_hash + query_params_hash`
|
||||||
|
- No `expires_at` - cache lives forever unless manually cleared
|
||||||
|
- Tracks `hit_count` for analytics
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
```sql
|
||||||
|
CHECK hit_count >= 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Indexes:**
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX idx_cache_key
|
||||||
|
ON prompt_url_cache(project_id, prompt_hash, query_params_hash);
|
||||||
|
|
||||||
|
CREATE INDEX idx_cache_generation ON prompt_url_cache(generation_id);
|
||||||
|
CREATE INDEX idx_cache_image ON prompt_url_cache(image_id);
|
||||||
|
CREATE INDEX idx_cache_hits
|
||||||
|
ON prompt_url_cache(project_id, hit_count DESC, created_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Relationships Summary
|
||||||
|
|
||||||
|
### One-to-Many (1:M)
|
||||||
|
|
||||||
|
1. **organizations → projects** (CASCADE)
|
||||||
|
2. **organizations → api_keys** (CASCADE)
|
||||||
|
3. **projects → api_keys** (CASCADE)
|
||||||
|
4. **projects → flows** (CASCADE)
|
||||||
|
5. **projects → images** (CASCADE)
|
||||||
|
6. **projects → generations** (CASCADE)
|
||||||
|
7. **projects → prompt_url_cache** (CASCADE)
|
||||||
|
8. **flows → images** (CASCADE)
|
||||||
|
9. **flows → generations** (SET NULL)
|
||||||
|
10. **generations → images** (SET NULL) - output image
|
||||||
|
11. **api_keys → images** (SET NULL) - who created
|
||||||
|
12. **api_keys → generations** (SET NULL) - who requested
|
||||||
|
|
||||||
|
### Cascade Rules
|
||||||
|
|
||||||
|
**ON DELETE CASCADE:**
|
||||||
|
- Deleting organization → deletes all projects, api_keys
|
||||||
|
- Deleting project → deletes all flows, images, generations, cache
|
||||||
|
- Deleting flow → deletes all flow-scoped images
|
||||||
|
- Deleting generation → nothing (orphaned references OK)
|
||||||
|
|
||||||
|
**ON DELETE SET NULL:**
|
||||||
|
- Deleting generation → sets `images.generation_id` to NULL
|
||||||
|
- Deleting image → sets `generations.output_image_id` to NULL
|
||||||
|
- Deleting flow → sets `generations.flow_id` to NULL
|
||||||
|
- Deleting api_key → sets audit references to NULL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Alias System
|
||||||
|
|
||||||
|
### Two-Tier Alias Scope
|
||||||
|
|
||||||
|
#### Project-Scoped (Global)
|
||||||
|
- **Storage:** `images.alias` column
|
||||||
|
- **Lifetime:** Permanent (until image deleted)
|
||||||
|
- **Visibility:** Across entire project
|
||||||
|
- **Examples:** `@logo`, `@brand`, `@header`
|
||||||
|
- **Use Case:** Reusable brand assets
|
||||||
|
|
||||||
|
#### Flow-Scoped (Temporary)
|
||||||
|
- **Storage:** `flows.aliases` JSONB
|
||||||
|
- **Lifetime:** Duration of flow
|
||||||
|
- **Visibility:** Only within specific flow
|
||||||
|
- **Examples:** `@hero`, `@product`, `@variant`
|
||||||
|
- **Use Case:** Conversational generation chains
|
||||||
|
|
||||||
|
#### Technical Aliases (Computed)
|
||||||
|
- **Storage:** None (computed on-the-fly)
|
||||||
|
- **Types:**
|
||||||
|
- `@last` - Last generation in flow (any status)
|
||||||
|
- `@first` - First generation in flow
|
||||||
|
- `@upload` - Last uploaded image in flow
|
||||||
|
- **Implementation:** Query-based resolution
|
||||||
|
|
||||||
|
### Resolution Algorithm
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Check if technical alias (@last, @first, @upload) → compute from flow data
|
||||||
|
2. Check flow.aliases for flow-scoped alias → return if found
|
||||||
|
3. Check images.alias for project-scoped alias → return if found
|
||||||
|
4. Return null (alias not found)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Dual Alias Assignment
|
||||||
|
|
||||||
|
### Uploads
|
||||||
|
```typescript
|
||||||
|
POST /api/images/upload
|
||||||
|
{
|
||||||
|
file: <binary>,
|
||||||
|
alias: "@product", // Project-scoped (optional)
|
||||||
|
flowAlias: "@hero", // Flow-scoped (optional)
|
||||||
|
flowId: "uuid" // Required if flowAlias provided
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
- If `alias` provided → set `images.alias = "@product"`
|
||||||
|
- If `flowAlias` provided → add to `flows.aliases["@hero"] = imageId`
|
||||||
|
- Can have both simultaneously
|
||||||
|
|
||||||
|
### Generations
|
||||||
|
```typescript
|
||||||
|
POST /api/generations
|
||||||
|
{
|
||||||
|
prompt: "hero image",
|
||||||
|
assignAlias: "@brand", // Project-scoped (optional)
|
||||||
|
assignFlowAlias: "@hero", // Flow-scoped (optional)
|
||||||
|
flowId: "uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result (after successful generation):**
|
||||||
|
- If `assignAlias` → set `images.alias = "@brand"` on output image
|
||||||
|
- If `assignFlowAlias` → add to `flows.aliases["@hero"] = outputImageId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Performance Optimizations
|
||||||
|
|
||||||
|
### Critical Indexes
|
||||||
|
|
||||||
|
All indexes listed in individual table sections above. Key performance considerations:
|
||||||
|
|
||||||
|
1. **Alias Lookup:** Partial index on `images(project_id, alias)` WHERE conditions
|
||||||
|
2. **Flow Activity:** Composite index on `generations(flow_id, created_at)`
|
||||||
|
3. **Cache Hit:** Unique composite on `prompt_url_cache(project_id, prompt_hash, query_params_hash)`
|
||||||
|
4. **Audit Queries:** Indexes on `api_key_id` columns
|
||||||
|
|
||||||
|
### Denormalization
|
||||||
|
|
||||||
|
**Avoided intentionally:**
|
||||||
|
- No counters (image_count, generation_count)
|
||||||
|
- Computed via COUNT(*) queries with proper indexes
|
||||||
|
- Simpler, more reliable, less trigger overhead
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧹 Data Lifecycle
|
||||||
|
|
||||||
|
### Soft Delete
|
||||||
|
|
||||||
|
**Tables with soft delete:**
|
||||||
|
- `images` - via `deleted_at` column
|
||||||
|
|
||||||
|
**Cleanup strategy:**
|
||||||
|
- Hard delete after 30 days of soft delete
|
||||||
|
- Implemented via cron job or manual cleanup script
|
||||||
|
|
||||||
|
### Hard Delete
|
||||||
|
|
||||||
|
**Tables with hard delete:**
|
||||||
|
- `generations` - cascade deletes
|
||||||
|
- `flows` - cascade deletes
|
||||||
|
- `prompt_url_cache` - cascade deletes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Security & Audit
|
||||||
|
|
||||||
|
### API Key Tracking
|
||||||
|
|
||||||
|
All mutations tracked via `api_key_id`:
|
||||||
|
- `images.api_key_id` - who uploaded/generated
|
||||||
|
- `generations.api_key_id` - who requested generation
|
||||||
|
|
||||||
|
### Request Correlation
|
||||||
|
|
||||||
|
- `generations.request_id` - correlate with application logs
|
||||||
|
- `generations.user_agent` - client identification
|
||||||
|
- `generations.ip_address` - rate limiting, abuse prevention
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Migration Strategy
|
||||||
|
|
||||||
|
### Phase 1: Core Tables
|
||||||
|
1. Create `flows` table
|
||||||
|
2. Create `images` table
|
||||||
|
3. Create `generations` table
|
||||||
|
4. Add all indexes and constraints
|
||||||
|
5. Migrate existing MinIO data to `images` table
|
||||||
|
|
||||||
|
### Phase 2: Advanced Features
|
||||||
|
1. Create `prompt_url_cache` table
|
||||||
|
2. Add indexes
|
||||||
|
3. Implement cache warming for existing data (optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Design Decisions Log
|
||||||
|
|
||||||
|
### Why JSONB for `flows.aliases`?
|
||||||
|
- Simple key-value structure
|
||||||
|
- No need for JOINs
|
||||||
|
- Flexible schema
|
||||||
|
- Atomic updates
|
||||||
|
- Trade-off: No referential integrity (acceptable for temporary data)
|
||||||
|
|
||||||
|
### Why JSONB for `generations.referenced_images`?
|
||||||
|
- Reference info is append-only
|
||||||
|
- No need for complex queries on references
|
||||||
|
- Simpler schema (one less table)
|
||||||
|
- Trade-off: No CASCADE on image deletion (acceptable)
|
||||||
|
|
||||||
|
### Why no `namespaces`?
|
||||||
|
- Adds complexity without clear benefit for MVP
|
||||||
|
- Flow-scoped + project-scoped aliases sufficient
|
||||||
|
- Can add later if needed
|
||||||
|
|
||||||
|
### Why no `generation_groups`?
|
||||||
|
- Not needed for core functionality
|
||||||
|
- Grouping can be done via tags or meta JSONB
|
||||||
|
- Can add later if analytics requires it
|
||||||
|
|
||||||
|
### Why `focal_point` as JSONB?
|
||||||
|
- Imageflow server expects normalized coordinates
|
||||||
|
- Format: `{ "x": 0.0-1.0, "y": 0.0-1.0 }`
|
||||||
|
- JSONB allows future extension (e.g., multiple focal points)
|
||||||
|
|
||||||
|
### Why track `api_key_id` in images/generations?
|
||||||
|
- Essential for audit trail
|
||||||
|
- Cost attribution per key
|
||||||
|
- Usage analytics
|
||||||
|
- Abuse detection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 References
|
||||||
|
|
||||||
|
- **Imageflow Focal Points:** https://docs.imageflow.io/querystring/focal-point
|
||||||
|
- **Drizzle ORM:** https://orm.drizzle.team/
|
||||||
|
- **PostgreSQL JSONB:** https://www.postgresql.org/docs/current/datatype-json.html
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document Version: 2.0*
|
||||||
|
*Last Updated: 2025-10-26*
|
||||||
|
*Status: Ready for Implementation*
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { pgTable, uuid, jsonb, timestamp, index } from 'drizzle-orm/pg-core';
|
||||||
|
import { projects } from './projects';
|
||||||
|
|
||||||
|
export const flows = pgTable(
|
||||||
|
'flows',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
projectId: uuid('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
// Flow-scoped named aliases (user-assigned only)
|
||||||
|
// Technical aliases (@last, @first, @upload) computed programmatically
|
||||||
|
// Format: { "@hero": "image-uuid", "@product": "image-uuid" }
|
||||||
|
aliases: jsonb('aliases').$type<Record<string, string>>().notNull().default({}),
|
||||||
|
|
||||||
|
// Flexible metadata storage
|
||||||
|
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
// Updates on every generation/upload activity within this flow
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
// Index for querying flows by project, ordered by most recent
|
||||||
|
projectCreatedAtIdx: index('idx_flows_project').on(table.projectId, table.createdAt.desc()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Flow = typeof flows.$inferSelect;
|
||||||
|
export type NewFlow = typeof flows.$inferInsert;
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
timestamp,
|
||||||
|
pgEnum,
|
||||||
|
index,
|
||||||
|
check,
|
||||||
|
type AnyPgColumn,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { projects } from './projects';
|
||||||
|
import { flows } from './flows';
|
||||||
|
import { apiKeys } from './apiKeys';
|
||||||
|
|
||||||
|
// Enum for generation status
|
||||||
|
export const generationStatusEnum = pgEnum('generation_status', [
|
||||||
|
'pending',
|
||||||
|
'processing',
|
||||||
|
'success',
|
||||||
|
'failed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Type for referenced images JSONB
|
||||||
|
export type ReferencedImage = {
|
||||||
|
imageId: string;
|
||||||
|
alias: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generations = pgTable(
|
||||||
|
'generations',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
projectId: uuid('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
|
flowId: uuid('flow_id').references(() => flows.id, { onDelete: 'set null' }),
|
||||||
|
apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }),
|
||||||
|
|
||||||
|
// Status
|
||||||
|
status: generationStatusEnum('status').notNull().default('pending'),
|
||||||
|
|
||||||
|
// Prompts
|
||||||
|
originalPrompt: text('original_prompt').notNull(),
|
||||||
|
enhancedPrompt: text('enhanced_prompt'), // AI-enhanced version (if enabled)
|
||||||
|
|
||||||
|
// Generation parameters
|
||||||
|
aspectRatio: varchar('aspect_ratio', { length: 10 }),
|
||||||
|
width: integer('width'),
|
||||||
|
height: integer('height'),
|
||||||
|
|
||||||
|
// AI Model
|
||||||
|
modelName: varchar('model_name', { length: 100 }).notNull().default('gemini-flash-image-001'),
|
||||||
|
modelVersion: varchar('model_version', { length: 50 }),
|
||||||
|
|
||||||
|
// Result
|
||||||
|
outputImageId: uuid('output_image_id').references(
|
||||||
|
(): AnyPgColumn => {
|
||||||
|
const { images } = require('./images');
|
||||||
|
return images.id;
|
||||||
|
},
|
||||||
|
{ onDelete: 'set null' },
|
||||||
|
),
|
||||||
|
|
||||||
|
// Referenced images used in generation
|
||||||
|
// Format: [{ "imageId": "uuid", "alias": "@product" }, ...]
|
||||||
|
referencedImages: jsonb('referenced_images').$type<ReferencedImage[]>(),
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
errorMessage: text('error_message'),
|
||||||
|
errorCode: varchar('error_code', { length: 50 }),
|
||||||
|
retryCount: integer('retry_count').notNull().default(0),
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
processingTimeMs: integer('processing_time_ms'),
|
||||||
|
cost: integer('cost'), // In cents (USD)
|
||||||
|
|
||||||
|
// Request context
|
||||||
|
requestId: uuid('request_id'),
|
||||||
|
userAgent: text('user_agent'),
|
||||||
|
ipAddress: text('ip_address'),
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
// CHECK constraints
|
||||||
|
statusSuccessCheck: check(
|
||||||
|
'status_success_check',
|
||||||
|
sql`(${table.status} = 'success' AND ${table.outputImageId} IS NOT NULL) OR (${table.status} != 'success')`,
|
||||||
|
),
|
||||||
|
statusFailedCheck: check(
|
||||||
|
'status_failed_check',
|
||||||
|
sql`(${table.status} = 'failed' AND ${table.errorMessage} IS NOT NULL) OR (${table.status} != 'failed')`,
|
||||||
|
),
|
||||||
|
retryCountCheck: check('retry_count_check', sql`${table.retryCount} >= 0`),
|
||||||
|
processingTimeCheck: check(
|
||||||
|
'processing_time_check',
|
||||||
|
sql`${table.processingTimeMs} IS NULL OR ${table.processingTimeMs} >= 0`,
|
||||||
|
),
|
||||||
|
costCheck: check('cost_check', sql`${table.cost} IS NULL OR ${table.cost} >= 0`),
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
// Index for querying generations by project and status
|
||||||
|
projectStatusIdx: index('idx_generations_project_status').on(
|
||||||
|
table.projectId,
|
||||||
|
table.status,
|
||||||
|
table.createdAt.desc(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Index for flow-scoped generations (partial index)
|
||||||
|
flowIdx: index('idx_generations_flow')
|
||||||
|
.on(table.flowId, table.createdAt.desc())
|
||||||
|
.where(sql`${table.flowId} IS NOT NULL`),
|
||||||
|
|
||||||
|
// Index for output image lookup
|
||||||
|
outputIdx: index('idx_generations_output').on(table.outputImageId),
|
||||||
|
|
||||||
|
// Index for request correlation
|
||||||
|
requestIdx: index('idx_generations_request').on(table.requestId),
|
||||||
|
|
||||||
|
// Index for API key audit trail
|
||||||
|
apiKeyIdx: index('idx_generations_api_key').on(table.apiKeyId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Generation = typeof generations.$inferSelect;
|
||||||
|
export type NewGeneration = typeof generations.$inferInsert;
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
timestamp,
|
||||||
|
pgEnum,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
check,
|
||||||
|
type AnyPgColumn,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { projects } from './projects';
|
||||||
|
import { flows } from './flows';
|
||||||
|
import { apiKeys } from './apiKeys';
|
||||||
|
|
||||||
|
// Enum for image source
|
||||||
|
export const imageSourceEnum = pgEnum('image_source', ['generated', 'uploaded']);
|
||||||
|
|
||||||
|
// Type for focal point JSONB
|
||||||
|
export type FocalPoint = {
|
||||||
|
x: number; // 0.0 - 1.0
|
||||||
|
y: number; // 0.0 - 1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
export const images = pgTable(
|
||||||
|
'images',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
projectId: uuid('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
|
generationId: uuid('generation_id').references(
|
||||||
|
(): AnyPgColumn => {
|
||||||
|
const { generations } = require('./generations');
|
||||||
|
return generations.id;
|
||||||
|
},
|
||||||
|
{ onDelete: 'set null' },
|
||||||
|
),
|
||||||
|
flowId: uuid('flow_id').references(() => flows.id, { onDelete: 'cascade' }),
|
||||||
|
apiKeyId: uuid('api_key_id').references(() => apiKeys.id, { onDelete: 'set null' }),
|
||||||
|
|
||||||
|
// Storage (MinIO path format: orgSlug/projectSlug/category/YYYY-MM/filename.ext)
|
||||||
|
storageKey: varchar('storage_key', { length: 500 }).notNull().unique(),
|
||||||
|
storageUrl: text('storage_url').notNull(),
|
||||||
|
|
||||||
|
// File metadata
|
||||||
|
mimeType: varchar('mime_type', { length: 100 }).notNull(),
|
||||||
|
fileSize: integer('file_size').notNull(),
|
||||||
|
fileHash: varchar('file_hash', { length: 64 }), // SHA-256 for deduplication
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
width: integer('width'),
|
||||||
|
height: integer('height'),
|
||||||
|
aspectRatio: varchar('aspect_ratio', { length: 10 }),
|
||||||
|
|
||||||
|
// Focal point for image transformations (imageflow)
|
||||||
|
// Normalized coordinates: { "x": 0.5, "y": 0.3 } where 0.0-1.0
|
||||||
|
focalPoint: jsonb('focal_point').$type<FocalPoint>(),
|
||||||
|
|
||||||
|
// Source
|
||||||
|
source: imageSourceEnum('source').notNull(),
|
||||||
|
|
||||||
|
// Project-level alias (global scope)
|
||||||
|
// Flow-level aliases stored in flows.aliases
|
||||||
|
alias: varchar('alias', { length: 100 }),
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
description: text('description'),
|
||||||
|
tags: text('tags').array(),
|
||||||
|
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({}),
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.notNull()
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date()),
|
||||||
|
deletedAt: timestamp('deleted_at'), // Soft delete
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
// CHECK constraints
|
||||||
|
sourceGeneratedCheck: check(
|
||||||
|
'source_generation_check',
|
||||||
|
sql`(${table.source} = 'uploaded' AND ${table.generationId} IS NULL) OR (${table.source} = 'generated' AND ${table.generationId} IS NOT NULL)`,
|
||||||
|
),
|
||||||
|
aliasFormatCheck: check(
|
||||||
|
'alias_format_check',
|
||||||
|
sql`${table.alias} IS NULL OR ${table.alias} ~ '^@[a-zA-Z0-9_-]+$'`,
|
||||||
|
),
|
||||||
|
fileSizeCheck: check('file_size_check', sql`${table.fileSize} > 0`),
|
||||||
|
widthCheck: check(
|
||||||
|
'width_check',
|
||||||
|
sql`${table.width} IS NULL OR (${table.width} > 0 AND ${table.width} <= 8192)`,
|
||||||
|
),
|
||||||
|
heightCheck: check(
|
||||||
|
'height_check',
|
||||||
|
sql`${table.height} IS NULL OR (${table.height} > 0 AND ${table.height} <= 8192)`,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
// Unique index for project-scoped aliases (partial index)
|
||||||
|
projectAliasIdx: uniqueIndex('idx_images_project_alias')
|
||||||
|
.on(table.projectId, table.alias)
|
||||||
|
.where(sql`${table.alias} IS NOT NULL AND ${table.deletedAt} IS NULL AND ${table.flowId} IS NULL`),
|
||||||
|
|
||||||
|
// Index for querying images by project and source (partial index)
|
||||||
|
projectSourceIdx: index('idx_images_project_source')
|
||||||
|
.on(table.projectId, table.source, table.createdAt.desc())
|
||||||
|
.where(sql`${table.deletedAt} IS NULL`),
|
||||||
|
|
||||||
|
// Index for flow-scoped images (partial index)
|
||||||
|
flowIdx: index('idx_images_flow')
|
||||||
|
.on(table.flowId)
|
||||||
|
.where(sql`${table.flowId} IS NOT NULL`),
|
||||||
|
|
||||||
|
// Index for generation lookup
|
||||||
|
generationIdx: index('idx_images_generation').on(table.generationId),
|
||||||
|
|
||||||
|
// Index for storage key lookup
|
||||||
|
storageKeyIdx: index('idx_images_storage_key').on(table.storageKey),
|
||||||
|
|
||||||
|
// Index for file hash (deduplication)
|
||||||
|
hashIdx: index('idx_images_hash').on(table.fileHash),
|
||||||
|
|
||||||
|
// Index for API key audit trail
|
||||||
|
apiKeyIdx: index('idx_images_api_key').on(table.apiKeyId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Image = typeof images.$inferSelect;
|
||||||
|
export type NewImage = typeof images.$inferInsert;
|
||||||
|
|
@ -2,11 +2,19 @@ import { relations } from 'drizzle-orm';
|
||||||
import { organizations } from './organizations';
|
import { organizations } from './organizations';
|
||||||
import { projects } from './projects';
|
import { projects } from './projects';
|
||||||
import { apiKeys } from './apiKeys';
|
import { apiKeys } from './apiKeys';
|
||||||
|
import { flows } from './flows';
|
||||||
|
import { images } from './images';
|
||||||
|
import { generations } from './generations';
|
||||||
|
import { promptUrlCache } from './promptUrlCache';
|
||||||
|
|
||||||
// Export all tables
|
// Export all tables
|
||||||
export * from './organizations';
|
export * from './organizations';
|
||||||
export * from './projects';
|
export * from './projects';
|
||||||
export * from './apiKeys';
|
export * from './apiKeys';
|
||||||
|
export * from './flows';
|
||||||
|
export * from './images';
|
||||||
|
export * from './generations';
|
||||||
|
export * from './promptUrlCache';
|
||||||
|
|
||||||
// Define relations
|
// Define relations
|
||||||
export const organizationsRelations = relations(organizations, ({ many }) => ({
|
export const organizationsRelations = relations(organizations, ({ many }) => ({
|
||||||
|
|
@ -20,9 +28,13 @@ export const projectsRelations = relations(projects, ({ one, many }) => ({
|
||||||
references: [organizations.id],
|
references: [organizations.id],
|
||||||
}),
|
}),
|
||||||
apiKeys: many(apiKeys),
|
apiKeys: many(apiKeys),
|
||||||
|
flows: many(flows),
|
||||||
|
images: many(images),
|
||||||
|
generations: many(generations),
|
||||||
|
promptUrlCache: many(promptUrlCache),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
|
export const apiKeysRelations = relations(apiKeys, ({ one, many }) => ({
|
||||||
organization: one(organizations, {
|
organization: one(organizations, {
|
||||||
fields: [apiKeys.organizationId],
|
fields: [apiKeys.organizationId],
|
||||||
references: [organizations.id],
|
references: [organizations.id],
|
||||||
|
|
@ -31,4 +43,70 @@ export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
|
||||||
fields: [apiKeys.projectId],
|
fields: [apiKeys.projectId],
|
||||||
references: [projects.id],
|
references: [projects.id],
|
||||||
}),
|
}),
|
||||||
|
images: many(images),
|
||||||
|
generations: many(generations),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const flowsRelations = relations(flows, ({ one, many }) => ({
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [flows.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
|
images: many(images),
|
||||||
|
generations: many(generations),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const imagesRelations = relations(images, ({ one, many }) => ({
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [images.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
|
generation: one(generations, {
|
||||||
|
fields: [images.generationId],
|
||||||
|
references: [generations.id],
|
||||||
|
}),
|
||||||
|
flow: one(flows, {
|
||||||
|
fields: [images.flowId],
|
||||||
|
references: [flows.id],
|
||||||
|
}),
|
||||||
|
apiKey: one(apiKeys, {
|
||||||
|
fields: [images.apiKeyId],
|
||||||
|
references: [apiKeys.id],
|
||||||
|
}),
|
||||||
|
promptUrlCacheEntries: many(promptUrlCache),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const generationsRelations = relations(generations, ({ one, many }) => ({
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [generations.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
|
flow: one(flows, {
|
||||||
|
fields: [generations.flowId],
|
||||||
|
references: [flows.id],
|
||||||
|
}),
|
||||||
|
apiKey: one(apiKeys, {
|
||||||
|
fields: [generations.apiKeyId],
|
||||||
|
references: [apiKeys.id],
|
||||||
|
}),
|
||||||
|
outputImage: one(images, {
|
||||||
|
fields: [generations.outputImageId],
|
||||||
|
references: [images.id],
|
||||||
|
}),
|
||||||
|
promptUrlCacheEntries: many(promptUrlCache),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const promptUrlCacheRelations = relations(promptUrlCache, ({ one }) => ({
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [promptUrlCache.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
|
generation: one(generations, {
|
||||||
|
fields: [promptUrlCache.generationId],
|
||||||
|
references: [generations.id],
|
||||||
|
}),
|
||||||
|
image: one(images, {
|
||||||
|
fields: [promptUrlCache.imageId],
|
||||||
|
references: [images.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
timestamp,
|
||||||
|
index,
|
||||||
|
uniqueIndex,
|
||||||
|
check,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { projects } from './projects';
|
||||||
|
import { generations } from './generations';
|
||||||
|
import { images } from './images';
|
||||||
|
|
||||||
|
export const promptUrlCache = pgTable(
|
||||||
|
'prompt_url_cache',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
projectId: uuid('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: 'cascade' }),
|
||||||
|
generationId: uuid('generation_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => generations.id, { onDelete: 'cascade' }),
|
||||||
|
imageId: uuid('image_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => images.id, { onDelete: 'cascade' }),
|
||||||
|
|
||||||
|
// Cache keys (SHA-256 hashes)
|
||||||
|
promptHash: varchar('prompt_hash', { length: 64 }).notNull(),
|
||||||
|
queryParamsHash: varchar('query_params_hash', { length: 64 }).notNull(),
|
||||||
|
|
||||||
|
// Original request (for debugging/reconstruction)
|
||||||
|
originalPrompt: text('original_prompt').notNull(),
|
||||||
|
requestParams: jsonb('request_params').$type<Record<string, unknown>>().notNull(),
|
||||||
|
|
||||||
|
// Cache statistics
|
||||||
|
hitCount: integer('hit_count').notNull().default(0),
|
||||||
|
lastHitAt: timestamp('last_hit_at'),
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
// CHECK constraints
|
||||||
|
hitCountCheck: check('hit_count_check', sql`${table.hitCount} >= 0`),
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
// Unique composite index for cache lookup
|
||||||
|
cacheKeyIdx: uniqueIndex('idx_cache_key').on(
|
||||||
|
table.projectId,
|
||||||
|
table.promptHash,
|
||||||
|
table.queryParamsHash,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Index for generation lookup
|
||||||
|
generationIdx: index('idx_cache_generation').on(table.generationId),
|
||||||
|
|
||||||
|
// Index for image lookup
|
||||||
|
imageIdx: index('idx_cache_image').on(table.imageId),
|
||||||
|
|
||||||
|
// Index for cache hit analytics
|
||||||
|
hitsIdx: index('idx_cache_hits').on(
|
||||||
|
table.projectId,
|
||||||
|
table.hitCount.desc(),
|
||||||
|
table.createdAt.desc(),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type PromptUrlCache = typeof promptUrlCache.$inferSelect;
|
||||||
|
export type NewPromptUrlCache = typeof promptUrlCache.$inferInsert;
|
||||||
Loading…
Reference in New Issue