feat(photos): separate object storage from metadata layer

Move physical file facts out of the photos table into an objects ledger:

- objects / photo_objects / variant_jobs tables (migration 000008)
- drop storage_key/thumb_key/preview_key/size/width/height/mime_type from
  photos (migration 000009); they are derived via joins to the original object
- album-free storage keys: objects/{id}/{role}.{ext}
- explicit GC via pending/ready/deleting states; objects deleted only when no
  photo references them
- background worker drives variant generation (durable variant_jobs) and GC
- copy/move are metadata-only; storage objects are shared between photos
- remove unused storage.DeletePrefix

Non-destructive to the external JSON API: size/dimensions/mime and URLs are
still returned, just sourced from the object layer.

OpenSpec change: media-object-layer
This commit is contained in:
iu
2026-10-10 23:31:41 +03:00
parent 9effc0fc82
commit 96268ee16c
30 changed files with 1835 additions and 136 deletions
+26 -8
View File
@@ -328,23 +328,41 @@ colors: {
- **albums** (id, owner_id, title, description, cover_photo_id, privacy, created_at)
- **album_members** (album_id, user_id, role, invited_by, joined_at)
- **invites** (id, album_id, token, created_by, expires_at, max_uses, uses)
- **photos** (id, album_id, uploader_id, storage_key, thumb_key, preview_key,
original_filename, size, width, height, mime_type,
taken_at, latitude, longitude, caption, created_at)
- **photos** (id, album_id, uploader_id, original_filename,
taken_at, latitude, longitude, caption, exif, deleted_at, created_at)
- **objects** (id, storage_key, mime_type, size, width, height, checksum, state, created_at)
- **photo_objects** (photo_id, object_id, role)
- **variant_jobs** (photo_id, attempts, next_attempt_at, last_error)
- **comments** (id, photo_id, user_id, text, created_at, updated_at)
- **likes** (photo_id, user_id, created_at)
- **notifications** (id, user_id, type, payload_json, read_at, created_at)
## 10. Структура в S3 (Garage)
## 10. Слой хранения и слой метаданных
**S3 (Garage) — непрозрачное объектное хранилище.** Ключи не содержат сущностей
метаданных (альбома, фото). Каждый физический файл адресуется своим `object_id`:
```
{bucket}/
└── albums/{album_id}/
├── original/{photo_id}.{ext}
├── preview/{photo_id}.webp
└── thumb/{photo_id}.webp
└── objects/{object_id}/
├── original.{ext}
├── thumb.webp
└── preview.webp
```
**Postgres — журнал истины.** Таблица `objects` описывает каждый блоб (ключ, mime,
размер, габариты, checksum, состояние `pending|ready|deleting`), таблица
`photo_objects` связывает фото с объектами по ролям (`original|thumb|preview`),
таблица `variant_jobs` — очередь генерации вариантов. Альбом, принадлежность и
представление живут только в `photos`/`albums`.
Следствия:
- **Копирование** между альбомами — только новые строки `photos`+`photo_objects`,
ссылающиеся на те же объекты; операции с S3 не выполняются.
- **Перенос** — только `UPDATE photos.album_id`; объекты не трогаются.
- **Удаление** фото удаляет строки; фоновый воркер удаляет блоб лишь когда на
объект не осталось ни одной ссылки (сборка мусора по ledger-состояниям).
Доступ: **presigned URL** со сроком 1 час (бакет приватный).
Garage поддерживает presigned URL через SigV4 query-string signatures.