## 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-кнопки должно получать понятный ответ «кнопка устарела», а не панику/тишину. - Логи: поле `_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 нужны только для единообразия и логов, отдельных требований не несут.