хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
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)`,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user