feat(metadata): SQLite backend, storage reconciliation and local defaults

- pluggable per-domain Repository interfaces; PostgreSQL and SQLite impls
- SQLite on modernc.org/sqlite (WAL, busy_timeout, foreign_keys, immediate tx)
- separate migrations/sqlite and db.MigrateSQLite; store.NewPostgres/NewSQLite
- cmd/reconcile compares object storage with the journal (report, then --delete)
- storage.Backend.List and photos.Repository.AllStorageKeys
- DB_BACKEND/SQLITE_PATH and STORAGE_BACKEND/FS_ROOT selectable; local defaults
  (sqlite ./galo.db, fs ./data)
- Makefile reconcile/reconcile-delete; docs and .env.example updated
This commit is contained in:
iu
2026-10-11 10:08:31 +03:00
parent ec8d907565
commit 176a8d8ced
69 changed files with 4174 additions and 95 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,120 @@
# Design
## Context
См. `proposal.md` — Why. Текущее состояние, определяющее подход:
- Каждый домен имеет конкретный `Store` с полем `*pgxpool.Pool` и SQL диалекта Postgres.
Всего ~72 метода: `auth(9)`, `albums(7)`, `members(8)`, `photos(20)`,
`photos/objects(15)`, `social(6)`, `notifications(3)`, `sharelinks(4)`.
- Postgres-специфика: `FOR UPDATE SKIP LOCKED`, `ANY($1)`, `make_interval`/`power`/`least`,
`now()`, `timestamptz`, `jsonb`/`::jsonb`, `ON CONFLICT`, `RETURNING`.
- Кросс-доменные зависимости уже выражены интерфейсами (`AlbumAccess`, `PhotoLookup`,
`Notifier`, `MemberLookup`, `AlbumReader`); свои store сервисы берут конкретными.
- Сборка `CGO_ENABLED=0`; model: один экземпляр приложения на инсталляцию.
- Ограничение: SQLite допускает один писатель, много читателей (WAL); один файл на
один экземпляр приложения.
## Goals / Non-Goals
**Goals:**
- Ввести интерфейсы репозиториев по доменам и вторую реализацию на SQLite с паритетом.
- Сохранить текущее поведение PostgreSQL по умолчанию и не менять сервисную логику.
- Обеспечить атомарность объектов, GC и очереди вариантов на обоих бэкендах.
- Гарантировать паритет общим контрактным набором тестов.
**Non-Goals:**
- Горизонтальное масштабирование и несколько экземпляров на один файл SQLite.
- Миграция данных между PostgreSQL и SQLite.
- Изменение наблюдаемого поведения API/спек (см. `proposal.md`, изменений требований нет).
- Изменение слоя хранения (см. `local-fs-storage-backend`).
## Decisions
### D1. Параллельные реализации, а не слой диалекта
Выбрано (по решению пользователя): Postgres-store не трогаем, пишем отдельные
SQLite-store. Интерфейс `Repository` становится швом между доменом и хранилищем.
- Альтернатива: единая логика + `Dialect` — отвергнута: правки во всех существующих
store и риск регрессии Postgres ради экономии дублирования.
- Цена: каждая будущая фича реализуется дважды; снижается контрактными тестами (D9).
### D2. Именование и размещение
Интерфейс домена — `Repository`; существующий pgx-тип остаётся `Store` (`NewStore(pool)`),
SQLite-тип — `SQLiteStore` и живёт в том же пакете (`photos/sqlite_store.go` и т.п.).
- В том же пакете — доступ к `models` и sentinel-ошибкам без циклов импорта и без
перемещения существующего кода.
- Сервисы типизируются интерфейсом (`albums.Repository` и т.д.); кросс-доменные
интерфейсы удовлетворяются любой реализацией.
### D3. Драйвер SQLite — `modernc.org/sqlite`
Чистый Go, совместим с `CGO_ENABLED=0`.
- Альтернатива `mattn/go-sqlite3` (CGO) — отвергнута, ломает модель сборки.
### D4. Роль `$1`-плейсхолдеров
Плейсхолдеры остаются `$1`: и pgx, и `modernc` понимают именованные параметры.
Поскольку реализации раздельные, каждая пишет свой SQL; отдельный слой диалекта не нужен.
### D5. Отдельный набор миграций для SQLite
`migrations/sqlite/` рядом с Postgres-набором, драйвер `golang-migrate/database/sqlite`.
Отображение типов: `timestamptz`→TEXT, `jsonb`→TEXT, `bigint`→INTEGER,
`double precision`→REAL, `now()`→`strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
`REFERENCES ... ON DELETE CASCADE` и `CHECK` сохраняются.
- Альтернатива: переносимый единый набор — отвергнута, тянет переписывание обоих.
### D6. Настройка соединения и конкурентность
`journal_mode=WAL`, `busy_timeout=5000`, `foreign_keys=ON` через DSN-параметры
`_pragma`. Писатель один; читатели — параллельно.
- `ANY($1)` переписывается в `IN (?, ...)` с построением списка в Go; батчи малы.
- `FOR UPDATE`/`SKIP LOCKED` заменяются транзакцией с немедленной блокировкой записи
(`BEGIN IMMEDIATE`); порядок «проверить состояние — вставить/заявку» сохраняется.
### D7. Атомарность жизненного цикла объектов
`BindObject` и claim-методы (`ClaimGarbage`, `ClaimStalePending`, `ClaimVariantJobs`)
выполняются в `BEGIN IMMEDIATE`-транзакции, что сериализует запись и воспроизводит
инварианты Postgres-версии (нельзя привязать объект, уходящий в GC; нельзя удалить
используемый; одинаковая роль без дублей).
### D8. Backoff и `now()`
`FailVariantJob` считает задержку в Go (`min(300, 10*2^attempts)`) вместо
`make_interval/power/least`. Время пишется/читается через хелперы преобразования
`time.Time` <-> RFC3339 TEXT; запросы сравнения используют `datetime('now')`/значения Go,
а не неявное приведение драйвера.
### D9. Контрактные тесты на интерфейс
Один набор тестов на каждый `Repository`, прогоняемый на обоих бэкендах: Postgres под
`TEST_DATABASE_URL` (пропуск без него), SQLite на временном файле (всегда). Это механизм
удержания паритета при параллельных реализациях.
### D10. Фабрика и сборка зависимостей
`store.NewPostgres(pool)` и `store.NewSQLite(db)` возвращают бандл репозиториев;
`app.New` принимает бандл и `storage.Backend`. Выбор бэкенда — на старте по
`DB_BACKEND=postgres|sqlite` (дефолт `postgres`), путь — `SQLITE_PATH`.
- Альтернатива: ветвление внутри `app.New` по типу пула — отвергнута, размазывает выбор.
### D11. Маппинг ошибок и `RETURNING`
`schema`-уровневые sentinel-ошибки домена сохраняются; каждая реализация сама
отображает `pgx.ErrNoRows`/`sql.ErrNoRows` в sentinel. `RETURNING` используется и в
SQLite (>=3.35), поэтому вставки с возвратом `created_at` переносятся один-в-один.
## Risks / Trade-offs
- [Дрейф параллельных реализаций] → общий контрактный набор тестов на интерфейс (D9).
- [Пропускная способность записи SQLite под многими пользователями] → WAL,
`busy_timeout`, один писатель; документируем как режим малой инсталляции.
- [Потеря валидации `jsonb`] → хранение как TEXT, проверка JSON при необходимости в Go.
- [Ошибки преобразования времени/JSON между бэкендами] → хелперы + контрактные тесты.
- [Расхождение миграций] → симметричные наборы, прогон `up` на обоих в тестах.
- [Лимит параметров SQLite в `IN (...)`] → малые батчи (`WORKER_BATCH`), построение
списка в Go.
## Migration Plan
1. Ввести интерфейсы, не меняя Postgres-реализации и дефолт `postgres`.
2. Добавить SQLite-реализации, миграции и фабрику; включение — `DB_BACKEND=sqlite`.
3. Существующие развёртывания не меняются; откат — вернуть `DB_BACKEND=postgres`.
4. Перенос данных между бэкендами не автоматизируется и вне рамок изменения.
## Open Questions
- Нужен ли SQLite-специфичный поиск меток (например, FTS) или достаточно текущих
запросов с точным совпадением/префиксом.
@@ -0,0 +1,37 @@
# Proposal
## Why
Слой метаданных жёстко привязан к PostgreSQL: каждый store держит `*pgxpool.Pool` и
пишет SQL диалекта Postgres. Инсталляциям без внешней БД нужен встроенный,
самодостаточный режим на SQLite с паритетом поведения для одного экземпляра приложения.
## What Changes
- Ввести интерфейсы репозиториев по доменам (auth, albums, members, photos, social,
notifications, sharelinks); существующие pgx-типы становятся одной из реализаций.
- Добавить SQLite-реализации (~72 метода, включая жизненный цикл объектов, сборку
мусора и очередь вариантов) на чистом Go (`modernc.org/sqlite`, `CGO_ENABLED=0`).
- Добавить отдельный набор миграций для SQLite.
- Ввести `DB_BACKEND=postgres|sqlite` и `SQLITE_PATH`; валидировать условно; настроить
WAL, `busy_timeout`, `foreign_keys`.
- Добавить фабрику сборки репозиториев по выбранному бэкенду.
- Обеспечить единый контрактный набор тестов, прогоняемый на обоих бэкендах.
## Capabilities
### New Capabilities
- `metadata-backends`: выбор и работа слоя метаданных — PostgreSQL и встроенная SQLite с паритетным поведением для одного экземпляра.
### Modified Capabilities
<!-- Нет изменений требований существующих спек: поведение системы для клиента не меняется, меняется способ хранения метаданных. -->
## Impact
- `backend/internal/*/store.go` и `backend/internal/photos/objects.go` (интерфейсы +
SQLite-реализации), `internal/db`, `internal/app`, `cmd/server`, `config`, `migrations`.
- Тесты: общий контрактный набор для Postgres и SQLite.
- `.env.example`, `project.md`.
- Ограничение: один экземпляр приложения на один SQLite-файл (без горизонтального
масштабирования); Postgres остаётся поведением по умолчанию.
- Не затрагивает слой хранения.
@@ -0,0 +1,76 @@
# Spec Delta
## Purpose
Выбор и работа слоя метаданных: внешняя PostgreSQL или встроенная SQLite, с
паритетным поведением для одного экземпляра приложения.
## ADDED Requirements
### Requirement: Выбор бэкенда метаданных
Система SHALL поддерживать выбор бэкенда слоя метаданных параметром конфигурации,
при этом PostgreSQL SHALL оставаться значением по умолчанию.
#### Scenario: Выбор PostgreSQL
- **WHEN** в конфигурации задан бэкенд PostgreSQL
- **THEN** метаданные читаются и пишутся во внешнюю PostgreSQL
#### Scenario: Выбор встроенной SQLite
- **WHEN** в конфигурации задан бэкенд SQLite
- **THEN** метаданные читаются и пишутся в локальный файл SQLite без внешней службы
#### Scenario: Неполная конфигурация
- **WHEN** конфигурация выбранного бэкенда неполна
- **THEN** система отказывается стартовать с явной ошибкой конфигурации
### Requirement: Паритет поведения бэкендов
Оба бэкенда метаданных SHALL обеспечивать одинаковое наблюдаемое поведение системы:
аутентификацию, альбомы, роли и доступ, фото, объектный журнал, сборку мусора и
очередь генерации вариантов.
#### Scenario: Одинаковое поведение API
- **WHEN** один и тот же сценарий выполняется на PostgreSQL и на SQLite
- **THEN** наблюдаемые результаты и ошибки API совпадают
#### Scenario: Соблюдение прав доступа
- **WHEN** на любом бэкенде участник без прав выполняет запрещённое действие
- **THEN** система отклоняет действие одинаковым образом
### Requirement: Встроенный одиночный экземпляр
Бэкенд SQLite SHALL работать как самодостаточный режим для одного экземпляра приложения,
и система SHALL отвергать или явно документировать использование одного файла SQLite
несколькими экземплярами.
#### Scenario: Один экземпляр
- **WHEN** приложение запущено в единственном экземпляре
- **THEN** SQLite корректно обслуживает всех пользователей этого экземпляра
#### Scenario: Совместная работа с файлом
- **WHEN** предполагается совместная работа нескольких экземпляров с одним файлом SQLite
- **THEN** конфигурация считает такой режим неподдерживаемым
### Requirement: Атомарность жизненного цикла объектов
На обоих бэкендах привязка готового объекта к фото и заявка объекта на удаление SHALL
быть атомарными и SHALL исключать привязку объекта, который собирается быть удалён, и
удаление используемого объекта.
#### Scenario: Конкурентная привязка и сборка мусора
- **WHEN** привязка объекта и заявка на его удаление происходят одновременно
- **THEN** система либо привязывает объект без удаления, либо удаляет его без привязки
#### Scenario: Повторная привязка
- **WHEN** объект уже привязан к фото в роли
- **THEN** повторная привязка приводит к согласованному состоянию без дублей
### Requirement: Устойчивость к перезапуску
Очередь генерации вариантов и сборка мусора SHALL восстанавливаться из состояния слоя
метаданных после перезапуска на обоих бэкендах, и система SHALL применять схему
метаданных при старте.
#### Scenario: Незавершённые задания
- **WHEN** процесс перезапускается с незавершёнными заданиями генерации вариантов
- **THEN** обработка заданий продолжается
#### Scenario: Применение схемы
- **WHEN** приложение стартует с выбранным бэкендом
- **THEN** схема метаданных выбранного бэкенда приведена к актуальному состоянию
@@ -0,0 +1,32 @@
# Tasks
## 1. Интерфейсы репозиториев и контрактный набор
- [x] 1.1 Ввести `Repository` в доменах auth, albums, members, photos, social, notifications, sharelinks и перевести сервисы на интерфейсы; проверка — `go build ./...` проходит, PostgreSQL-реализации не менялись.
- [x] 1.2 Поднять общий контрактный набор тестов на каждый `Repository`; проверка — набор зелёный на PostgreSQL под `TEST_DATABASE_URL` (пропуск без него).
- [x] 1.3 Зафиксировать маппинг sentinel-ошибок домена и `created_at` через `RETURNING` как часть контракта; проверка — тесты ошибок «не найдено» на Postgres проходят.
## 2. SQLite: драйвер, миграции, фабрика
- [x] 2.1 Добавить `modernc.org/sqlite` и подключение с `journal_mode=WAL`, `busy_timeout`, `foreign_keys=ON`; проверка — тест открытия, `Ping` и применения прагм проходит.
- [x] 2.2 Добавить набор `migrations/sqlite/` и `db.MigrateSQLite`; проверка — тест применяет схему на временном файле и повторный запуск идемпотентен.
- [x] 2.3 Реализовать фабрику `store.NewPostgres(pool)` / `store.NewSQLite(db)`, возвращающую бандл `Repository`; проверка — тест собирает бандл для обоих бэкендов и `go build ./...` проходит.
## 3. SQLite-реализации доменов
- [x] 3.1 Реализовать SQLite-store для auth, members, albums; проверка — контрактные тесты этих доменов зелёные на SQLite.
- [x] 3.2 Реализовать SQLite-store для photos (фото, метки, корзина, backfill EXIF); проверка — контрактные тесты photos зелёные на SQLite.
- [x] 3.3 Реализовать SQLite-логику объектов (`BindObject`, `ClaimGarbage`, `ClaimStalePending`, `ClaimVariantJobs`, `FailVariantJob`) в `BEGIN IMMEDIATE`; проверка — тесты на атомарность привязки/GC и на backoff зелёные.
- [x] 3.4 Реализовать SQLite-store для social, notifications, sharelinks; проверка — контрактные тесты этих доменов зелёные на SQLite.
## 4. Конфигурация, проводка, health, документация
- [x] 4.1 Ввести `DB_BACKEND=postgres|sqlite` и `SQLITE_PATH` с условной валидацией; проверка — юнит-тесты `config.Load` для обоих бэкендов и неполной конфигурации.
- [x] 4.2 Перевести `app.New` на бандл репозиториев и выбрать бэкенд в `cmd/server`; проверка — сервер стартует с обоими значениями `DB_BACKEND`, db-метка в health динамическая.
- [x] 4.3 Обновить `.env.example`, `project.md` и README с ограничением «один экземпляр на файл SQLite»; проверка — документированный запуск с `DB_BACKEND=sqlite` стартует.
## 5. Интеграционные проверки
- [x] 5.1 Прогнать полный контрактный набор на обоих бэкендах и сравнить результаты; проверка — одинаковые исходы на Postgres и SQLite.
- [x] 5.2 End-to-end на SQLite (регистрация → альбом → загрузка → превью → удаление → GC) с перезапуском процесса; проверка — очередь вариантов и GC продолжаются, сирот нет.
- [x] 5.3 Убедиться, что дефолт `postgres` не меняет текущее развёртывание; проверка — запуск без новых переменных проходит как раньше.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,82 @@
# Design
## Context
См. `proposal.md` — Why. Текущее состояние, определяющее подход:
- `storage.Backend` умеет Put/Delete/Get/URL/ServeObject/Ping/EnsureBucket/Bucket, но не
умеет перечислять объекты.
- Сборка мусора (`photos.Worker`) управляется журналом `objects`: `ClaimGarbage` выбирает
строки без привязок и удаляет блоб. Блобы без строки в журнале воркеру невидимы.
- Ключи объектов — `objects/<object_id>/<role><ext>`, слой метаданных хранит их в
`objects.storage_key`.
- Есть два бэкенда хранения (`s3`, `fs`) и два бэкенда метаданных (`postgres`, `sqlite`)
за интерфейсами `storage.Backend` и доменными `Repository`.
## Goals / Non-Goals
**Goals:**
- Ручная сверка хранилища и журнала: инвентаризация обеих сторон, отчёт, безопасное удаление.
- Работа с любой комбинацией бэкендов хранения и метаданных.
- Безопасное поведение по умолчанию: ничего не удаляется без явного флага, свежие блобы
защищены.
**Non-Goals:**
- Автоматическая периодическая реконсиляция в воркере.
- Восстановление пропавших блобов (в журнале есть, в хранилище нет).
- Миграция блобов между бэкендами.
- Сверка префиксов, отличных от объектов.
## Decisions
### D1. Перечисление в `storage.Backend`
Добавляем `List(ctx context.Context, prefix string) ([]ObjectInfo, error)` где
`ObjectInfo{Key string; Size int64; ModTime time.Time}`.
- S3: `ListObjectsV2` с пагинацией по `ContinuationToken`, `Prefix`.
- ФС: обход корня (`filepath.WalkDir`), ключ — путь относительно корня, размер/время из
`os.Stat`.
- Альтернатива: не трогать интерфейс и добавить отдельный тип для реконсиляции — отвергнуто,
перечисление логично принадлежит бэкенду хранения.
### D2. Ключи журнала в метаданных
Добавляем `AllStorageKeys(ctx) ([]string, error)` в `photos.Repository`; реализации:
`SELECT storage_key FROM objects` (pg и sqlite). Контрактный набор покрывает метод.
### D3. Команда `cmd/reconcile`
Отдельная maintenance-команда, читает конфиг, открывает выбранные бэкенды
(`storage.Open` + бандл репозиториев), строит множества и печатает отчёт.
Флаги: `--delete` (bool), `--min-age` (duration, по умолчанию `1h`), `--sample` (число
примеров в отчёте). Область — фиксированный префикс `objects/`.
### D4. Классификация расхождений
- **Сироты** = ключи хранилища под `objects/`, которых нет в журнале; кандидаты на удаление.
- **Пропавшие** = ключи журнала, которых нет в хранилище; только отчёт.
- Сироты моложе `--min-age` не удаляются (защита от гонки с идущей загрузкой: блоб уже
записан, строка ещё не создана).
### D5. Безопасность
По умолчанию — только отчёт и нулевое воздействие. Удаление только по `--delete`, только
под `objects/`, только старше `--min-age`. Пропавшие никогда не «исправляются».
## Risks / Trade-offs
- [Гонка с загрузкой] → `--min-age` пропускает молодые блобы; рекомендуем запускать в
спокойный период.
- [Большие бакеты: память/время] → пагинация S3 и потоковый обход ФС; в отчёте лишь
примеры, не полный список.
- [Несогласованность формата ключей между бэкендами] → оба бэкенда отдают ключи
относительно корня/перфикса без ведущего слэша; контракт фиксируется тестами.
- [Удаление используемого блоба] → сверка выполняется вручную и в спокойный период; журнал
— источник истины, поэтому «сирота» по определению не имеет строки.
## Migration Plan
1. Добавить `List` в интерфейс — S3 и ФС реализуют его (компилятор требует оба).
2. Добавить `AllStorageKeys` в `photos.Repository` — обе реализации.
3. Добавить `cmd/reconcile` (аддитивно, ничего не меняет в работающем сервисе).
4. Данные не мигрируются; команда запускается вручную по необходимости.
## Open Questions
- Нужно ли со временем добавить восстановление пропавших блобов или отметку повреждённых
записей журнала.
@@ -0,0 +1,36 @@
# Proposal
## Why
Сборка мусора воркера управляется журналом `objects` и никогда не сканирует хранилище.
Поэтому блобы, у которых нет строки в журнале (после сброса БД в обход журнала,
исторических миграций или ошибок), накапливаются и не удаляются никогда: воркер их не
видит. Нужна ручная сверка «хранилище ∖ журнал».
## What Changes
- Добавить в `storage.Backend` перечисление объектов под префиксом (ключ, размер, время
изменения); реализовать для S3 (`ListObjectsV2`) и локальной ФС (обход каталога).
- Добавить в слой метаданных чтение всех ключей журнала `objects` (Postgres и SQLite).
- Добавить maintenance-команду `cmd/reconcile`: по умолчанию — только отчёт (сироты и
пропавшие), флаг `--delete` удаляет сироты с учётом минимального возраста
(`--min-age`), область ограничена префиксом `objects/`.
- Пропавшие ключи (есть в журнале, нет в хранилище) только сообщаются — автоматически не
«исправляются».
## Capabilities
### New Capabilities
- `storage-reconciliation`: ручная сверка объектного хранилища с журналом метаданных и удаление осиротевших блобов.
### Modified Capabilities
<!-- Нет изменений требований существующих спек: поведение системы для клиента не меняется, добавляется отдельная maintenance-операция. -->
## Impact
- `backend/internal/storage` (интерфейс + перечисление для S3 и ФС).
- `backend/internal/photos` (`Repository.AllStorageKeys`, реализации pg и sqlite).
- Новый `backend/cmd/reconcile`.
- `README.md`, `project.md`.
- Не меняет API и существующие спеки; дополняет `media-objects` ручной сверкой к
журнальной сборке мусора.
@@ -0,0 +1,65 @@
# Spec Delta
## Purpose
Ручная сверка объектного хранилища с журналом метаданных: инвентаризация обеих сторон,
отчёт о расхождениях и безопасное удаление осиротевших блобов.
## ADDED Requirements
### Requirement: Инвентаризация хранилища
Слой хранения SHALL перечислять объекты под заданным префиксом, возвращая ключ, размер и
время изменения, независимо от бэкенда (S3-совместимое или локальная ФС).
#### Scenario: Перечисление в S3-совместимом хранилище
- **WHEN** запрашивается список объектов под префиксом
- **THEN** возвращаются все ключи с этим префиксом с их размером и временем изменения
#### Scenario: Перечисление в локальной ФС
- **WHEN** хранилищем выбрана локальная ФС
- **THEN** возвращаются ключи всех блобов под префиксом с их размером и временем изменения
### Requirement: Инвентаризация журнала
Слой метаданных SHALL возвращать все ключи, зарегистрированные в журнале `objects`, на
обоих бэкендах (PostgreSQL и SQLite).
#### Scenario: Ключи журнала
- **WHEN** запрашиваются ключи журнала
- **THEN** возвращается множество ключей всех строк `objects`
### Requirement: Отчёт о расхождениях
По умолчанию сверка SHALL выполняться в режиме отчёта и SHALL не изменять хранилище;
отчёт SHALL различать сироты (в хранилище есть, в журнале нет) и пропавшие (в журнале
есть, в хранилище нет).
#### Scenario: Только отчёт
- **WHEN** сверка запущена без флага удаления
- **THEN** система выводит число и примеры сирот и пропавших, ничего не удаляя
#### Scenario: Пропавшие не восстанавливаются
- **WHEN** ключ есть в журнале, но отсутствует в хранилище
- **THEN** он сообщается как пропавший и не создаётся и не удаляется
### Requirement: Удаление сирот по явному запросу
Удаление сирот SHALL выполняться только по явному флагу, только для ключей под префиксом
объектов и только для блобов старше заданного минимального возраста.
#### Scenario: Удаление включено
- **WHEN** сверка запущена с флагом удаления
- **THEN** сироты старше минимального возраста удаляются из хранилища, а более молодые пропускаются
#### Scenario: Защита от гонки с загрузкой
- **WHEN** блоб создан недавно (моложе минимального возраста)
- **THEN** он не удаляется, даже если строки в журнале ещё нет
#### Scenario: Ограничение области
- **WHEN** удаление включено
- **THEN** удаляются только ключи под префиксом объектов, остальные не затрагиваются
### Requirement: Независимость от бэкендов
Сверка SHALL работать с любой комбинацией бэкенда хранения (S3 или ФС) и бэкенда
метаданных (PostgreSQL или SQLite).
#### Scenario: Комбинации бэкендов
- **WHEN** сверка запускается на выбранной конфигурации
- **THEN** она использует выбранные бэкенды хранения и метаданных без изменений кода
@@ -0,0 +1,21 @@
# Tasks
## 1. Перечисление в хранилище
- [x] 1.1 Добавить `List(ctx, prefix)` (`[]ObjectInfo{Key,Size,ModTime}`) в `storage.Backend` и реализовать в `storage.S3` через `ListObjectsV2` с пагинацией; проверка — тест перечисления под префиксом (под `S3_ENDPOINT`, иначе пропуск).
- [x] 1.2 Реализовать `List` в `storage.FS` обходом корня с относительными ключами; проверка — юнит-тест на временном каталоге.
## 2. Ключи журнала в метаданных
- [x] 2.1 Добавить `AllStorageKeys` в `photos.Repository` и реализовать в pg- и sqlite-store; проверка — контрактный тест на обоих бэкендах.
## 3. Команда reconcile
- [x] 3.1 Реализовать `cmd/reconcile`: инвентаризация обеих сторон, классификация сирот/пропавших, отчёт по умолчанию; проверка — юнит-тесты классификации.
- [x] 3.2 Добавить флаги `--delete`, `--min-age`, `--sample` с ограничением области `objects/` и защитой свежих блобов; проверка — тест: молодой блоб не удаляется, старый сирота удаляется, пропавшие не трогаются.
- [x] 3.3 Собрать бэкенды через конфиг (`storage.Open` + бандл репозиториев) для postgres/sqlite и s3/fs; проверка — запуск на fs и s3.
## 4. Документация и интеграция
- [x] 4.1 Обновить `README.md` и `project.md` с описанием команды и её безопасного использования; проверка — документированный запуск работает.
- [x] 4.2 Интеграционная проверка: создать сироту, прогнать отчёт, затем `--delete`, убедиться в удалении и в том, что журнальные записи не затронуты.