# Схема хранилища Актуальная схема SQLite: таблицы, поля и связи. Это **живой** документ — его поддерживаем в соответствии с миграциями. > **Поддержка вместе с миграциями.** Источник истины по схеме — > `internal/store/migrations/*.sql` (goose). При **каждой** новой миграции, > меняющей структуру (таблица/столбец/индекс/связь), обновляем эту диаграмму > в том же change. Расхождение схемы с миграциями считаем багом документации; > его же ловит `docs.py check` в гейте. > > Состояние на: миграции `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`-файла), > `0010_retried_at`, `0011_parsed_context` (структура имени из контекста, JSON). Назначение таблиц и роль компонентов — [architecture.md](architecture.md). Значения `state` и легальные переходы — нормативно в [download-tracking](../openspec/specs/download-tracking/spec.md) и [state-reconciliation](../openspec/specs/state-reconciliation/spec.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 parsed_context "NOT NULL DEFAULT ''; структура имени из контекста (naming, JSON), базовый слой display_name (миграция 0011)" TEXT state "NOT NULL; активность выводится только из 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|copied|exists|collision|superseded" 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, поэтому живут весь срок строки загрузки. - `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`). ## Представление данных Чем физически лежит запись и что происходит при чтении и записи. - **Всё, кроме одного поля, — плоские колонки.** Никакого сжатия, никаких внешних файлов: строка читается и пишется целиком обычным запросом. - **JSON-строками в TEXT** лежат три поля: `recognition.plan` (канонический `recognize.Plan` — файл → роль/сезон/серия), `recognition.reasons` (список причин не-авто) и `download.parsed_context` (структура имени из контекста). Читаются целиком и разбираются в Go; частичного чтения и обновления поля внутри JSON нет, SQL по содержимому этих полей не делается. - **`recognition.raw_llm`** — сырой ответ модели как есть, **несжатый**. Это самое крупное поле в базе и главный кандидат на рост: у каждой попытки распознавания свой ответ, попытки не вытесняются, ретеншена нет (задача в беклоге). - **`download_torrent.data`** — единственный BLOB: исходные байты `.torrent` (обычно десятки КБ, у больших раздач — сотни). Читается целиком при добавлении в qBittorrent и при retry. - **Истории переходов нет** — хранится только текущий `state`; «как сюда попали» восстанавливается по логам (задача в беклоге). - **Терминальные загрузки не удаляются**, `file_link` со статусом `superseded` тоже остаются — база монотонно растёт по числу обработанных раздач. ## Настройки с числовым значением СУБД (`internal/store`, DSN при открытии): | Настройка | Значение | Зачем | | --- | --- | --- | | `journal_mode` | `WAL` | читатели не блокируют писателя | | `busy_timeout` | 5000 мс | ждать снятия блокировки, а не падать сразу `database is locked` | | `foreign_keys` | `ON` | `ON DELETE CASCADE` работает только с этим | | `_txlock` | `immediate` | явная транзакция открывается как write с самого начала; на этом держатся guarded-методы инварианта «одна активная загрузка на infohash» | | Размер пула | по умолчанию `database/sql` | явно не ограничен; писателя SQLite сериализует сама | Времена и пороги, влияющие на объём и частоту работы с базой (значения по умолчанию, `config.example.toml` — источник истины по полям): | Параметр | По умолчанию | Что означает | | --- | --- | --- | | `[worker].poll_interval` | `5s` | частота опроса qBittorrent, а значит и фонового чтения/записи состояния | | `[worker].stuck_after` | `1h` | простой раздачи, после которого она считается зависшей | | `[worker].magnet_timeout` | `24h` | страховочный предел ожидания метаданных magnet | | `[worker].catch_timeout` | `10m` | предел для пойманной задачи, не добавившейся в qBittorrent | | `[worker].source_missing_threshold` | `3` тика | дебаунс пропажи источника | | `[recognition].auto_confidence_threshold` | `0.85` | порог авто-раскладки (доп. проверка к матчу в базе) | | `[llm].timeout` / `max_retries` | `120s` / `3` | каждая попытка порождает строку `recognition` с сырым ответом | | `[metadata.*].timeout` | `10s` | таймаут запроса к метабазе | **Ретеншена нет ни у одной таблицы**, лимита на размер тела ответа LLM нет, кэша метабаз нет — всё три пункта в беклоге.