## Context

Идентичность сегодня: `download.id` — INTEGER AUTOINCREMENT (как и у всех
шести таблиц), дедуп — `idempotency_key UNIQUE` (= infohash у активных
задач). Ключ снимается при терминализации (`SetDownloadState`:
`idempotency_key = CASE WHEN terminal THEN NULL ELSE infohash END`) и
восстанавливается при возврате из терминала — так обеспечивается инвариант
«одна активная задача на infohash» при легальном повторном приёме того же
торрента после завершения. Поллинг сопоставляет раздачу по `hash`/
`infohash_v1`/`infohash_v2` с одним хранимым `download.infohash`.

Мотивация и разбор — в proposal и черновике
[logical-title-model §5.1](../../../docs/drafts/logical-title-model.md).

## Goals / Non-Goals

**Goals:**

- ULID как публичный стабильный ключ всех сущностей домена; единый формат id
  в БД, URL и логах; grep по голому id находит всё.
- Множество инфохэшей загрузки (`download_infohash`) вместо одного столбца;
  дедуп и сопоставление в поллинге — по любому из хешей.
- Упрощение механики активности: убрать снимаемый/восстанавливаемый
  `idempotency_key`, активность выводится только из `state`.
- Конвенция «без числовых PK» для будущих таблиц.

**Non-Goals:**

- Сходимость папки, merge-раскладка, `state_transition`, сущность title —
  отдельные change'и (этапность черновика).
- Оптимизация производительности БД (домашний масштаб).
- Совместимость со старыми числовыми id после миграции (старые URL в
  истории Telegram/закладках, старые inline-кнопки бота) — не поддерживаем.

## Decisions

### D1. ULID, канонически lowercase

ULID (`github.com/oklog/ulid/v2`, чистый Go): 128 бит, сортируем по времени
(48 бит timestamp), 26 символов Crockford base32 — компактнее UUID, без
дефисов (grep/двойной клик в логах), глобально уникален across таблиц.
Альтернативы: UUIDv4 — не сортируется; UUIDv7 — эквивалент со статусом RFC,
выбрали бы при интеграции с внешней системой, ждущей UUID (таких нет);
xid/KSUID — менее распространены без выгоды.

Канонический вид — **lowercase** (читаемость; спека ULID case-insensitive
при декодировании). Единственная точка генерации — хелпер `internal/ident`:
`NewID()` (monotonic entropy, `strings.ToLower`), `Parse()` (нормализация
регистра + валидация). Id сущностей генерируют Create-методы `store`
(сейчас они живут на `LastInsertId` — все переводятся на `ident.NewID()`);
`apply_batch_id` генерирует воркер тем же хелпером. Все входные границы
(URL, формы — включая `candidate_id` в ревью) прогоняют id через `Parse`
до запроса к БД — сравнение в SQLite побайтовое.

### D2. Хранение: TEXT PK, обычные rowid-таблицы

TEXT(26), не BLOB(16) — читаемость в `sqlite3` CLI и логах дороже 10 байт.
`WITHOUT ROWID` не используем — выгода на нашем масштабе нулевая, а готчи
есть. `AUTOINCREMENT` исчезает из схемы полностью.

### D3. `download_infohash`: составной ключ, а НЕ `UNIQUE(infohash)`

```sql
CREATE TABLE download_infohash (
    download_id TEXT NOT NULL REFERENCES download (id) ON DELETE CASCADE,
    infohash    TEXT NOT NULL,              -- lowercase hex
    kind        TEXT NOT NULL,              -- v1 | v2
    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
    PRIMARY KEY (infohash, download_id)
);
```

Черновик предлагал `UNIQUE(infohash)` — это **неверно**: спека
state-reconciliation гарантирует «повторный приём того же infohash после
терминала возможен», то есть один хеш легитимно принадлежит нескольким
загрузкам во времени. Глобальная уникальность действует только среди
**активных** загрузок, а это условие на `download.state` — в индекс SQLite
не выразить. PK `(infohash, download_id)` даёт и дедуп строк, и индекс для
поиска по хешу.

### D4. Инвариант «одна активная загрузка на infohash» — двумя атомарными операциями store

`idempotency_key` и его CASE-восстановление удаляются; активность — чисто
функция `state` (terminalStates).

**Критично:** сегодня финальный backstop инварианта — partial unique index
по `idempotency_key`, и на него опираются **пять** путей записи (комментарии
в коде прямо ссылаются на индекс): приём (`ingest`), adopt чужого торрента
(`worker/discover.go`), ручной `Retry` (`worker.go`), воскрешение сверкой
(`reconcile.go:reconcileOneRecovery`), `Relink` (`review.go`). Ни один из
них сейчас не делает check+write в одной транзакции — гонку закрывал индекс.
С удалением индекса **все пять** обязаны пройти через атомарные операции.

