# Схема базы данных Актуальная схема SQLite-хранилища: таблицы, поля и связи. Это **живой** документ — его поддерживаем в соответствии с миграциями. > **Поддержка вместе с миграциями.** Источник истины по схеме — > `internal/store/migrations/*.sql` (goose). При **каждой** новой миграции, > меняющей структуру (таблица/столбец/индекс/связь), обновляем эту диаграмму > в том же change. Расхождение схемы с миграциями считаем багом > документации. > > Состояние на: миграции `0001_init`, `0002_recognition_plan`, > `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`, > `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`), > `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC, > `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла). Назначение таблиц и почему так — [architecture.md](architecture.md) → «Хранилище». Значения `state` и переходы — [workflow.md](workflow.md). Первичные ключи — ULID (TEXT, lowercase), генерятся приложением (`internal/ident`) — см. [конвенцию](../conventions/database.md). Метки времени (`created_at`/`updated_at`) — TEXT в RFC 3339, UTC (суффикс `Z`); пишет приложение (`store.Now`/`FormatTime`), без `DEFAULT` на колонках. ## ER-диаграмма ```mermaid erDiagram download ||--o{ download_infohash : "инфохэши (v1/v2)" download ||--o| download_torrent : "байты .torrent (1:0..1)" download ||--o{ recognition : "распознавания" download ||--o{ hint : "подсказки" download ||--o{ override : "ручные правки" download ||--o{ file_link : "хардлинки" recognition ||--o{ metadata_candidate : "кандидаты базы" download { TEXT id PK "ULID (lowercase), генерится приложением" TEXT source_type "NOT NULL; magnet|torrent|url" TEXT source_ref "NOT NULL; magnet/url/путь" TEXT display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)" TEXT context "NOT NULL DEFAULT ''" TEXT state "NOT NULL; см. workflow.md; активность выводится только из state" TEXT error_code "nullable" TEXT error_msg "nullable" INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)" TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)" TEXT retried_at "nullable; время последнего ручного retry (RFC 3339 UTC Z), сброс базиса таймаутов (миграция 0010)" TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" TEXT updated_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } download_infohash { TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; PK(infohash, download_id)" TEXT infohash PK "NOT NULL; lowercase hex (40 — v1, 64 — v2)" TEXT kind "NOT NULL; v1|v2" TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } download_torrent { TEXT download_id PK_FK "NOT NULL; ON DELETE CASCADE; байты source_type=torrent" BLOB data "NOT NULL; исходные байты .torrent для добавления файлом (миграция 0009)" } recognition { TEXT id PK "ULID" TEXT download_id FK "NOT NULL; ON DELETE CASCADE" INTEGER attempt_no "NOT NULL DEFAULT 1" INTEGER is_current "NOT NULL DEFAULT 1; 0/1" TEXT media_type "nullable; movie|series" TEXT title "nullable" TEXT original_title "nullable" INTEGER year "nullable" TEXT provider "nullable; tmdb|tvdb|tvmaze|none" TEXT provider_id "nullable" REAL confidence "nullable" TEXT reasons "NOT NULL DEFAULT '[]'; JSON: причины не-авто" TEXT raw_llm "nullable; сырой ответ LLM" TEXT plan "nullable; JSON recognize.Plan (миграция 0002)" TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } hint { TEXT id PK "ULID" TEXT download_id FK "NOT NULL; ON DELETE CASCADE" TEXT text "NOT NULL" TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } override { TEXT id PK "ULID" TEXT download_id FK "NOT NULL; ON DELETE CASCADE" TEXT field "NOT NULL; UNIQUE(download_id, field)" TEXT value "NOT NULL" TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } metadata_candidate { TEXT id PK "ULID" TEXT recognition_id FK "NOT NULL; ON DELETE CASCADE" TEXT provider "NOT NULL" TEXT provider_id "NOT NULL" TEXT title "nullable" INTEGER year "nullable" TEXT url "nullable; ссылка на страницу на сайте провайдера" INTEGER chosen "NOT NULL DEFAULT 0; 0/1" TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } file_link { TEXT id PK "ULID" TEXT download_id FK "NOT NULL; ON DELETE CASCADE" TEXT apply_batch_id "NOT NULL; батч для точечного undo" TEXT src_path "NOT NULL; исходный файл раздачи" TEXT dst_path "NOT NULL; целевой хардлинк" TEXT kind "NOT NULL; video|subtitle|..." TEXT status "NOT NULL; linked|..." INTEGER size "NOT NULL DEFAULT 0; размер файла (байт), фолбэк размера раздачи" TEXT created_at "NOT NULL; RFC 3339 UTC (Z), пишет приложение" } ``` ## Связи и кардинальность - `download` 1 — N `download_infohash` / `recognition` / `hint` / `override` / `file_link`; `recognition` 1 — N `metadata_candidate`. Все дочерние — с `ON DELETE CASCADE`: удаление загрузки уносит её хеши, распознавания, подсказки, правки и ссылки. - `download_infohash` — множество хешей одной загрузки (v1/v2 гибридного торрента); один и тот же infohash может принадлежать нескольким загрузкам во времени (повторный приём после терминального состояния). Инвариант «не более одной активной загрузки на infohash» держат guarded-методы store (`CreateDownloadIfNoActive`/`ActivateIfNoOtherActive`) в одной write-транзакции — на уровне схемы он не выражается (условие на `state`). - `download` 1 — 0..1 `download_torrent` — байты исходного `.torrent` (только у `source_type=torrent`); нужны воркеру для добавления раздачи файлом и для повторного добавления при retry, поэтому живут весь срок строки загрузки. `ON DELETE CASCADE` — страховка на будущий delete-путь (сейчас загрузки не удаляются). - `download` ↔ `file_link` — один источник (раздача) ко многим разложенным файлам; внутри строки `file_link` связь `src_path → dst_path` — 1:1. Не каждый файл раздачи попадает в `file_link` (только распознанные медиа и субтитры); ссылки могут накапливаться несколькими `apply_batch_id`. ## Индексы и ограничения - `download`: индекс по `state`. - `download_infohash`: PK `(infohash, download_id)` (он же индекс поиска по хешу); индекс по `download_id`. - `recognition`: индекс по `download_id`. - `override`: `UNIQUE(download_id, field)`. - `metadata_candidate`: индекс по `recognition_id`. - `file_link`: индексы по `download_id` и по `apply_batch_id`. > Enum-поля (`source_type`, `state`, `provider`, `kind`, `status`, флаги > `0/1`) на уровне SQLite — обычный `TEXT`/`INTEGER` без `CHECK`; допустимые > значения держит код (`internal/store`).