package migrations import ( "context" "database/sql" "fmt" "strconv" "git.vakhrushev.me/av/transcriber/internal/entity" ) // up202608220002 заводит схему сервиса целиком. // // Шаг один, и он начальный: прежние шаги встроенного хранилища удалены вместе с // ним — разовое снятие инварианта «применённая миграция не переписывается» // решением владельца от 2026-08-22. Причина названа прямо: стадия проекта — // стройка, на сервере данных нет, сервис остановлен, а новая база ведёт учёт // применённого своей таблицей, которой отметки прежнего каталога не годятся // вовсе. Снятие кончается этим шагом: уехав на сервер, он подпадает под // инвариант как всякий прежний. // // Порядок заведения задан связями: сперва учётные записи, потом всё, что на них // ссылается, и только потом обратные ссылки записи на её приложения. // // **Времени умолчанием схема не ставит.** Вид времени один на все колонки — // `TEXT` в RFC 3339, UTC, секундная точность, — и ставит его приложение единой // точкой. `CURRENT_TIMESTAMP` писал бы свой вид, отличный от объявленного, а // вставка, забывшая проставить время, при умолчании прошла бы молча. // // **Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки // и вид текста были закрыты схемой, потому что панель владельца правила запись // руками и вправе была завести значение, которого сервис не знает. Панели нет, // правка идёт только нашим кодом, и `CHECK` остался бы ценой — новое значение // стоило бы нового шага схемы — без покупателя. func up202608220002(ctx context.Context, tx *sql.Tx) error { for _, statement := range initStatements() { if _, err := tx.ExecContext(ctx, statement); err != nil { return fmt.Errorf("failed to apply initial schema: %w", err) } } return nil } // down202608220002 сносит схему целиком. Порядок обратный порядку заведения: // приложения ссылаются на запись, запись — на учётную запись. func down202608220002(ctx context.Context, tx *sql.Tx) error { tables := []string{ "record_events", "recognitions", "structures", "texts", "record_topics", "audio_records", "topics", "files", "users", } for _, table := range tables { if _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table); err != nil { return fmt.Errorf("failed to drop %s: %w", table, err) } } return nil } // initStatements — шаг по одному оператору на элемент. // // Россыпью, а не одной строкой с разделителями: тело триггера само несёт точку с // запятой, и разбиение общей строки резало бы его пополам. func initStatements() []string { return []string{ // Учётная запись. Ключ — логин у провайдера: его приносит заголовок // доверенного источника, и по нему запись находится при каждом // обращении. Адрес почты необязателен и ключом не служит — он меняется, // и первое обращение с чужим адресом досталось бы чужой записи. `CREATE TABLE users ( id TEXT NOT NULL PRIMARY KEY, provider_login TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `CREATE UNIQUE INDEX idx_users_provider_login ON users (provider_login)`, // Уникальность почты частичная: пустая почта законна и не спорит с // другой пустой. Индекс нужен затем, чтобы занятый адрес отвергался // схемой — по этому отказу заведение переходит на ветвь «запись без // почты», а не отдаёт чужую учётную запись. `CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email <> ''`, // Копия записи на диске. Владелец лежит своей колонкой, а не выводится // через запись: файл переживает свою запись — шаг заводит его до // сохранения, — и заведённый до неё остаётся с владельцем и без ссылки. // // Ссылки на аудиозапись внешним ключом нет намеренно, и `record_id` // здесь — имя подкаталога, где копия лежит. Приём заводит файл **до** // самой записи, и обязательная связь отвергала бы первую же принятую // запись. `CREATE TABLE files ( id TEXT NOT NULL PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users (id), record_id TEXT NOT NULL, file_name TEXT NOT NULL, size_bytes INTEGER NOT NULL, format TEXT NOT NULL DEFAULT '', duration_ms INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL )`, `CREATE INDEX idx_files_owner ON files (owner_id)`, // Словарь тем. Своя таблица, а не набор строк в записи: перечень тем // человека нужен целиком перед каждым обращением к модели, а собрать его // из наборов строк можно только перебором всех его записей. `CREATE TABLE topics ( id TEXT NOT NULL PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users (id), name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `CREATE UNIQUE INDEX idx_topics_owner_name ON topics (owner_id, name)`, // Аудиозапись — центральная сущность. Поля очереди соседствуют с // доменом, но не с содержимым: расшифровка лежит строкой `texts`, и // чтение очереди её не тянет. // // Колонка владельца обязательна и объявлена внешним ключом: ничьей // записи не бывает, и держит это схема, а не проверка вызывающего. // Пустое значение внешнему ключу не отвечает — идентификаторы у учётных // записей непустые, — поэтому ничью запись отвергает та же связь. // // `duration_ms` и `size_bytes` обязательны и различать «неизвестно» и // «ноль» не обязаны: обе величины ставит приём и ставит всегда — запись, // метаданные которой прочитать не удалось, отвергается отказом и не // заводится вовсе. Решение владельца 2026-08-15. `CREATE TABLE audio_records ( id TEXT NOT NULL PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users (id), title TEXT, brief TEXT, original_filename TEXT, duration_ms INTEGER NOT NULL, size_bytes INTEGER NOT NULL, state TEXT NOT NULL, state_entered_at TEXT NOT NULL, halted_at TEXT, halt_reason TEXT, error_text TEXT, acquisition_id TEXT, acquire_expires_at TEXT, delay_time TEXT, attempts INTEGER NOT NULL DEFAULT 0, original_file_id TEXT REFERENCES files (id), normalized_file_id TEXT REFERENCES files (id), transcript_text_id TEXT, literary_text_id TEXT, structure_id TEXT, recognition_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, // Отбор захвата идёт по рубежу, признаку остановки и порядку ленты. // Индекс заводится здесь, а не потом: применённый шаг схемы не // переписывается, и добавление индекса стоило бы отдельного шага. `CREATE INDEX idx_audio_records_acquire ON audio_records (state, halted_at, created_at, id)`, // Страница списка сужается владельцем и режется полным ключом // сортировки — парой «время заведения и ключ записи». `CREATE INDEX idx_audio_records_owner_page ON audio_records (owner_id, created_at, id)`, // Темы записи. Отдельной таблицей связи, а не колонкой-перечнем: у // набора строк в колонке нет ни связи, ни потолка. `CREATE TABLE record_topics ( record_id TEXT NOT NULL REFERENCES audio_records (id), topic_id TEXT NOT NULL REFERENCES topics (id), PRIMARY KEY (record_id, topic_id) )`, `CREATE INDEX idx_record_topics_topic ON record_topics (topic_id)`, // Потолок числа тем держит схема: без него часовой разговор даёт два // десятка тем, и словарь распухает за неделю. Число берётся у домена — // то же самое, которое сервис объявляет приложению. `CREATE TRIGGER trg_record_topics_limit BEFORE INSERT ON record_topics BEGIN SELECT RAISE(ABORT, 'record has too many topics') WHERE ( SELECT COUNT(*) FROM record_topics WHERE record_id = NEW.record_id ) >= ` + strconv.Itoa(entity.MaxTopicsPerRecord) + `; END`, // Тексты записи. Пара «запись и вид» уникальна: повтор прерванного шага // иначе завёл бы второй комплект строк, и вопрос «какой текст отдавать // человеку» стал бы вопросом порядка записи, а не состояния. `CREATE TABLE texts ( id TEXT NOT NULL PRIMARY KEY, record_id TEXT NOT NULL REFERENCES audio_records (id), kind TEXT NOT NULL, contents TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `CREATE UNIQUE INDEX idx_texts_record_kind ON texts (record_id, kind)`, // Структура реплик. Номер версии нужен потому, что разбор сохранённого // ответа изменится раньше, чем архив пересчитают. `CREATE TABLE structures ( id TEXT NOT NULL PRIMARY KEY, record_id TEXT NOT NULL REFERENCES audio_records (id), version INTEGER NOT NULL, contents TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `CREATE UNIQUE INDEX idx_structures_record_version ON structures (record_id, version)`, // Попытка распознавания у внешнего провайдера. // // Сохранённый ответ лежит **третьим файлом в подкаталоге записи**, а // здесь стоит только его имя: шаг опроса читает эту строку раз в // несколько секунд, и ответ на многочасовую запись, положенный колонкой, // ехал бы в память при каждом опросе. `CREATE TABLE recognitions ( id TEXT NOT NULL PRIMARY KEY, record_id TEXT NOT NULL REFERENCES audio_records (id), provider TEXT NOT NULL, model TEXT NOT NULL DEFAULT '', external_id TEXT NOT NULL DEFAULT '', source_uri TEXT NOT NULL DEFAULT '', payload_file TEXT NOT NULL DEFAULT '', started_at TEXT, finished_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `CREATE INDEX idx_recognitions_record ON recognitions (record_id)`, // Журнал событий записи. Колонка текста отказа зовётся `outcome_text`, а // не `error_text`: последнее имя названо поимённо инвариантом проекта о // секрете, и две колонки с этим именем сделали бы инвариант // двусмысленным. `CREATE TABLE record_events ( id TEXT NOT NULL PRIMARY KEY, record_id TEXT NOT NULL REFERENCES audio_records (id), origin TEXT NOT NULL, step TEXT NOT NULL DEFAULT '', outcome TEXT NOT NULL, outcome_text TEXT NOT NULL DEFAULT '', duration_ms INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL )`, `CREATE INDEX idx_record_events_record ON record_events (record_id)`, } }