Feat: Replace UUID image identifiers with 8-character base62 short IDs

Short IDs become the canonical identifier in URLs (/i/:short_id),
MinIO/R2 storage keys, and all API responses. Hash-based deduplication
is preserved. Includes two-phase Alembic migration (003 adds nullable
column, 004 enforces NOT NULL) with a backfill script to copy storage
objects and populate short_id for existing images.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 00:13:55 +00:00
parent 87eb2703f5
commit 61d923d5be
41 changed files with 1445 additions and 137 deletions

View File

@@ -0,0 +1,77 @@
# Data Model: Short Image IDs
## Changed Entity: Image
### New Column
| Column | Type | Constraints | Notes |
|------------|--------------|------------------------------|-------------------------------------------|
| `short_id` | VARCHAR(8) | UNIQUE, NOT NULL (post-migration), INDEX | Base62 alphanumeric, 8 characters |
### Updated Columns (values change, types unchanged)
| Column | Old values | New values |
|-----------------|-----------------------------------------|-----------------------------------|
| `storage_key` | SHA-256 hash (64 hex chars) | short_id (8 base62 chars) |
| `thumbnail_key` | `{hash}-thumb` (69 chars) | `{short_id}-thumb` (13 chars) |
### Unchanged Columns
| Column | Notes |
|------------|-----------------------------------------------------------------------|
| `id` | UUID primary key — unchanged, retained as internal identifier |
| `hash` | SHA-256 content hash — unchanged, still used for deduplication |
| `filename` | Unchanged |
| `mime_type`| Unchanged |
| `size_bytes`, `width`, `height` | Unchanged |
| `created_at` | Unchanged |
### Validation Rules
- `short_id`: exactly 8 characters, matching `[a-zA-Z0-9]{8}` — generated on insert, never updated
- `short_id` must be unique across all image records
- On collision (rare), a new value is generated and retried (up to 10 attempts)
---
## Alembic Migrations
### Migration 003 — Add `short_id` column (nullable)
```
ALTER TABLE images ADD COLUMN short_id VARCHAR(8) NULL;
CREATE UNIQUE INDEX ix_images_short_id ON images (short_id);
```
Run immediately on deploy. Existing rows get `short_id = NULL`. New uploads will populate `short_id` on insert (application-level).
### Migration Script — Backfill existing rows
`api/scripts/migrate_to_short_ids.py`
For each image where `short_id IS NULL`:
1. Generate 8-char base62 short_id (retry on collision)
2. Copy storage object: `{hash}``{short_id}` (S3 copy)
3. Copy thumbnail if present: `{hash}-thumb``{short_id}-thumb`
4. Verify new objects exist (S3 head_object)
5. Update DB row: `short_id = {short_id}`, `storage_key = {short_id}`, `thumbnail_key = {short_id}-thumb` (or NULL)
6. Delete old storage objects
### Migration 004 — Add NOT NULL constraint
```
ALTER TABLE images ALTER COLUMN short_id SET NOT NULL;
```
Run only after the migration script completes successfully with zero `short_id IS NULL` rows remaining.
---
## Storage Object Naming Convention
| Object type | Key pattern | Example |
|-------------|---------------------|-------------------|
| Original | `{short_id}` | `xK7mN2pQ` |
| Thumbnail | `{short_id}-thumb` | `xK7mN2pQ-thumb` |
No folder structure. Flat bucket layout (unchanged from current convention).