Files
av 42d5b73a04 docs: перевод документации на канон av-dev
- Раскладка docs/ приведена к канону 2: заведены passport/architecture/
  database/security/review и research; docs/specs, drafts, backlog, review/
  и BRIEF.md разобраны и удалены, беклог переехал в docs/tasks (34 задачи,
  6 целей, слаги на английский).
- Нарративы specs удалены как дубли openspec-спек после поимённой сверки;
  остаток заведён задачами (редактор маппинга ревью, крайние случаи
  именования), отказ от сущности title промоутнут в ADR.
- Проектные копии агентов и скиллов ревью/пайплайна удалены в пользу
  плагинов av-dev-pm и av-dev-pipeline; в task gate добавлен шаг canon
  вместо er-schema.
2026-08-04 09:27:26 +03:00

212 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Схема хранилища
Актуальная схема 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 нет,
кэша метабаз нет — всё три пункта в беклоге.