- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
255 lines
14 KiB
Go
255 lines
14 KiB
Go
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)`,
|
||
}
|
||
}
|