хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
@@ -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)`,
}
}
@@ -0,0 +1,36 @@
// Package migrations — шаги схемы базы.
//
// Шаг лежит своим файлом, имя файла начинается версией, и **применённый шаг не
// переписывается** — только новым файлом. Инвариант проекта держится так же, как
// держался прежде: изменение схемы это новый шаг, а не правка уехавшего.
//
// Шаги лежат отдельным каталогом, а не файлом внутри пакета хранилища, по
// внешней причине: сверка документов ловит изменённый шаг схемы при нетронутом
// `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции
// `[docs]`), а префикс наводится только на каталог.
//
// Регистрация идёт **перечнем**, а не глобальным списком библиотеки: провайдер
// заводится в точке входа и получает этот перечень доводом, поэтому два
// провайдера в одном процессе — например, сервис и проверка — не спорят за общее
// состояние.
package migrations
import (
"github.com/pressly/goose/v3"
)
// All — шаги схемы в порядке версий.
//
// Порядок исхода от порядка этого перечня не зависит: библиотека сортирует шаги
// по версии сама. Перечень собран ради того, чтобы шаг, добавленный файлом и
// забытый здесь, не оказался незамеченным: незарегистрированный шаг не
// накатывается вовсе.
func All() []*goose.Migration {
return []*goose.Migration{
goose.NewGoMigration(
202608220002,
&goose.GoFunc{RunTx: up202608220002},
&goose.GoFunc{RunTx: down202608220002},
),
}
}