# Data Model: Reaction Image Board v1 **Date**: 2026-05-02 --- ## Entities ### Image Represents a single uploaded image file. | Field | Type | Constraints | Notes | |---|---|---|---| | `id` | UUID | PK, not null | Generated on insert | | `hash` | VARCHAR(64) | UNIQUE, not null | SHA-256 hex digest of file bytes | | `filename` | VARCHAR | not null | Original filename; display only | | `mime_type` | VARCHAR(20) | not null | `image/jpeg`, `image/png`, `image/gif`, `image/webp` | | `size_bytes` | BIGINT | not null, > 0 | File size | | `width` | INTEGER | not null, > 0 | Pixel width | | `height` | INTEGER | not null, > 0 | Pixel height | | `storage_key` | VARCHAR(64) | not null | S3 object key; equals `hash` in v1 | | `created_at` | TIMESTAMPTZ | not null, default now() | Set on insert; never updated | **Indexes**: - `images_hash_idx` UNIQUE on `hash` — supports fast duplicate detection **Validation rules**: - `mime_type` MUST be one of: `image/jpeg`, `image/png`, `image/gif`, `image/webp` - `size_bytes` MUST be > 0 and ≤ `MAX_UPLOAD_BYTES` (default 52 428 800) - `hash` MUST be a 64-character lowercase hex string (SHA-256) --- ### Tag Represents a single normalised tag string. | Field | Type | Constraints | Notes | |---|---|---|---| | `id` | UUID | PK, not null | Generated on insert | | `name` | VARCHAR(64) | UNIQUE, not null | Normalised: lowercase, trimmed | | `created_at` | TIMESTAMPTZ | not null, default now() | Set on insert | **Indexes**: - `tags_name_idx` UNIQUE on `name` — supports upsert by name - `tags_name_prefix_idx` on `name` with `varchar_pattern_ops` — supports prefix search (`LIKE 'prefix%'`) **Validation rules**: - `name` MUST match `^[a-z0-9_-]{1,64}$` (after normalisation) - Normalisation applied before validation: lowercase + whitespace trim **Lifecycle**: - Tags are created implicitly on first use; no explicit creation endpoint - Tag records are never deleted even when all image associations are removed --- ### ImageTag (join) Many-to-many association between Image and Tag. | Field | Type | Constraints | Notes | |---|---|---|---| | `image_id` | UUID | FK → images.id ON DELETE CASCADE | | | `tag_id` | UUID | FK → tags.id ON DELETE RESTRICT | | **Primary key**: composite `(image_id, tag_id)` **Notes**: - Deleting an image cascades to all its ImageTag rows - Deleting a tag is RESTRICT (not permitted while image associations exist) - In practice, tags are never deleted in v1 so RESTRICT is never triggered --- ## Relationships ``` Image ──< ImageTag >── Tag (1) (M:M) (1) ``` - One Image has zero or more Tags (through ImageTag) - One Tag is applied to zero or more Images (through ImageTag) --- ## State Transitions ### Image lifecycle ``` [upload received] │ ▼ Validate MIME + size │ Compute SHA-256 │ Existing hash? ──yes──► return existing record (duplicate: true) │ no │ Write to S3 │ Insert images row │ Upsert tags + insert image_tag rows │ Return new record (duplicate: false) │ [user deletes image] │ Delete image_tag rows (cascade) Delete images row Delete S3 object │ [gone] ``` ### Tag set update (PATCH) ``` [PATCH /api/v1/images/{id}/tags with new_tags=[...]] │ Validate each tag name (post-normalisation) │ Fetch current tag set for image │ Compute removed = current \ new_tags Compute added = new_tags \ current │ Delete ImageTag rows for removed tags Upsert Tag records for added tags Insert ImageTag rows for added tags │ Return full updated image record ``` --- ## Database Migration Strategy - Alembic manages all schema changes - Migration files are committed to `api/alembic/versions/` - Schema is applied on API startup (`alembic upgrade head`) - M0: initial empty migration (no tables) - M1: `images` table - M2: `tags` table + `image_tags` table + indexes