Вводим два guarded-метода `store`, каждый — одна write-транзакция
(`BEGIN IMMEDIATE`; SQLite сериализует писателей, поэтому check-then-write
внутри одной write-tx гонок не имеет):

- `CreateDownloadIfNoActive(d, hashes)` — проверка «нет активной загрузки с
  любым из хешей» (join `download_infohash` × `state`) → вставка `download`
  + хешей; иначе возвращает существующую активную (семантика дедупа приёма),
  дописав ей недостающие хеши из вызова (второй хеш гибрида не теряется).
  Используют ingest и discover-adopt.
- `ActivateIfNoOtherActive(id, toState, …)` — проверка «никакая ДРУГАЯ
  активная загрузка не владеет любым из хешей этой» (сама задача исключена
  из выборки — stuck-задача при retry активна и не должна маскировать
  чужого владельца) → переход состояния; иначе отказ. Используют Retry,
  recovery-воскрешение, Relink; отказ — ДО побочных эффектов (Retry
  активирует до повторного qbt.Add, при сбое Add откатывает состояние).
- `AddInfohashes(id, hashes)` — дозапись хешей (раскрытие гибрида) под тем
  же гардом: хеш чужой активной задачи не дописывается (ErrInfohashTaken).

Механический бэкстоп вместо удалённого unique-индекса: `SetDownloadState`
отклоняет переход терминал→активное (предикат в UPDATE) — оживление идёт
только через `ActivateIfNoOtherActive`.

`FindActiveByInfohash`/`ExistsByInfohash` переезжают на join по
`download_infohash` и остаются для чтения (не как гард).

### D5. Накопление хешей из поллинга

Magnet-парсер извлекает btih (v1) **и** btmh (v2) гибридной ссылки
(`Info.Infohashes`, v1 первым) — приём записывает ВСЕ известные хеши,
`kind` — по длине hex: 40 = v1, 64 = v2 (не хардкодить v1). Когда
qBittorrent отдаёт торрент с заполненными `infohash_v1`/`infohash_v2`,
поллинг дописывает недостающие строки через guarded `AddInfohashes`.
Сборщик хешей торрента один — `torrentHashes`: поле `hash` qBittorrent
берётся только при пустых v1/v2 (старый API), потому что у v2-only раздач
это УСЕЧЁННЫЙ v2 (40 hex, по длине неотличим от v1) — его не храним, а
SourceRef усыновления строится из полноразмерного хеша (btih/btmh).
Сопоставление раздачи — по любому из хешей загрузки; live-карта воркера
уже ключуется всеми формами хеша торрента, модель с ней совместима.

### D6. Миграция: одна Go-миграция goose

Первая Go-миграция в проекте (до сих пор — только SQL-файлы из embed).
Механизм: goose поддерживает смешение — classic API (`goose.SetBaseFS` +
`goose.Up`) подхватывает и зарегистрированные Go-миграции. Регистрация —
`goose.AddMigrationContext` в `init()` пакета `store/migrations`, файл
`0006_*.go`; пакет становится Go-пакетом и должен быть импортирован из
`store.go` (иначе `init()` не выполнится). SQL не может генерить ULID —
поэтому Go.

Вся миграция — в одной транзакции goose. Важно: `PRAGMA foreign_keys=OFF`
внутри транзакции — тихий no-op в SQLite, а DSN включает FK на каждом
соединении, поэтому **работаем с включёнными FK** и соблюдаем порядок:

1. Прочитать строки старых таблиц **в порядке старого `id`** (хронология).
2. Сгенерить маппинг `old int id → ULID` для каждой таблицы:
   **timestamp-часть — из `created_at` строки** (UTC в БД), entropy — через
   `ulid.Monotonic`-reader. `created_at` имеет секундное разрешение и
   дубли — норма (батч `file_link`): monotonic-инкремент entropy при равном
   timestamp сохраняет относительный порядок старых id. Непарсибельный
   `created_at` → время миграции.
3. Создать новые таблицы (`*_new`) **родители первыми**, дочерние — с
   `REFERENCES` на `*_new`-родителей; заливать данные тоже родители-первыми
   (FK включены — порядок обязателен). `download.infohash` разносится в
   `download_infohash_new` (lowercase, `kind` по длине hex);
   `idempotency_key` и `download.infohash` опускаются.
4. `DROP` старых таблиц **дети первыми**, затем `ALTER TABLE … RENAME`
   (`*_new` → канонические имена; SQLite ≥ 3.25 переписывает `REFERENCES`
   в ссылающихся таблицах при переименовании), пересоздать индексы.
