Files
transcriber/internal/adapter/repo/sqlite/migrations/202608220002_init.go
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

255 lines
14 KiB
Go
Raw 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.
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)`,
}
}