Files
reactbin/specs/001-reaction-image-board/data-model.md

152 lines
3.9 KiB
Markdown

# 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