5. Финальный `PRAGMA foreign_key_check` как самопроверка.

### D7. Границы: URL, ссылки, логи

- `httpapi`: парсинг `{id}` централизован в `pathID` — там `ident.Parse`
  вместо `strconv.ParseInt`; невалидный id → 404 без похода в БД. Вне
  `{id}`-роутов: `candidate_id` из формы ревью, сентинелы `downloadID > 0`
  в `errBody`/`userErr` (со string — `!= ""`). **BREAKING для REST JSON**:
  поле `id` в DTO меняет тип `number → string`.
- Telegram: ссылки бота ведут на `/review/{id}` (не только `/download/`),
  callback-data содержит id (`parseCallback` через `strconv.ParseInt`,
  сентинел `id == 0`, `pending map[int64]int64`) — всё переводится на
  string/ULID. Старые сообщения: числовые URL отдадут 404, нажатие старой
  inline-кнопки должно получать понятный ответ «кнопка устарела», а не
  панику/тишину.
- Логи: поле `<entity>_id` у каждой сущности (`download_id`,
  `recognition_id`, `batch_id`); scoped-логгер уже есть; grep по голому ULID
  — штатный способ корреляции наравне с jq.
- Сортировки: списки сегодня сортируются `ORDER BY id DESC` (и
  `COALESCE(source_added_at, created_at), id`) — с ULID это остаётся
  корректным благодаря сортируемости и хронологическому бэкфиллу; менять
  запросы не требуется.

### D8. Экспозиция множества хешей наружу

`download.infohash` читают не только дедуп и поллинг: карточка загрузки и
кнопка копирования (требование web-ui), REST DTO, live-лукап
(`Live(d.Infohash)`), scoped-логгеры воркера, поиск в списке
(`listWhere … infohash LIKE`). Решения:

- Модель `Download` дополняется срезом хешей, подгружаемым вместе с записью
  (или методом `store`); карточка и REST показывают **все** хеши загрузки
  (v1 и v2, каждый с копированием); REST-поле `infohash` заменяется на
  `infohashes` (список).
- Live-лукап — по любому из хешей (live-карта воркера уже ключуется всеми
  формами хеша торрента).
- Scoped-логгер кладёт в поле `infohash` первый известный хеш (для
  корреляции этого достаточно — id теперь главный ключ поиска по логам).
- Поиск в списке — `EXISTS`-подзапрос по `download_infohash` вместо
  `LIKE` по удаляемому столбцу.

### D9. Конвенция

Новый `docs/conventions/database.md`: PK — TEXT ULID, генерится приложением
через `internal/ident`; числовой AUTOINCREMENT не используем; у
деталей/связей допустим естественный/составной ключ; канонический вид id —
lowercase, нормализация на входных границах. Ссылки — из README конвенций и
CLAUDE.md. ER-схема `docs/specs/database.md` обновляется в том же change.

## Risks / Trade-offs

- [Гонка дедупа без UNIQUE-гарда] → check-then-insert строго в одной
  write-транзакции (`BEGIN IMMEDIATE`), SQLite сериализует писателей.
- [Ошибка миграции портит данные] → миграция в транзакции goose
  (SQLite умеет транзакционный DDL); перед деплоем — копия файла БД
  (штатный бэкап на umbar пока не автоматизирован — сделать руками).
- [Старые URL в истории Telegram/закладках и старые inline-кнопки ломаются]
  → принято: домашний сервис, история коротка; редиректов со старых числовых
  id не делаем; на устаревшую callback-data бот отвечает понятной ошибкой.
- [`created_at` непарсибелен] → fallback на время миграции, порядок ULID
  внутри таблицы всё равно монотонен (entropy-инкремент).
- [Коллизия ULID] → 80 бит энтропии на миллисекунду, единственный генератор
  в одном процессе — пренебрежимо.
- [Первая Go-миграция усложняет store/migrations] → цена принята: паттерн
  понадобится и дальше (backfill-миграции), закладываем аккуратно.

## Migration Plan

1. Код + миграция в одном бинаре; goose прогоняет миграцию на старте, как
   обычно.
2. Перед деплоем на umbar — ручная копия SQLite-файла (data-том).
3. Откат = восстановить копию файла + прежний бинарь (совместимость схем
   вниз не поддерживаем).

## Open Questions

- Нет блокирующих. Мелочь на реализацию: `hint`/`override`/
  `metadata_candidate` нигде не светятся наружу — их ULID нужны только для
  единообразия и логов, отдельных требований не несут.
