# Схема базы данных

Актуальная схема 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`-файла),
> `0010_retried_at`, `0011_parsed_context` (структура имени из контекста, JSON).

Назначение таблиц и почему так — [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    parsed_context "NOT NULL DEFAULT ''; структура имени из контекста (naming, JSON), базовый слой display_name (миграция 0011)"
        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`).
