хранилище переехало с 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
-65
View File
@@ -1,65 +0,0 @@
// Package pocketbase — хранилище задач и файлов поверх встроенной PocketBase.
//
// Приложение поднимается библиотекой, а не её набором команд: разбор флагов и
// мягкая остановка остаются нашими, а ключ `-c config.toml` — объявленный
// контракт запуска.
package pocketbase
import (
"fmt"
pb "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
// Шаги схемы регистрируются загрузкой своего пакета, а накатывает их
// `RunAllMigrations` ниже. Импорт здесь пустой и явный, хотя соседние файлы
// пакета и так берут оттуда имена коллекций: день, когда имена перестанут
// читаться отсюда, унёс бы вместе с последней ссылкой и регистрацию — список
// шагов остался бы пустым, `RunAllMigrations` вернул бы `nil`, и приложение
// поднялось бы здоровым, но без коллекций. Отказ вылез бы не на старте, а на
// первом приёме записи.
_ "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// New создаёт приложение хранилища на заданном каталоге данных и приводит его в
// рабочее состояние: открывает базу, читает настройки и накатывает непринятые
// шаги схемы.
//
// Схема накатывается **здесь**, а не оставляется серверу, хотя тот и гоняет
// непринятые шаги сам. Причина в порядке: воркеры стартуют раньше сервера, и на
// чистом каталоге их первые опросы приходились бы на несуществующую таблицу —
// отказ в журнале и в счётчике на каждую секунду до конца накатки.
func New(dataDir string) (*pb.PocketBase, error) {
app := pb.NewWithConfig(pb.Config{
DefaultDataDir: dataDir,
HideStartBanner: true,
})
if err := app.Bootstrap(); err != nil {
return nil, fmt.Errorf("failed to bootstrap storage: %w", err)
}
if err := app.RunAllMigrations(); err != nil {
return nil, fmt.Errorf("failed to apply storage schema: %w", err)
}
// Страж владельца вешается здесь, а не вызывающим: он защищает архив от
// удаления учётной записи, и сборка, забывшая его позвать, теряет защиту
// молча. Так это уже и было — окружение проверок его не ставило, и всё
// разграничение проверялось на приложении, где архив сносится одним
// запросом.
GuardOwnerDeletion(app)
return app, nil
}
// MustFindCollection достаёт коллекцию по имени. Отсутствие коллекции здесь —
// не отказ окружения, а несделанный шаг схемы: сервис до этой точки не доходит,
// потому что Serve накатывает схему прежде, чем поднять сервер.
func findCollection(app core.App, name string) (*core.Collection, error) {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
return nil, fmt.Errorf("failed to find collection %s: %w", name, err)
}
return collection, nil
}
@@ -1,261 +0,0 @@
package pocketbase
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// workFile — рабочая копия файла на диске. Живёт во временном каталоге
// системы, а не в каталоге данных: последний смонтирован на сервере, и
// временному там не место.
type workFile struct {
path string
}
func (w *workFile) Path() string { return w.path }
func (w *workFile) Size() (int64, error) {
info, err := os.Stat(w.path)
if err != nil {
return 0, fmt.Errorf("failed to stat work file: %w", err)
}
return info.Size(), nil
}
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
// до его создания, и повторный Close тоже законен.
func (w *workFile) Close() error {
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove work file: %w", err)
}
return nil
}
type FileRepository struct {
app core.App
}
func NewFileRepository(app core.App) *FileRepository {
return &FileRepository{app: app}
}
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
func newWorkFile(ext string) (*workFile, error) {
f, err := os.CreateTemp("", "transcriber-*"+ext)
if err != nil {
return nil, fmt.Errorf("failed to create work file: %w", err)
}
path := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("failed to close work file: %w", err)
}
return &workFile{path: path}, nil
}
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
return newWorkFile(ext)
}
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
work, err := newWorkFile(ext)
if err != nil {
return nil, err
}
if err := writeTo(work.path, content); err != nil {
// Отказ уборки не подменяет отказ записи, но и не теряется.
return nil, errors.Join(err, work.Close())
}
return work, nil
}
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
}
name := firstFileName(record)
if name == "" {
return nil, fmt.Errorf("file %s has no content in storage", fileID)
}
work, err := newWorkFile(filepath.Ext(name))
if err != nil {
return nil, err
}
src, err := repo.openStored(record, name)
if err != nil {
return nil, errors.Join(err, work.Close())
}
defer src.Close()
if err := writeTo(work.path, src); err != nil {
return nil, errors.Join(err, work.Close())
}
return work, nil
}
// Create кладёт рабочую копию в хранилище. Имя задаём мы: умолчание библиотеки
// строит его из имени, данного отправителем, а имя отправителя в хранилище не
// попадает — путь к файлу читается в журнале, и инвариант приватности этого не
// допускает. Свой суффикс хранилище допишет само.
//
// Копий у записи ровно две — принятая и приведённая, — и обе местные. Прежний
// путь заведения записи о копии во внешнем хранилище отсюда ушёл: та копия
// файлом записи не считается, а её ключ живёт в строке попытки распознавания.
func (repo *FileRepository) Create(name string, work contract.WorkFile, meta contract.FileMeta, ownerID string) (*entity.File, error) {
collection, err := findCollection(repo.app, migrations.FilesCollection)
if err != nil {
return nil, err
}
stored, err := filesystem.NewFileFromPath(work.Path())
if err != nil {
return nil, fmt.Errorf("failed to read work file: %w", err)
}
stored.Name = name
record := core.NewRecord(collection)
record.Set("file", stored)
record.Set("location", entity.LocationLocal)
record.Set("size", stored.Size)
record.Set("format", meta.Format)
record.Set("duration_ms", meta.DurationMs)
// Владелец файла — владелец записи, которой файл принадлежит. Пустой значит
// «файл без владельца»: таков всякий файл записи, принятой ботом. Правило
// просмотра коллекции сужено этой колонкой, и без неё чужое аудио осталось
// бы доступным всякому вошедшему.
record.Set("owner", ownerID)
if err := repo.app.Save(record); err != nil {
// Отказ укладки называет имя файла — то самое, из которого строится
// ссылка на скачивание. В цепочку оно не идёт по той же причине, что и
// ключ при чтении.
return nil, errors.New("failed to store file")
}
return recordToFile(record), nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
record, err := repo.app.FindRecordById(migrations.FilesCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return recordToFile(record), nil
}
func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) {
record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID)
if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
}
name := firstFileName(record)
if name == "" {
return nil, fmt.Errorf("file %s has no content in storage", fileID)
}
return repo.openStored(record, name)
}
// openStored открывает содержимое файла в хранилище потоком.
func (repo *FileRepository) openStored(record *core.Record, name string) (io.ReadCloser, error) {
fsys, err := repo.app.NewFilesystem()
if err != nil {
return nil, fmt.Errorf("failed to open storage filesystem: %w", err)
}
reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + name)
if err != nil {
// Отказ хранилища несёт ключ файла целиком, а ключ — последняя часть
// ссылки `/api/files/...`, по которой запись скачивают. Наружу отдаётся
// идентификатор записи, и только он: цепочка `%w` уехала бы в журнал и
// стала бы там бессрочным ключом к чужому аудио.
return nil, errors.Join(
fmt.Errorf("failed to read stored file of record %s", record.Id),
fsys.Close(),
)
}
return &storedReader{reader: reader, fsys: fsys}, nil
}
// storedReader держит открытой файловую систему хранилища на всё время чтения:
// закрытая раньше времени, она обрывает поток на середине записи.
type storedReader struct {
reader io.ReadCloser
fsys io.Closer
}
func (r *storedReader) Read(p []byte) (int, error) { return r.reader.Read(p) }
func (r *storedReader) Close() error {
readerErr := r.reader.Close()
fsysErr := r.fsys.Close()
switch {
case readerErr != nil && fsysErr != nil:
return errors.New("failed to close stored file and its filesystem")
case readerErr != nil:
return errors.New("failed to close stored file")
default:
return fsysErr
}
}
// writeTo переливает содержимое в файл потоком. В память запись целиком не
// читается: расчётный потолок — шесть часов.
func writeTo(path string, content io.Reader) error {
dst, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to open work file: %w", err)
}
if _, err := io.Copy(dst, content); err != nil {
_ = dst.Close()
return fmt.Errorf("failed to write work file: %w", err)
}
if err := dst.Close(); err != nil {
return fmt.Errorf("failed to close work file: %w", err)
}
return nil
}
func firstFileName(record *core.Record) string {
names := record.GetStringSlice("file")
if len(names) == 0 {
return ""
}
return names[0]
}
func recordToFile(record *core.Record) *entity.File {
return &entity.File{
Id: record.Id,
Location: record.GetString("location"),
FileName: firstFileName(record),
Size: int64(record.GetInt("size")),
Format: record.GetString("format"),
DurationMs: int64(record.GetInt("duration_ms")),
CreatedAt: record.GetDateTime("created").Time(),
}
}
@@ -1,264 +0,0 @@
package pocketbase
import (
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// MaxProviderLoginLength — предел длины логина у провайдера.
//
// Значение приходит заголовком, то есть целиком задаётся тем, кто шлёт запрос, и
// без предела в колонку уехало бы столько, сколько влезет в заголовки. Число то
// же, что у имени в умолчании библиотеки: длиннее имени логин не бывает, а два
// разных предела на соседних колонках одной записи разошлись бы молча.
const MaxProviderLoginLength = 255
// MaxDisplayNameLength — предел длины имени, пригодного к показу. Число то же и
// по той же причине: столько держит колонка имени в умолчании библиотеки.
const MaxDisplayNameLength = 255
// ErrLoginNotAcceptable — логин негоден: пустой, из одних пробельных знаков,
// длиннее предела или с управляющими знаками. Это не отказ хранилища, а
// негодный ввод, и звать по нему учётную запись не надо.
var ErrLoginNotAcceptable = errors.New("provider login is not acceptable")
// Identity — то, чем доверенный источник называет пришедшего.
//
// Логин — ключ, остальное берётся только при заведении записи.
type Identity struct {
Login string
Name string
Email string
}
// EnsureUser находит учётную запись по логину у провайдера, а не найдя —
// заводит её.
//
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
// представиться — личные токены — придёт следующей задачей и возьмёт этот же
// метод; правило, уложенное куском в слой транспорта, пришлось бы тогда либо
// дублировать вторым куском, либо вытаскивать задним числом.
//
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
// его работы.
//
// Сравнение точное, знак в знак: приведение регистра завело бы правило, которого
// у провайдера нет, — считает ли он `admin` и `Admin` одним человеком, сервису
// неизвестно, а угаданное правило склеило бы двух разных людей.
func EnsureUser(app core.App, identity Identity) (record *core.Record, created bool, err error) {
login, ok := AcceptProviderLogin(identity.Login)
if !ok {
return nil, false, ErrLoginNotAcceptable
}
record, err = findUserByLogin(app, login)
if err != nil {
return nil, false, err
}
if record != nil {
return record, false, nil
}
users, err := findCollection(app, migrations.UsersCollection)
if err != nil {
return nil, false, err
}
record = core.NewRecord(users)
record.Set(migrations.ProviderLoginField, login)
// Имя и почта принимаются так же, как логин, а не кладутся как есть.
// Значения приходят заголовками, то есть задаются тем, кто шлёт запрос;
// имя длиннее предела колонки отвергается проверкой записи, и человек с
// таким именем у провайдера не завёлся бы **никогда** — каждый его запрос
// отвечал бы отказом сервиса. Негодное значение необязательного поля не
// вправе отменять заведение записи.
record.Set("name", acceptDisplayName(identity.Name))
if email, ok := acceptEmail(identity.Email); ok {
record.SetEmail(email)
}
// Пароль записи обязателен при любом значении признака — это проверка самой
// библиотеки, а не колонки. Ставится случайный: употребить его нельзя,
// потому что вход по паролю у коллекции выключен шагом схемы.
record.SetRandomPassword()
if err := app.Save(record); err != nil {
record, err = retryAfterConflict(app, login, record, err)
return record, record != nil, err
}
return record, true, nil
}
// retryAfterConflict разбирает отказ сохранения. Два отказа уникальности здесь
// разные, и исход у них разный.
//
// По **ключевой** колонке — это гонка двух первых обращений одним логином:
// запись успел завести соседний запрос, и надо просто взять его. Отказ, который
// после повторного поиска никуда не делся, — уже не гонка, и его отдают наверх.
//
// По **любой другой** — почта, пришедшая от провайдера, занята другой учётной
// записью: общий почтовый ящик, семья, группа. Запись заводится без почты; она
// необязательна, а ключом не служит. Без этого разреза второй человек с общим
// адресом не завёлся бы никогда — повторный поиск по логину снова ничего не
// нашёл бы, и исход выродился бы либо в цикл, либо в вечный отказ без внятной
// причины.
func retryAfterConflict(app core.App, login string, record *core.Record, saveErr error) (*core.Record, error) {
if isUniqueViolation(saveErr, migrations.ProviderLoginField) {
existing, err := findUserByLogin(app, login)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil
}
return nil, fmt.Errorf("failed to create user account: %w", saveErr)
}
if !isUniqueViolation(saveErr, core.FieldNameEmail) {
return nil, fmt.Errorf("failed to create user account: %w", saveErr)
}
record.SetEmail("")
if err := app.Save(record); err != nil {
return nil, fmt.Errorf("failed to create user account without email: %w", err)
}
return record, nil
}
// findUserByLogin ищет учётную запись по ключу. Значение уходит хранилищу
// **параметром** запроса, а не подстановкой в текст фильтра: строка приходит
// снаружи, и подставленная в текст она правила бы сам запрос, а не только его
// аргумент.
func findUserByLogin(app core.App, login string) (*core.Record, error) {
records, err := app.FindRecordsByFilter(
migrations.UsersCollection,
migrations.ProviderLoginField+" = {:login}",
"", 1, 0,
map[string]any{"login": login},
)
if err != nil {
return nil, fmt.Errorf("failed to look up user account: %w", err)
}
if len(records) == 0 {
return nil, nil
}
return records[0], nil
}
// uniqueViolationCode — каким кодом библиотека называет отказ уникальности.
//
// Разбор идёт по **коду**, а не по имени текста и не по тексту драйвера: текст
// у драйвера свой на каждую версию, а имя колонки не говорит о причине.
const uniqueViolationCode = "validation_not_unique"
// isUniqueViolation говорит, отказала ли по названной колонке проверка
// **уникальности** — а не какая-нибудь другая.
//
// Разница не педантизм. Под ключом `email` библиотека складывает и отказ
// уникальности, и отказ формы адреса; проверка «есть ли ключ в карте» считала
// бы опечатку прокси занятым семейным ящиком и молча заводила бы запись без
// почты. На ключевой колонке та же неточность когда-нибудь выстрелит громче:
// любой отказ проверки логина читался бы как гонка двух первых обращений.
func isUniqueViolation(err error, field string) bool {
var errs validation.Errors
if !errors.As(err, &errs) {
return false
}
fieldErr, ok := errs[field]
if !ok {
return false
}
var object validation.ErrorObject
if !errors.As(fieldErr, &object) {
return false
}
return object.Code() == uniqueViolationCode
}
// acceptDisplayName приводит имя к годному для колонки значению.
//
// Обрезается по пределу колонки и чистится от управляющих знаков — тем же
// приёмом, каким приём записи чистит имя файла отправителя. Пустое значение
// законно: имени у человека может не быть вовсе.
func acceptDisplayName(value string) string {
name := strings.TrimSpace(stripControls(value))
runes := []rune(name)
if len(runes) > MaxDisplayNameLength {
return string(runes[:MaxDisplayNameLength])
}
return name
}
// acceptEmail отдаёт адрес почты, если он вообще похож на адрес.
//
// Негодный отбрасывается **здесь**, а не отказом сохранения: иначе опечатка в
// заголовке кончалась бы либо отказом сервиса, либо — что хуже — ветвью
// «почта занята», и владелец искал бы общий ящик там, где сломан контур.
func acceptEmail(value string) (string, bool) {
email := strings.TrimSpace(value)
if email == "" {
return "", false
}
if err := is.EmailFormat.Validate(email); err != nil {
return "", false
}
return email, true
}
// stripControls убирает управляющие знаки: они приезжают заголовком и в колонке
// им делать нечего.
func stripControls(value string) string {
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, value)
}
// AcceptProviderLogin приводит пришедшее значение к годному логину либо
// отвергает его.
//
// Отвергается пустое, состоящее из одних пробельных знаков, длиннее предела и
// несущее управляющие знаки. Пустое значение — не крайний случай: обратный
// прокси штатно шлёт заголовок пустым там, где никого не назвал, и без этой
// проверки все неназванные собрались бы в одну учётную запись с общим архивом.
//
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
// называет одного человека, а две записи о нём разошлись бы молча.
func AcceptProviderLogin(value string) (string, bool) {
login := strings.TrimSpace(value)
// Предел считается в **знаках**, а не в байтах: колонка считает знаки, и
// два предела в разных единицах разошлись бы вдвое на любой кириллице.
if login == "" || utf8.RuneCountInString(login) > MaxProviderLoginLength {
return "", false
}
for _, r := range login {
if unicode.IsControl(r) {
return "", false
}
}
return login, true
}
@@ -1,316 +0,0 @@
package pocketbase
import (
"strings"
"sync"
"testing"
"unicode/utf8"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// Проверки узнавания: как учётная запись находится и как заводится.
// Заведение идемпотентно: второе обращение попадает в ту же запись и не
// переписывает её.
//
// Не переписывает — половина требования, и она отдельная: перепись на каждом
// запросе означала бы запись в базу на каждый запрос, а правка имени у
// провайдера меняла бы карточку человека молча, посреди его работы.
func TestEnsureUserIsIdempotent(t *testing.T) {
app := newTestStorage(t)
first, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Алиса", Email: "alice@example.test"})
require.NoError(t, err)
second, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Другое имя", Email: "other@example.test"})
require.NoError(t, err)
assert.Equal(t, first.Id, second.Id, "второе обращение завело вторую запись")
assert.Equal(t, "Алиса", second.GetString("name"), "имя переписано вторым обращением")
assert.Equal(t, "alice@example.test", second.Email(), "почта переписана вторым обращением")
assert.Equal(t, 1, countUsers(t, app))
}
// Одновременные первые обращения одним логином дают одну учётную запись.
//
// Проверка стоит потому, что норма без неё держалась бы на одном уникальном
// индексе: забытый в шаге схемы, он дал бы зелёную приёмку и две учётные записи
// на одного человека — а архив разъехался бы между ними молча и склеить его было
// бы нечем.
func TestEnsureUserSurvivesConcurrentFirstRequests(t *testing.T) {
app := newTestStorage(t)
const racers = 8
var wg sync.WaitGroup
ids := make([]string, racers)
errs := make([]error, racers)
start := make(chan struct{})
for i := range racers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
record, _, err := EnsureUser(app, Identity{Login: "racer", Name: "Гонщик"})
errs[i] = err
if record != nil {
ids[i] = record.Id
}
}()
}
close(start)
wg.Wait()
for i, err := range errs {
require.NoError(t, err, "обращение %d отказало", i)
}
for i, id := range ids {
assert.Equal(t, ids[0], id, "обращение %d попало в другую учётную запись", i)
}
assert.Equal(t, 1, countUsers(t, app), "гонка завела больше одной учётной записи")
}
// Занятая почта не мешает завести запись: она достаётся первому, а второй
// заводится без неё.
//
// Общий почтовый ящик — обычное дело в семье, а Authelia вправе отдать один
// адрес группе. Без разреза двух отказов уникальности второй человек не завёлся
// бы никогда: повторный поиск по логину снова ничего не находит.
func TestEnsureUserWithTakenEmail(t *testing.T) {
app := newTestStorage(t)
first, _, err := EnsureUser(app, Identity{Login: "one", Email: "family@example.test"})
require.NoError(t, err)
second, _, err := EnsureUser(app, Identity{Login: "two", Email: "family@example.test"})
require.NoError(t, err)
assert.NotEqual(t, first.Id, second.Id)
assert.Equal(t, "family@example.test", first.Email(), "почта досталась первому")
assert.Empty(t, second.Email(), "второму почта не досталась, но запись завелась")
assert.Equal(t, 2, countUsers(t, app))
}
// Вырожденный логин никого не узнаёт и ничего не заводит.
func TestEnsureUserRejectsDegenerateLogin(t *testing.T) {
app := newTestStorage(t)
values := map[string]string{
"пустой": "",
"одни пробелы": " \t ",
"управляющий знак": "ali\x00ce",
"длиннее предела": strings.Repeat("a", MaxProviderLoginLength+1),
}
for name, value := range values {
t.Run(name, func(t *testing.T) {
record, _, err := EnsureUser(app, Identity{Login: value})
assert.Nil(t, record)
require.ErrorIs(t, err, ErrLoginNotAcceptable)
assert.Equal(t, 0, countUsers(t, app))
})
}
}
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
// называет одного человека, и две записи о нём разошлись бы молча.
func TestEnsureUserTrimsSurroundingSpaces(t *testing.T) {
app := newTestStorage(t)
first, _, err := EnsureUser(app, Identity{Login: "alice"})
require.NoError(t, err)
second, _, err := EnsureUser(app, Identity{Login: " alice "})
require.NoError(t, err)
assert.Equal(t, first.Id, second.Id)
assert.Equal(t, 1, countUsers(t, app))
}
// Сравнение точное: приведение регистра завело бы правило, которого у
// провайдера нет, — и склеило бы двух разных людей.
func TestEnsureUserComparesExactly(t *testing.T) {
app := newTestStorage(t)
lower, _, err := EnsureUser(app, Identity{Login: "alice"})
require.NoError(t, err)
upper, _, err := EnsureUser(app, Identity{Login: "Alice"})
require.NoError(t, err)
assert.NotEqual(t, lower.Id, upper.Id)
assert.Equal(t, 2, countUsers(t, app))
}
// Значение, похожее на условие отбора, ищется как значение, а не как часть
// запроса: оно уходит хранилищу параметром.
func TestEnsureUserDoesNotLetLoginChangeTheQuery(t *testing.T) {
app := newTestStorage(t)
victim, _, err := EnsureUser(app, Identity{Login: "victim"})
require.NoError(t, err)
attacker, _, err := EnsureUser(app, Identity{Login: `x" || provider_login = "victim`})
require.NoError(t, err)
assert.NotEqual(t, victim.Id, attacker.Id,
"значение изменило сам запрос и вернуло чужую учётную запись")
assert.Equal(t, 2, countUsers(t, app))
}
func countUsers(t *testing.T, app core.App) int {
t.Helper()
records, err := app.FindAllRecords(migrations.UsersCollection)
require.NoError(t, err)
return len(records)
}
// Признак заведения отличает первое обращение от всех следующих.
//
// По нему слой узнавания пишет строку журнала, и без него владелец не отличит
// «никто не заходил» от «завелось двадцать»: убрать заведённую запись потом
// нечем.
func TestEnsureUserReportsWhetherItCreated(t *testing.T) {
app := newTestStorage(t)
_, created, err := EnsureUser(app, Identity{Login: "alice"})
require.NoError(t, err)
assert.True(t, created, "первое обращение не назвалось заведением")
_, created, err = EnsureUser(app, Identity{Login: "alice"})
require.NoError(t, err)
assert.False(t, created, "второе обращение назвалось заведением")
}
// Негодное имя не отменяет заведения: оно обрезается по пределу колонки.
//
// Прежде имя уходило в колонку как есть, и человек с длинным именем у
// провайдера получал отказ сервиса на **каждом** запросе — учётная запись не
// заводилась никогда, а починить у себя он ничего не мог.
func TestEnsureUserAcceptsDegenerateName(t *testing.T) {
app := newTestStorage(t)
long := strings.Repeat("я", MaxDisplayNameLength+50)
record, created, err := EnsureUser(app, Identity{Login: "bob", Name: long})
require.NoError(t, err, "негодное имя отменило заведение записи")
require.True(t, created)
name := record.GetString("name")
assert.Equal(t, MaxDisplayNameLength, utf8.RuneCountInString(name), "имя не обрезано по пределу")
assert.NotEmpty(t, name)
}
// Управляющие знаки из имени убираются: значение приезжает заголовком.
func TestEnsureUserStripsControlsFromName(t *testing.T) {
app := newTestStorage(t)
record, _, err := EnsureUser(app, Identity{Login: "carol", Name: "Ка\x00ро\nл"})
require.NoError(t, err)
assert.Equal(t, "Карол", record.GetString("name"))
}
// Негодная почта отбрасывается **явно**, а не через ветвь «почта занята».
//
// Иначе опечатка в контуре неотличима от общего семейного ящика, и владелец
// ищет второго человека там, где сломан прокси.
func TestEnsureUserDropsMalformedEmail(t *testing.T) {
app := newTestStorage(t)
record, created, err := EnsureUser(app, Identity{Login: "dave", Email: "не-адрес"})
require.NoError(t, err, "негодная почта отменила заведение записи")
require.True(t, created)
assert.Empty(t, record.Email())
}
// Предел логина считается в знаках, а не в байтах: колонка считает знаки.
//
// Прежде кириллический логин длиннее половины предела отвергался навсегда,
// хотя колонка приняла бы его.
func TestEnsureUserCountsLoginInRunes(t *testing.T) {
app := newTestStorage(t)
login := strings.Repeat("я", MaxProviderLoginLength)
record, _, err := EnsureUser(app, Identity{Login: login})
require.NoError(t, err, "логин ровно на пределе отвергнут: предел считается в байтах")
assert.Equal(t, login, record.GetString(migrations.ProviderLoginField))
_, _, err = EnsureUser(app, Identity{Login: strings.Repeat("я", MaxProviderLoginLength+1)})
assert.ErrorIs(t, err, ErrLoginNotAcceptable, "логин сверх предела принят")
}
// Ключ учётной записи не меняется после заведения — ни правкой в панели, ни
// прямым сохранением.
//
// Правила доступа коллекции закрывают только путь снаружи; панель работает
// суперпользователем. Переписанный ключ отдал бы весь архив следующему, кто
// придёт с этим именем, и вернуть его было бы нечем.
func TestProviderLoginIsImmutable(t *testing.T) {
app := newTestStorage(t)
BindPanelRules(app)
record, _, err := EnsureUser(app, Identity{Login: "victim-owner"})
require.NoError(t, err)
record.Set(migrations.ProviderLoginField, "someone-else")
err = app.Save(record)
require.Error(t, err, "ключ учётной записи переписан прямым сохранением")
stored, err := app.FindRecordById(migrations.UsersCollection, record.Id)
require.NoError(t, err)
assert.Equal(t, "victim-owner", stored.GetString(migrations.ProviderLoginField))
}
// Правка прочих полей учётной записи при этом проходит: хук сторожит один ключ,
// а не запирает коллекцию целиком.
func TestUserRecordStaysEditableExceptTheKey(t *testing.T) {
app := newTestStorage(t)
BindPanelRules(app)
record, _, err := EnsureUser(app, Identity{Login: "editable", Name: "Прежнее"})
require.NoError(t, err)
record.Set("name", "Новое")
require.NoError(t, app.Save(record), "правка имени в панели отвергнута")
stored, err := app.FindRecordById(migrations.UsersCollection, record.Id)
require.NoError(t, err)
assert.Equal(t, "Новое", stored.GetString("name"))
}
// Две учётные записи без ключа уживаются: индекс частичный, как и соседний
// индекс почты.
//
// Сплошной индекс ронял бы накатку шага на всякой базе, где записей больше
// одной, — то есть у разработчика, ходившего прежним рецептом входа.
func TestEmptyProviderLoginDoesNotCollide(t *testing.T) {
app := newTestStorage(t)
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
require.NoError(t, err)
for _, name := range []string{"Первый", "Второй"} {
record := core.NewRecord(users)
record.Set("name", name)
record.SetRandomPassword()
require.NoError(t, app.Save(record), "вторая запись без ключа отвергнута индексом")
}
assert.Equal(t, 2, countUsers(t, app))
}
@@ -1,109 +0,0 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
func up202608110001(app core.App) error {
files := core.NewBaseCollection(FilesCollection)
files.Fields.Add(
// Сам файл. Защищённым поле не помечено намеренно: право прочитать
// запись даёт знание её идентификатора, и файл встаёт вровень с опросом
// готовности задачи, а не ниже.
//
// Потолок задан **числом**: нулём библиотека читает не «без предела», а
// своё умолчание в 5 МиБ, и на нём отваливалось бы всё длиннее пяти
// минут. Число выведено из расчётного потолка записи в шесть часов с
// запасом на видео; оно же стоит строкой в docs/database.md.
&core.FileField{Name: "file", MaxSelect: 1, MaxSize: entity.MaxRecordSize},
// Где лежит копия. Поле названо `location`, а не `storage`: последним
// словом зовут само хранилище, и третий смысл развёл бы одно слово по
// разным вещам.
&core.SelectField{
Name: "location",
Values: []string{entity.LocationLocal, entity.LocationS3},
MaxSelect: 1,
Required: true,
},
// Ключ объекта во внешнем хранилище; у местной копии пуст.
&core.TextField{Name: "object_key"},
&core.NumberField{Name: "size", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
if err := app.Save(files); err != nil {
return err
}
jobs := core.NewBaseCollection(JobsCollection)
jobs.Fields.Add(
// Перечень состояний закрыт схемой: задача, заведённая в панели руками,
// не должна попасть в выборку с состоянием, которого конвейер не знает.
&core.SelectField{
Name: "state",
Values: []string{
entity.StateCreated,
entity.StateConverted,
entity.StateTranscribe,
entity.StateDone,
entity.StateFailed,
entity.StateDead,
},
MaxSelect: 1,
Required: true,
},
&core.SelectField{
Name: "source",
Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram},
MaxSelect: 1,
Required: true,
},
// Текущий файл задачи: шаг конвейера переставляет ссылку на свой
// результат.
// Обязательна: задача без записи не может пройти ни одного шага, и
// заведённая в панели руками она дошла бы до шага только затем, чтобы
// отказать. Компилятор этого не держит — держит схема.
&core.RelationField{
Name: "file",
CollectionId: files.Id,
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "error_text"},
&core.TextField{Name: "acquisition_id"},
&core.DateField{Name: "acquire_time"},
&core.DateField{Name: "delay_time"},
// Число попыток: растёт при каждом захвате, обнуляется на шаге,
// завершившемся без отказа.
&core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)},
&core.TextField{Name: "recognition_op_id"},
&core.EditorField{Name: "transcription_text"},
&core.NumberField{Name: "tg_chat_id", OnlyInt: true},
&core.NumberField{Name: "tg_reply_message_id", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
// Выборка воркера идёт по состоянию, паузе и сроку захвата — индекс по
// состоянию снимает полный перебор, который был у прежней таблицы.
jobs.AddIndex("idx_transcribe_jobs_state", false, "state", "")
return app.Save(jobs)
}
func down202608110001(app core.App) error {
// Порядок обратный порядку заведения: задачи ссылаются на файлы.
for _, name := range []string{JobsCollection, FilesCollection} {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
continue
}
if err := app.Delete(collection); err != nil {
return err
}
}
return nil
}
@@ -1,124 +0,0 @@
package migrations
import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// defaultAuthTokenDuration — умолчание библиотеки, к которому возвращает откат.
const defaultAuthTokenDuration = 1209600
// up202608120001 закрывает поверхность, которую хранилище приносит своим
// системным шагом, и защищает файл записи.
//
// Коллекция пользователей заводится библиотекой с открытым созданием записи и
// включённым входом по паролю. Без этого шага закрытие API обходится двумя
// запросами: завести себе учётную запись, войти паролем, предъявить полученное
// заголовком. Отдельная цена открытого создания — захват учётной записи: обмен
// кода ищет запись сперва по неизменяемому признаку провайдера, а не найдя —
// по адресу почты, и запись, заведённая посторонним на чужой адрес, достаётся
// первому же настоящему входу с этим адресом.
func up202608120001(app core.App) error {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
// Завести учётную запись можно только входом у провайдера.
//
// Правило именно такое, а не `nil`: запись при первом входе заводит
// внутренний запрос самого обмена, и он идёт без прав суперпользователя —
// глухое `nil` отвергло бы его наравне с посторонним, и войти не смог бы
// никто. Контекст `oauth2` ставит обмен (`core.RequestInfoContextOAuth2`),
// а посторонний запрос приходит с контекстом по умолчанию.
//
// Открывать правило пустой строкой нельзя: публичный обмен принимает поля
// создаваемой записи от вызывающего, и всякий владелец учётной записи у
// провайдера задал бы их сам.
users.CreateRule = ptr(`@request.context = "oauth2"`)
users.PasswordAuth.Enabled = false
users.OTP.Enabled = false
// Провайдер включается здесь с пустыми значениями: адреса, идентификатор
// клиента и секрет приходят из конфига при каждом подъёме. Положенный сюда
// секрет не пережил бы ротации — применённый шаг не переписывается.
users.OAuth2.Enabled = true
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to close users collection surface: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
// Ссылка на файл перестаёт быть правом пройти по ней: до этого шага знание
// ссылки и было доступом, а отзыва у неё нет. Конвейер этим не затронут —
// он читает файл из файловой системы хранилища, а не по ссылке.
//
// Комментарий прежнего шага утверждает обратное — «защищённым поле не
// помечено намеренно». Прежний шаг не переписывается, поэтому решение
// отменяется здесь: право прочитать запись больше не даёт знание её
// идентификатора.
field, ok := files.Fields.GetByName("file").(*core.FileField)
if !ok {
return errors.New("files collection has no file field")
}
field.Protected = true
// Одной пометки мало: защищённый файл судится ещё и правилом просмотра
// коллекции, а незаданное правило означает «только владелец панели» — файл
// не получил бы и вошедший. Правило пускает всякого узнанного: владельца у
// записи ещё нет, и сужать выборку эта задача не должна.
files.ViewRule = ptr(`@request.auth.id != ""`)
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to protect record file: %w", err)
}
return nil
}
// down202608120001 возвращает умолчания библиотеки — те, что стояли до шага.
//
// Открытое создание записи сюда не возвращается намеренно: это ровно то, что
// шаг и закрывал, и откат, восстанавливающий анонимную регистрацию, оставил бы
// сервис хуже, чем он был до задачи. Срок жизни сессии возвращается
// умолчанием, а не нулём: нулевую длительность валидация коллекции отвергает, и
// прежний откат падал на ней, не дойдя до снятия защиты с файла.
func down202608120001(app core.App) error {
users, err := app.FindCollectionByNameOrId("users")
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
users.CreateRule = nil
users.PasswordAuth.Enabled = true
users.OTP.Enabled = true
users.OAuth2.Enabled = false
users.OAuth2.Providers = nil
users.AuthToken.Duration = defaultAuthTokenDuration
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to restore users collection: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
if field, ok := files.Fields.GetByName("file").(*core.FileField); ok {
field.Protected = false
}
files.ViewRule = nil
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to unprotect record file: %w", err)
}
return nil
}
@@ -1,106 +0,0 @@
package migrations
import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// up202608140001 заводит владельца записи.
//
// Колонка — связь с коллекцией пользователей: хранилище само следит, чтобы
// владельцем стояла существующая учётная запись, а не строка, похожая на её
// идентификатор.
//
// Пустое значение допустимо, и это решение с названной ценой. Записи, принятые
// ботом, владельца не имеют вовсе: связи чата Telegram с учётной записью сервис
// не ведёт, её заводит отдельная задача. Обязательность для приёма по HTTP
// держит поэтому сам приём, а не схема.
//
// Каскадное удаление выключено, но одного этого мало: при выключенном каскаде
// хранилище **вынимает** идентификатор из поля связи и сохраняет запись без
// проверок, то есть архив удалённого пользователя стал бы ничьим и не достался
// бы никому. Поэтому удаление учётной записи, у которой остались задачи,
// отвергается слоем приложения — `GuardOwnerDeletion`.
func up202608140001(app core.App) error {
users, err := app.FindCollectionByNameOrId(UsersCollection)
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
jobs, err := app.FindCollectionByNameOrId(JobsCollection)
if err != nil {
return fmt.Errorf("failed to find jobs collection: %w", err)
}
jobs.Fields.Add(ownerField(users.Id))
if err := app.Save(jobs); err != nil {
return fmt.Errorf("failed to add owner to jobs: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
files.Fields.Add(ownerField(users.Id))
// Правило просмотра сужается владельцем. Прежнее пускало всякого узнанного:
// владельца у записи тогда не было, и сужать выборку было нечем. Без этой
// строки разграничение закрыло бы метаданные задачи и оставило открытым
// содержимое — то самое, что оно и заведено прятать: знание идентификатора
// файловой записи равнялось бы праву скачать чужое аудио.
files.ViewRule = ptr(`@request.auth.id != "" && owner = @request.auth.id`)
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to narrow files by owner: %w", err)
}
return nil
}
// ownerField собирает описание колонки владельца. Обе коллекции получают
// одинаковую: разойдясь, они дали бы разное поведение у задачи и у её файла.
func ownerField(usersCollectionID string) *core.RelationField {
return &core.RelationField{
Name: "owner",
CollectionId: usersCollectionID,
MaxSelect: 1,
// Пустое значение допустимо — см. шапку шага. Умолчания у колонки нет:
// связь его не имеет по устройству, и запись не достаётся никому по
// недосмотру схемы.
Required: false,
// Удаление учётной записи не уносит её записи следом: сервис объявлен
// архивом. Что происходит вместо этого, держит `GuardOwnerDeletion`.
CascadeDelete: false,
}
}
// down202608140001 снимает колонку с обеих коллекций и возвращает правило
// просмотра файлов к тому, что стояло до шага, — «всякий узнанный».
func down202608140001(app core.App) error {
for _, name := range []string{JobsCollection, FilesCollection} {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", name, err)
}
field := collection.Fields.GetByName("owner")
if field == nil {
return errors.New("collection " + name + " has no owner field")
}
collection.Fields.RemoveById(field.GetId())
if name == FilesCollection {
collection.ViewRule = ptr(`@request.auth.id != ""`)
}
if err := app.Save(collection); err != nil {
return fmt.Errorf("failed to drop owner from %s: %w", name, err)
}
}
return nil
}
@@ -1,347 +0,0 @@
package migrations
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// up202608140002 перестраивает модель вокруг аудиозаписи.
//
// Прежняя коллекция задач уходит целиком: сервис на сервере остановлен, а
// прежние данные удалены решением владельца 2026-08-14 — переноса эта работа не
// делает, и оставленная пустая коллекция висела бы в панели вторым домом для
// того же понятия.
//
// Порядок заведения задан связями, а не вкусом: приложения ссылаются на запись,
// а запись — на них, поэтому запись заводится первой без обратных ссылок, потом
// приложения, и только потом ссылки дописываются.
//
// Правила доступа у новых коллекций остаются **незаданными**, то есть «только
// владелец панели». Содержимое записи отдаёт собственный адрес сервиса, а не
// поверхность хранилища; непустое правило открыло бы перечисление коллекции
// впрок, а норма проекта велит держать эту поверхность закрытой.
func up202608140002(app core.App) error {
users, err := app.FindCollectionByNameOrId(UsersCollection)
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
files, err := app.FindCollectionByNameOrId(FilesCollection)
if err != nil {
return fmt.Errorf("failed to find files collection: %w", err)
}
// Формат и длительность у копии: по ним видно, чем запись была, не открывая
// её. Расширение наружу выходит только приведённым к перечню известных.
files.Fields.Add(
&core.TextField{Name: "format"},
&core.NumberField{Name: "duration_ms", OnlyInt: true},
)
if err := app.Save(files); err != nil {
return fmt.Errorf("failed to extend files: %w", err)
}
topics, err := createTopics(app, users.Id)
if err != nil {
return err
}
records, err := createAudioRecords(app, users.Id, files.Id, topics.Id)
if err != nil {
return err
}
texts, err := createTexts(app, records.Id)
if err != nil {
return err
}
structures, err := createStructures(app, records.Id)
if err != nil {
return err
}
recognitions, err := createRecognitions(app, records.Id)
if err != nil {
return err
}
if err := createRecordEvents(app, records.Id); err != nil {
return err
}
// Обратные ссылки дописываются последними: раньше коллекций-целей ещё нет.
records.Fields.Add(
&core.RelationField{Name: "transcript_text", CollectionId: texts.Id, MaxSelect: 1},
&core.RelationField{Name: "literary_text", CollectionId: texts.Id, MaxSelect: 1},
&core.RelationField{Name: "structure", CollectionId: structures.Id, MaxSelect: 1},
&core.RelationField{Name: "recognition", CollectionId: recognitions.Id, MaxSelect: 1},
)
if err := app.Save(records); err != nil {
return fmt.Errorf("failed to link audio records to their appendices: %w", err)
}
jobs, err := app.FindCollectionByNameOrId(JobsCollection)
if err != nil {
return fmt.Errorf("failed to find jobs collection: %w", err)
}
if err := app.Delete(jobs); err != nil {
return fmt.Errorf("failed to drop the former jobs collection: %w", err)
}
return nil
}
// createAudioRecords заводит центральную сущность.
//
// Ссылки на файлы две и порознь: шаг конвейера больше не переставляет одну на
// свой результат, и исходник остаётся доступным после того, как запись прошла
// конвейер.
func createAudioRecords(app core.App, usersID, filesID, topicsID string) (*core.Collection, error) {
records := core.NewBaseCollection(RecordsCollection)
records.Fields.Add(
ownerField(usersID),
&core.SelectField{
Name: "source",
Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram},
MaxSelect: 1,
Required: true,
},
// Заголовок и краткое описание читаются вместе со списком, сотней штук
// разом, и потому лежат колонками записи, а не строками текстов.
&core.TextField{Name: "title"},
&core.TextField{Name: "brief"},
// Перечень рубежей закрыт схемой: запись, заведённая в панели руками, не
// должна попасть в выборку с рубежом, которого конвейер не знает.
&core.SelectField{
Name: "state",
Values: entity.AllStates(),
MaxSelect: 1,
Required: true,
},
// Время входа в рубеж — сторож застревания. Ставится только сменой рубежа
// и возвратом записи в работу; откладывание опроса его не двигает.
&core.DateField{Name: "state_entered_at"},
// Остановка — признак, а не рубеж: `state` при ней не стирается, и снятие
// признака продолжает работу с места остановки.
&core.DateField{Name: "halted_at"},
&core.SelectField{
Name: "halt_reason",
Values: entity.AllHaltReasons(),
MaxSelect: 1,
},
&core.TextField{Name: "error_text"},
// Признак **этого** захвата: значение уникально для каждого захвата, и
// запись результата условна по нему, а не по занятости записи.
&core.TextField{Name: "acquisition_id"},
// Срок протухания захвата приезжает с рубежом и пишется числом при самом
// захвате: воркер не привязан к шагу и вывести срок из себя не может.
&core.DateField{Name: "acquire_expires_at"},
&core.DateField{Name: "delay_time"},
// Число отказов ограничивает повторы внутри шага. Время в рубеже мерит
// отдельный сторож: одно число не справлялось ни с одной из обязанностей.
&core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)},
&core.RelationField{Name: "original_file", CollectionId: filesID, MaxSelect: 1},
&core.RelationField{Name: "normalized_file", CollectionId: filesID, MaxSelect: 1},
&core.RelationField{
Name: "topics",
CollectionId: topicsID,
MaxSelect: entity.MaxTopicsPerRecord,
},
&core.NumberField{Name: "tg_chat_id", OnlyInt: true},
&core.NumberField{Name: "tg_reply_message_id", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
// Отбор захвата идёт по рубежу, признаку остановки, паузе и сроку протухания
// захвата — индекс снимает полный перебор.
records.AddIndex("idx_audio_records_state", false, "state, halted_at", "")
if err := app.Save(records); err != nil {
return nil, fmt.Errorf("failed to create audio records: %w", err)
}
return records, nil
}
// createTexts заводит тексты записи. Пара «запись и вид» уникальна: повтор
// прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст
// отдавать человеку» стал бы вопросом порядка записи, а не состояния.
func createTexts(app core.App, recordsID string) (*core.Collection, error) {
texts := core.NewBaseCollection(TextsCollection)
texts.Fields.Add(
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
&core.SelectField{
Name: "kind",
Values: entity.AllTextKinds(),
MaxSelect: 1,
Required: true,
},
// Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме
// зовут формат файла, и третий смысл у одного слова развёл бы по разным
// вещам вид текста и формат копии.
&core.EditorField{Name: "contents"},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
texts.AddIndex("idx_texts_record_kind", true, "record, kind", "")
if err := app.Save(texts); err != nil {
return nil, fmt.Errorf("failed to create texts: %w", err)
}
return texts, nil
}
// createStructures заводит структуру реплик. Номер версии нужен потому, что
// разбор сохранённого ответа изменится раньше, чем архив пересчитают.
func createStructures(app core.App, recordsID string) (*core.Collection, error) {
structures := core.NewBaseCollection(StructuresCollection)
structures.Fields.Add(
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
&core.NumberField{Name: "version", OnlyInt: true, Required: true},
&core.JSONField{Name: "contents", MaxSize: structureContentsMaxSize},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
structures.AddIndex("idx_structures_record_version", true, "record, version", "")
if err := app.Save(structures); err != nil {
return nil, fmt.Errorf("failed to create structures: %w", err)
}
return structures, nil
}
// createRecognitions заводит попытку распознавания у внешнего провайдера.
//
// Сырой ответ лежит **вложением**, а не колонкой: шаг опроса читает эту строку
// раз в несколько секунд, а хранилище читает запись целиком — ответ на
// многочасовую запись ехал бы в память при каждом опросе.
//
// Поле вложения помечено защищённым: сырой ответ это полный текст речи, и
// умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
func createRecognitions(app core.App, recordsID string) (*core.Collection, error) {
recognitions := core.NewBaseCollection(RecognitionsCollection)
payload := &core.FileField{Name: "payload", MaxSelect: 1, MaxSize: recognitionPayloadMaxSize}
payload.Protected = true
recognitions.Fields.Add(
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
&core.TextField{Name: "provider", Required: true},
&core.TextField{Name: "model"},
// Идентификатор операции у провайдера — самое провайдерское, что есть в
// модели, и живёт он здесь, а не колонкой записи.
&core.TextField{Name: "external_id"},
// Адрес, по которому провайдер читает аудио. Копия во внешнем хранилище
// файлом записи не считается: другой провайдер её не потребует.
&core.TextField{Name: "source_uri"},
payload,
&core.DateField{Name: "started_at"},
&core.DateField{Name: "finished_at"},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
if err := app.Save(recognitions); err != nil {
return nil, fmt.Errorf("failed to create recognitions: %w", err)
}
return recognitions, nil
}
// createRecordEvents заводит журнал событий записи.
//
// Колонка текста отказа зовётся `outcome_text`, а не `error_text`: последнее имя
// названо поимённо инвариантом проекта о секрете, и две колонки с этим именем
// сделали бы инвариант двусмысленным.
func createRecordEvents(app core.App, recordsID string) error {
events := core.NewBaseCollection(RecordEventsCollection)
events.Fields.Add(
&core.RelationField{Name: "record", CollectionId: recordsID, MaxSelect: 1, Required: true},
&core.SelectField{
Name: "origin",
Values: entity.AllEventOrigins(),
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "step"},
&core.SelectField{
Name: "outcome",
Values: entity.AllEventOutcomes(),
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "outcome_text"},
&core.NumberField{Name: "duration_ms", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
)
events.AddIndex("idx_record_events_record", false, "record", "")
if err := app.Save(events); err != nil {
return fmt.Errorf("failed to create record events: %w", err)
}
return nil
}
// createTopics заводит словарь тем. Тема уникальна в паре «владелец и название»:
// словарь свой у каждого человека, и общий показал бы одному темы другого.
func createTopics(app core.App, usersID string) (*core.Collection, error) {
topics := core.NewBaseCollection(TopicsCollection)
topics.Fields.Add(
&core.RelationField{
Name: "owner",
CollectionId: usersID,
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "name", Required: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
topics.AddIndex("idx_topics_owner_name", true, "owner, name", "")
if err := app.Save(topics); err != nil {
return nil, fmt.Errorf("failed to create topics: %w", err)
}
return topics, nil
}
const (
// structureContentsMaxSize — потолок разбитой на реплики расшифровки. Число
// с запасом: шестичасовой разговор даёт порядка мегабайта текста с временем.
structureContentsMaxSize = 16 << 20
// recognitionPayloadMaxSize — потолок сохранённого ответа провайдера. Он
// многословнее самой расшифровки: несёт альтернативы, время каждого слова и
// разбор говорящих.
recognitionPayloadMaxSize = 256 << 20
)
// Поля объявляются россыпью, а не помощником, который принимал бы имя доводом:
// сверка перечня колонок со схемой читает литерал `Name:` в шагах, и имя,
// спрятанное за вызовом, она не видит — колонка выпала бы из-под правила молча.
// down202608140002 снимает новые коллекции. Прежнюю коллекцию задач он не
// восстанавливает: данных под ней не было, а пустая копия прежней схемы была бы
// вторым домом для понятия, которого больше нет.
func down202608140002(app core.App) error {
// Порядок обратный порядку заведения: приложения ссылаются на запись.
order := []string{
RecordEventsCollection,
RecognitionsCollection,
StructuresCollection,
TextsCollection,
RecordsCollection,
TopicsCollection,
}
for _, name := range order {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
continue
}
if err := app.Delete(collection); err != nil {
return fmt.Errorf("failed to drop %s: %w", name, err)
}
}
return nil
}
@@ -1,77 +0,0 @@
package migrations
import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// up202608140003 запрещает пустого владельца у аудиозаписи и у её файла.
//
// Прежде пустое значение допускалось, и цену за это платили записи, принятые
// ботом: связи чата Telegram с учётной записью сервис не вёл, и владельца у них
// не было вовсе. Вход Telegram убран, заводить ничью запись стало некому, и
// обязательность переезжает из приёма в схему — туда, где её держит хранилище, а
// не договорённость. Разница не косметическая: пока обязательность жила в
// приёме, ничью запись заводили руками в панели, она уходила в конвейер, стоила
// денег на распознавание и не доставалась потом никому.
//
// Существующих строк шаг **не смотрит**, и это проверено прогоном: хранилище
// держит обязательность связи проверкой записи при сохранении, а не ограничением
// таблицы, поэтому смена признака на базе с ничьей записью проходит зелёным и
// такую запись оставляет. Искать ничьи строки надо до выкладки и запросом —
// `SELECT count(*) FROM audio_records WHERE owner = ”` и то же по `files`;
// прогон самого шага на копии этого не показывает.
//
// Оставленная ничья запись становится незакрываемой: захват идёт сырым запросом
// мимо проверки и выдаёт её воркеру, а всякое сохранение — включая то, которым
// ставится признак остановки, — отказывает. Порядок выкладки поэтому начинается
// с проверки данных, а не с прогона шага.
func up202608140003(app core.App) error {
for _, name := range []string{RecordsCollection, FilesCollection} {
if err := setOwnerRequired(app, name, true); err != nil {
return err
}
}
return nil
}
// down202608140003 возвращает колонке необязательность. Записей это не касается:
// пустых значений среди них нет, а появиться им теперь неоткуда.
func down202608140003(app core.App) error {
for _, name := range []string{RecordsCollection, FilesCollection} {
if err := setOwnerRequired(app, name, false); err != nil {
return err
}
}
return nil
}
// setOwnerRequired правит признак обязательности у колонки владельца одной
// коллекции. Колонка ищется по имени и приводится к типу связи: шаг, молча
// пропустивший чужой тип, оставил бы схему в состоянии, о котором никто не
// узнает.
func setOwnerRequired(app core.App, collectionName string, required bool) error {
collection, err := app.FindCollectionByNameOrId(collectionName)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", collectionName, err)
}
field := collection.Fields.GetByName("owner")
if field == nil {
return errors.New("collection " + collectionName + " has no owner field")
}
relation, ok := field.(*core.RelationField)
if !ok {
return errors.New("owner field of collection " + collectionName + " is not a relation")
}
relation.Required = required
if err := app.Save(collection); err != nil {
return fmt.Errorf("failed to change owner requirement in %s: %w", collectionName, err)
}
return nil
}
@@ -1,94 +0,0 @@
package migrations
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// up202608150001 заводит у аудиозаписи три колонки, которые показывает список
// приложения: имя файла, данное отправителем, длительность и размер принятого.
//
// Шаг один на все три намеренно. Применённый шаг не переписывается, и три шага
// вместо одного стоили бы трёх необратимых решений там, где хватает одного.
//
// **Имя файла ложится своей колонкой, а не в заголовок.** Заголовок несёт
// название, которое дал человек либо посчитала языковая модель; имя файла — то,
// по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба
// смысла посчитанное название затирало бы имя, и вернуть затёртое было бы
// неоткуда.
//
// **Длительность и размер дублируют строку файла, и это решение владельца от
// 2026-08-15.** Равенство между ними не поддерживается никем: на записи лежит
// снимок принятого, взятый приёмом один раз, на файле — величины той копии,
// которой файл является сейчас. Расхождение — не поломка, а разные вопросы;
// норму держит capability `storage`.
//
// Единица стоит в имени колонки, а не в комментарии: расхождение «секунды против
// миллисекунд» между колонкой, ответом списка и объявленным пределом не увидит
// ни компилятор, ни гейт — оба конца числа.
func up202608150001(app core.App) error {
records, err := app.FindCollectionByNameOrId(RecordsCollection)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", RecordsCollection, err)
}
records.Fields.Add(
// Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому
// приём режет его по пределу и убирает управляющие знаки прежде, чем
// сохранить. Схема держит потолок вторым рубежом: значение сверх него
// отвергается хранилищем, а не доезжает до экрана.
&core.TextField{Name: "original_filename", Max: entity.MaxOriginalFilenameLen},
// Длительность и размер принятого. «Неизвестно» колонки не выражают:
// числовая колонка хранилища пустого значения не держит, пустое кладётся
// нулём. Обе ставит приём и ставит всегда — запись с непрочитанными
// метаданными отвергается отказом и не заводится. Решение владельца
// 2026-08-15.
&core.NumberField{Name: "duration_ms", OnlyInt: true, Min: ptr(0.0)},
&core.NumberField{Name: "size_bytes", OnlyInt: true, Min: ptr(0.0)},
)
// Индекс под ленту приложения. Единственный прежний индекс — по рубежу и
// признаку остановки — заведён под захват воркера и выборке владельца не
// помогает ничем: страница сканирует таблицу целиком и досортировывает
// результат во временном дереве.
//
// Замер на этом же изменении: рост архива с 5 тысяч строк до 200 тысяч —
// сорокакратный — растит время одной страницы владельца в двадцать-тридцать
// раз, хотя записей у него всё те же сорок. Цена растёт с **чужими**
// записями, потому что сервис объявлен архивом и хранит их бессрочно.
//
// Порядок колонок повторяет порядок выборки: сужение по владельцу, затем
// сортировка «новыми сверху» полным ключом.
records.AddIndex("idx_audio_records_owner_feed", false, "owner, created DESC, id DESC", "")
// Отбор тремя состояниями сужает по владельцу вместе с рубежом и признаком
// остановки — своим индексом, потому что ведущей колонкой здесь владелец.
records.AddIndex("idx_audio_records_owner_state", false, "owner, state, halted_at", "")
if err := app.Save(records); err != nil {
return fmt.Errorf("failed to add contract columns to %s: %w", RecordsCollection, err)
}
return nil
}
// down202608150001 снимает три колонки. Данные в них при этом теряются, и
// восстановить их неоткуда: имя файла отправителя нигде больше не хранится.
func down202608150001(app core.App) error {
records, err := app.FindCollectionByNameOrId(RecordsCollection)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", RecordsCollection, err)
}
for _, name := range []string{"original_filename", "duration_ms", "size_bytes"} {
records.Fields.RemoveByName(name)
}
records.RemoveIndex("idx_audio_records_owner_feed")
records.RemoveIndex("idx_audio_records_owner_state")
if err := app.Save(records); err != nil {
return fmt.Errorf("failed to drop contract columns from %s: %w", RecordsCollection, err)
}
return nil
}
@@ -1,125 +0,0 @@
package migrations
import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// ProviderLoginField — колонка, в которой лежит ключ учётной записи: логин
// человека у провайдера, тот самый, которым его называет обратный прокси.
//
// Имя говорит о происхождении значения, а не о заголовке, которым оно приехало:
// заголовок — способ доставки и может смениться, а логин у провайдера — то, чем
// значение является. Колонка уезжает шагом схемы и потому не переименовывается.
const ProviderLoginField = "provider_login"
// providerLoginIndex — имя уникального индекса по ключу учётной записи.
const providerLoginIndex = "idx_users_provider_login"
// up202608220001 переводит узнавание пришедшего с протокола OIDC на логин,
// который называет доверенный источник.
//
// Три части, и каждая закрывает своё.
//
// Первая — ключ учётной записи. Прежде идентичность человека лежала в системной
// таблице внешних учётных записей библиотеки: её вела механика обмена кода, и
// правил её только владелец панели. Механика уходит, и ключу нужен свой дом —
// колонка с уникальным индексом. Почта ключом не годится: провайдер не обязан
// её приносить, человек её меняет, а первое обращение с чужим адресом досталось
// бы чужой записи.
//
// Вторая — необязательная почта. Умолчание библиотеки требует непустого адреса
// у всякой учётной записи; заголовка с почтой может не быть вовсе, а
// уникальность почты держится **частичным** индексом (`WHERE email != ”`),
// поэтому записи без почты уживаются друг с другом. Пароль остаётся
// обязательным при любом значении признака — ему ставится случайный, употребить
// его нельзя: вход по паролю у коллекции выключен прежним шагом.
//
// Третья — поверхность коллекции пользователей. Умолчание библиотеки открывает
// владельцу записи чтение, правку и удаление собственной строки, и до сих пор
// это ничему не мешало ровно потому, что до поверхности хранилища браузер с
// кукой не дотягивался: слой предъявления жил под корнем приложения. С
// узнаванием по заголовку такая защита перестаёт быть защитой, а ключ учётной
// записи лежит теперь обычной колонкой — то есть правка своей записи и есть
// захват чужого имени: поставил себе ключом чужой логин, и первое обращение
// настоящего его владельца попало бы в твою запись вместе со всем архивом.
// Правила снимаются в пустое, что у хранилища означает «только владелец
// панели»; наш код читает и заводит запись мимо правил, панель работает
// суперпользователем, своих экранов профиля сервис не заводит.
func up202608220001(app core.App) error {
users, err := app.FindCollectionByNameOrId(UsersCollection)
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
users.Fields.Add(&core.TextField{
Name: ProviderLoginField,
// Предел тот же, что у имени в умолчании библиотеки: логин длиннее
// имени не бывает, а колонка без предела принимала бы килобайты,
// пришедшие заголовком.
Max: 255,
})
// Индекс **частичный** — ровно как соседний индекс почты у той же коллекции.
// Сплошной запретил бы вторую запись с пустым ключом, а такая заводится
// рукой в панели и остаётся у всякой базы, пережившей прежний вход: подъём
// на ней ронял бы накатку шага отказом уникальности, и сервис не стартовал
// бы вовсе. Пустым ключом при этом не узнаётся никто — это держит приём
// значения, а не индекс.
users.AddIndex(providerLoginIndex, true, ProviderLoginField, ProviderLoginField+" != ''")
email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField)
if !ok {
return errors.New("users collection has no email field")
}
email.Required = false
// Механика OIDC снимается целиком: настройки провайдера больше не приводятся
// к конфигу при подъёме, и обменивать код не на что.
users.OAuth2.Enabled = false
users.OAuth2.Providers = nil
// Наглухо все пять: заведение записи идёт нашим кодом, мимо правил.
users.ListRule = nil
users.ViewRule = nil
users.CreateRule = nil
users.UpdateRule = nil
users.DeleteRule = nil
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to switch users collection to provider login: %w", err)
}
return nil
}
// down202608220001 убирает ключ учётной записи и возвращает обязательность
// почты.
//
// Правила доступа сюда не возвращаются намеренно — ни открытое создание записи,
// которое закрывал прежний шаг, ни открытая правка, которую закрывает этот.
// Откат, восстанавливающий их, оставил бы сервис хуже, чем он был: правка своей
// записи открыта только тому, кто узнан, а узнают теперь по колонке, которую эта
// правка и переписывает.
func down202608220001(app core.App) error {
users, err := app.FindCollectionByNameOrId(UsersCollection)
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
users.RemoveIndex(providerLoginIndex)
users.Fields.RemoveByName(ProviderLoginField)
email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField)
if !ok {
return errors.New("users collection has no email field")
}
email.Required = true
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to restore users collection: %w", err)
}
return nil
}
@@ -1,57 +0,0 @@
// Package migrations — шаги схемы хранилища и имена коллекций, которые они
// заводят.
//
// Схема заводится версионированными шагами, и применённый шаг не переписывается
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
// применённое по **имени шага**, а не по пути файла, поэтому имена в
// `Register` ниже не переносятся и не переименовываются, даже если файл переехал.
//
// Шаги лежат своим каталогом, а не файлом внутри пакета репозитория, и причина
// внешняя: сверка документов ловит изменённый шаг схемы при нетронутом
// `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции
// `[docs]`), а префикс наводится только на каталог. Пока шаги лежали файлом,
// наводить его
// было не на что, и проверка молчала на всякой правке схемы.
package migrations
import (
pbmigrations "github.com/pocketbase/pocketbase/migrations"
)
// Имена коллекций живут здесь, рядом с шагом, который их заводит. Они же — часть
// пути к файлу в раскладке хранилища и часть адреса ссылки на него, поэтому
// меняются только новым шагом схемы.
const (
FilesCollection = "files"
// JobsCollection — прежняя коллекция задач. Шаг 202608140002 её удаляет;
// имя остаётся здесь, потому что на него ссылаются прежние шаги схемы, а
// применённый шаг не переписывается.
JobsCollection = "transcribe_jobs"
// RecordsCollection — аудиозапись, центральная сущность сервиса. Имя в
// snake_case, как у соседей по схеме: одно исключение разошлось бы молча по
// константе имён, запросу захвата, правилам панели и запрету удаления.
RecordsCollection = "audio_records"
TextsCollection = "texts"
StructuresCollection = "structures"
RecognitionsCollection = "recognitions"
RecordEventsCollection = "record_events"
TopicsCollection = "topics"
// UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит
// здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя
// на приёме: строковый литерал в двух местах разошёлся бы молча.
UsersCollection = "users"
)
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
// `apis.Serve` прежде, чем поднять сервер.
func init() {
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go")
pbmigrations.Register(up202608150001, down202608150001, "202608150001_record_contract_columns.go")
pbmigrations.Register(up202608220001, down202608220001, "202608220001_trusted_header_login.go")
}
func ptr[T any](v T) *T { return &v }
@@ -1,93 +0,0 @@
package pocketbase
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// GuardOwnerDeletion отвергает удаление учётной записи, у которой остались
// аудиозаписи, их файлы либо темы её словаря.
//
// Колонка владельца — связь с выключенным каскадным удалением, и одного этого
// мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а
// **вынимает** идентификатор из поля связи и сохраняет её без проверок. Задачи
// остались бы на месте, но стали бы ничьими, а ничья задача не достаётся по API
// никому — архив человека исчез бы молча и восстановлению не подлежал:
// прежнего владельца не остаётся нигде.
//
// Цена запрета названа прямо: владелец панели упирается в отказ, а способа
// удалить записи в сервисе пока нет вовсе — его приносит отдельная задача. До
// неё удаление учётной записи с записями невозможно, и это осознанный тупик.
//
// Слой стоит на удалении записи, а не на запросе к панели: панель ходит правами
// суперпользователя, и правило коллекции её не судит. Удаление при этом не
// только панельное — умолчание библиотеки разрешает вошедшему удалить свою
// учётную запись запросом, так что страж закрывает и публичную поверхность.
//
// Считаются **все** коллекции с владельцем, и перечень их живёт одним списком
// ниже. Файл переживает свою запись: шаг конвейера заводит его до сохранения, и
// потерянный захват оставляет файл с владельцем и без ссылки. Учётная запись, у
// которой остались одни такие файлы, без этого счёта удалялась бы штатно, а
// аудио становилось бы ничьим.
func GuardOwnerDeletion(app core.App) {
app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
count, err := countOwned(e.App, e.Record.Id)
if err != nil {
return err
}
if count > 0 {
// Отказ отдаётся ошибкой роутера, а не обычной: библиотека пропускает
// наружу только `*router.ApiError`, а всякую другую подменяет своим
// сообщением — «убедитесь, что запись не участвует в обязательной
// связи». Подсказка эта не просто бесполезная, а **ведущая**:
// единственная обязательная связь у задачи — файл, и владелец панели,
// поверив ей, пойдёт удалять задачи и файлы руками. То есть сделает
// ровно то необратимое, ради предотвращения чего страж и заведён.
//
// Число в отказе — не содержимое записей, а их счёт: он говорит
// владельцу панели, почему удаление не прошло, и не выносит наружу
// ничего о самих записях.
return router.NewBadRequestError(fmt.Sprintf(
"у учётной записи остались записи (%d): сервис — архив, и удаление сделало бы их ничьими",
count,
), nil)
}
return e.Next()
})
}
// ownedCollections — коллекции с колонкой владельца. Перечень живёт здесь одним
// списком, и разойтись с шагом схемы ему нельзя: пропущенная коллекция
// пропускает удаление вперёд, а наружу приезжает не наш отказ с причиной, а
// подсказка библиотеки про обязательную связь — та самая, по которой владелец
// панели пойдёт удалять записи руками.
//
// Так уже случилось однажды: `topics` завелась третьей и в списке не появилась.
var ownedCollections = []string{
migrations.RecordsCollection,
migrations.FilesCollection,
migrations.TopicsCollection,
}
// countOwned считает всё, что принадлежит учётной записи, — по всем коллекциям
// с колонкой владельца.
func countOwned(app core.App, ownerID string) (int64, error) {
var total int64
for _, collection := range ownedCollections {
count, err := app.CountRecords(collection, dbx.HashExp{"owner": ownerID})
if err != nil {
return 0, fmt.Errorf("failed to count owned records in %s: %w", collection, err)
}
total += count
}
return total, nil
}
@@ -1,167 +0,0 @@
package pocketbase
import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Страж удаления учётной записи — единственное, что стоит между владельцем
// панели и молчаливым обезличиванием чужого архива: при выключенном каскаде
// хранилище снимает ссылку и сохраняет запись без проверок.
func newAccount(t *testing.T, app core.App) *core.Record {
t.Helper()
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
require.NoError(t, err)
record := core.NewRecord(users)
record.Set("email", uuid.NewString()+"@example.test")
record.Set("verified", true)
record.Set("password", uuid.NewString())
require.NoError(t, app.Save(record))
return record
}
// newRecordOf заводит аудиозапись названного владельца.
func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord {
t.Helper()
record := &entity.AudioRecord{
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
Source: entity.SourceApi,
OwnerID: ownerID,
}
require.NoError(t, NewAudioRecordRepository(app).Create(record))
return record
}
// Учётная запись с архивом не удаляется, и отказ называет причину — иначе
// наружу приезжает подсказка библиотеки про обязательную связь, по которой
// владелец панели пойдёт удалять записи руками.
func TestGuardOwnerDeletion(t *testing.T) {
app := newTestStorage(t)
account := newAccount(t, app)
record := newRecordOf(t, app, account.Id)
err := app.Delete(account)
require.Error(t, err, "учётная запись с архивом не удаляется")
assert.Contains(t, err.Error(), "остались записи", "отказ называет причину")
after, err := NewAudioRecordRepository(app).Get(record.Id)
require.NoError(t, err, "запись на месте")
assert.Equal(t, account.Id, after.OwnerID, "и владелец у неё прежний")
}
// Считаются все коллекции с владельцем, а не одни записи: файл переживает свою
// запись, а тема живёт в словаре человека.
func TestGuardOwnerDeletionCountsEveryOwnedCollection(t *testing.T) {
cases := map[string]func(t *testing.T, app core.App, ownerID string){
"аудиозапись": func(t *testing.T, app core.App, ownerID string) {
newRecordOf(t, app, ownerID)
},
"один файл без записи": func(t *testing.T, app core.App, ownerID string) {
repo := NewFileRepository(app)
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID)
require.NoError(t, err)
},
"одна тема словаря": func(t *testing.T, app core.App, ownerID string) {
topics, err := app.FindCollectionByNameOrId(migrations.TopicsCollection)
require.NoError(t, err)
topic := core.NewRecord(topics)
topic.Set("owner", ownerID)
topic.Set("name", "личная тема")
require.NoError(t, app.Save(topic))
},
}
for name, own := range cases {
t.Run(name, func(t *testing.T) {
app := newTestStorage(t)
account := newAccount(t, app)
own(t, app, account.Id)
err := app.Delete(account)
require.Error(t, err, "учётная запись с этим добром не удаляется")
assert.Contains(t, err.Error(), "остались записи",
"отказ наш, а не подсказка библиотеки про обязательную связь")
})
}
}
// Учётная запись, за которой ничего не числится, удаляется штатно: страж
// заведён против потери архива, а не против удаления вообще.
func TestGuardOwnerDeletionLetsEmptyAccountGo(t *testing.T) {
app := newTestStorage(t)
account := newAccount(t, app)
require.NoError(t, app.Delete(account), "пустая учётная запись удаляется")
}
// Колонка владельца пустого значения не принимает и умолчания не имеет:
// ничьей записи в хранилище не бывает, и завести её нечем — ни приёмом, ни
// конвейером, ни рукой в панели.
func TestOwnerColumnRefusesEmptyValue(t *testing.T) {
app := newTestStorage(t)
for _, name := range []string{migrations.RecordsCollection, migrations.FilesCollection} {
t.Run(name, func(t *testing.T) {
collection, err := app.FindCollectionByNameOrId(name)
require.NoError(t, err)
field := collection.Fields.GetByName("owner")
require.NotNil(t, field, "колонка владельца заведена")
relation, ok := field.(*core.RelationField)
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
assert.True(t, relation.Required, "пустое значение колонка не принимает")
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом")
})
}
}
// Та же норма со стороны сохранения: схема отвергает запись без владельца, а не
// только объявляет колонку обязательной.
func TestStorageRefusesRecordWithoutOwner(t *testing.T) {
app := newTestStorage(t)
record := &entity.AudioRecord{
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
Source: entity.SourceApi,
}
require.Error(t, NewAudioRecordRepository(app).Create(record),
"ничья запись в хранилище не ложится")
}
// И файл — наравне с записью: разное правило у них читалось бы как недосмотр.
func TestStorageRefusesFileWithoutOwner(t *testing.T) {
app := newTestStorage(t)
repo := NewFileRepository(app)
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, "")
require.Error(t, err, "ничей файл в хранилище не ложится")
}
-131
View File
@@ -1,131 +0,0 @@
package pocketbase
import (
"errors"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// BindPanelRules подчиняет правку записи в панели тем же правилам, что и правку
// из кода.
//
// Панель — вход в запись наравне с конвейером, а не окно просмотра: ради правки
// она и покупалась, остановленная запись возвращается в работу снятием признака.
// Но правка полем идёт мимо кода, который чистит служебные поля, и владелец,
// «вернувший запись в работу», получил бы запись с прежним признаком захвата
// (захвату она не выдастся до конца срока), с числом отказов на пределе
// (остановится от первого же отказа) и со старым временем входа в рубеж
// (остановится снова первым же захватом по пределу простоя). Узнать об этом ему
// неоткуда.
//
// Правило живёт **одним местом** — доменными `Resume` и `MoveToState`, — и хук
// зовёт именно их, а не повторяет перечень служебных полей колонками. Повтор
// перечня был бы вторым домом того же правила: новый сторож попал бы в домен и
// не попал в панель, и владелец «вернул бы запись в работу», а она снова выпала
// бы из выборки — молча.
//
// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное
// событие не различает, кто пишет, и срабатывало бы на каждом переходе
// конвейера: тогда пауза, поставленная шагом вместе со сменой рубежа, стиралась
// бы тем же сохранением, а число отказов остановленной записи — которое
// остановка хранит намеренно — приходило бы владельцу нулём.
func BindPanelRules(app core.App) {
bindProviderLoginIsImmutable(app)
app.OnRecordUpdateRequest(migrations.RecordsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
original := e.Record.Original()
if original == nil {
return e.Next()
}
stateChanged := original.GetString("state") != e.Record.GetString("state")
// Снятие признака остановки — то самое движение, ради которого признак и
// заведён: запись возвращается в работу с сохранённого рубежа.
resumed := !original.GetDateTime("halted_at").IsZero() &&
e.Record.GetDateTime("halted_at").IsZero()
if !stateChanged && !resumed {
return e.Next()
}
// Запись читается уже с правкой человека: рубеж здесь тот, который он
// выбрал, а признак остановки — тот, который он снял или оставил.
record := recordToAudioRecord(e.Record)
switch {
case resumed:
record.Resume()
default:
record.MoveToState(record.State)
}
applyOwnedByPipeline(e.Record, record)
if err := e.Next(); err != nil {
return err
}
if resumed {
// Перезапуск виден в журнале событий с указанием, что его сделал
// человек: иначе запись, вернувшаяся в работу, выглядела бы как
// запись, которая туда и не уходила.
//
// Строка пишется **после** сохранения: событие о правке, которая не
// прошла, соврало бы о состоянии записи. Отказ записи журнала саму
// правку не отменяет — журнал никем не читается ради решения.
event := &entity.RecordEvent{
RecordID: e.Record.Id,
Origin: entity.EventOriginHuman,
Step: "resume",
Outcome: entity.EventOutcomeResumed,
}
if err := NewRecordEventRepository(e.App).Append(event); err != nil {
e.App.Logger().Error("Failed to log record resume", "error", err, "record_id", e.Record.Id)
}
}
return nil
})
}
// bindProviderLoginIsImmutable запрещает менять ключ учётной записи после
// заведения.
//
// Ключ — логин человека у провайдера, и по нему сервис узнаёт пришедшего.
// Переписанный, он отдаёт весь архив прежнего владельца следующему, кто придёт
// с этим именем: владелец записи назначается один раз и не меняется, так что
// вернуть архив будет нечем. Молча — журнала событий у коллекции пользователей
// нет.
//
// Правила доступа коллекции закрывают этот путь **снаружи**, но не изнутри:
// панель работает суперпользователем и правила обходит по построению. Отсюда
// хук, и он вешается на **модельное** событие, а не на правку запросом — иначе
// панель осталась бы незакрытой, а закрывать её и есть весь смысл.
//
// Заведение проходит: событие правки на нём не срабатывает вовсе.
//
// Прежнее значение читается **из базы**, а не из снимка правящейся записи.
// Снимок у записи, только что заведённой в этом же процессе, пуст — он не
// обновляется сохранением, — и сторож, опирающийся на него, пропускал бы правку
// в зависимости от того, откуда вызывающий взял запись. Панель её загружает, и
// на ней сторож сработал бы; молчаливая же зависимость от способа получения —
// ровно тот класс, из-за которого правило и заводится.
func bindProviderLoginIsImmutable(app core.App) {
app.OnRecordUpdate(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
stored, err := e.App.FindRecordById(migrations.UsersCollection, e.Record.Id)
if err != nil {
// Записи в базе нет — правки тоже нет: сохранение отвергнется само.
return e.Next()
}
was := stored.GetString(migrations.ProviderLoginField)
now := e.Record.GetString(migrations.ProviderLoginField)
if was != "" && was != now {
return errors.New("provider login is assigned once and never changes")
}
return e.Next()
})
}
@@ -1,240 +0,0 @@
package pocketbase
import (
"testing"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Панель — единственный сегодня путь вернуть остановленную запись в работу, и
// хук правил стоит на правке **запросом**. Модельное сохранение его не трогает,
// поэтому проверки ниже идут через запрос — иначе они зеленели бы, не касаясь
// того пути, которым владелец и ходит.
// newPanelStorage поднимает хранилище с повешенными правилами панели — так же,
// как это делает сборка сервиса. Без них проверки судили бы хранилище без
// правил, то есть не то, что работает в проде.
func newPanelStorage(t *testing.T) core.App {
t.Helper()
app := newTestStorage(t)
BindPanelRules(app)
return app
}
// updateByRequest правит запись так, как это делает панель: запросом, а не
// сохранением модели.
func updateByRequest(t *testing.T, app core.App, recordID string, body map[string]any) *core.Record {
t.Helper()
record, err := app.FindRecordById(migrations.RecordsCollection, recordID)
require.NoError(t, err)
// Запись, прочитанная из хранилища, помнит прежние значения сама — по ним
// хук и отличает смену рубежа от правки соседнего поля.
for key, value := range body {
record.Set(key, value)
}
// Событие правки запросом несёт и запрос, и коллекцию: `RequestEvent` вложен
// указателем, а по коллекции хук и отбирается — без неё он не сработает вовсе,
// и проверка зеленела бы, не коснувшись правила.
collection, err := app.FindCollectionByNameOrId(migrations.RecordsCollection)
require.NoError(t, err)
event := &core.RecordRequestEvent{RequestEvent: &core.RequestEvent{}}
event.App = app
event.Collection = collection
event.Record = record
require.NoError(t, app.OnRecordUpdateRequest(migrations.RecordsCollection).Trigger(event, func(e *core.RecordRequestEvent) error {
return e.App.Save(e.Record)
}))
after, err := app.FindRecordById(migrations.RecordsCollection, recordID)
require.NoError(t, err)
return after
}
// haltedRecord заводит остановленную запись со всеми накопленными сторожами —
// такой её видит владелец, открывая панель.
func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord {
t.Helper()
record := newRecordOf(t, app, newAccount(t, app).Id)
record.MoveToState(entity.StateNormalized)
record.Attempts = 4
record.AcquisitionID = ptrOf("прежний-захват")
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
record.DelayTime = ptrOf(clock.Now().Add(time.Hour))
record.Halt(entity.HaltReasonStepFailed, "сбой конвертации файла")
// Время входа в рубеж отодвигаем: запись простояла остановленной дольше
// предела простоя, и это ровно тот случай, ради которого сторож сбрасывается.
record.StateEnteredAt = clock.Now().Add(-24 * time.Hour)
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
return record
}
func ptrOf[T any](v T) *T { return &v } //nolint:newexpr // значение вычисляется, new(x) его не примет
// Снятие признака остановки возвращает запись в работу с сохранённого рубежа и
// сбрасывает **всех** сторожей. Без сброса времени входа в рубеж запись,
// простоявшая остановленной дольше предела, остановилась бы снова первым же
// захватом — и владелец не узнал бы об этом.
func TestPanelResumeClearsEveryGuard(t *testing.T) {
app := newPanelStorage(t)
record := haltedRecord(t, app)
after := updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""})
assert.Equal(t, entity.StateNormalized, after.GetString("state"), "рубеж сохранён")
assert.True(t, after.GetDateTime("halted_at").IsZero(), "признак остановки снят")
assert.Empty(t, after.GetString("halt_reason"), "причина снята вместе с ним")
assert.Empty(t, after.GetString("error_text"), "и текст отказа")
assert.Empty(t, after.GetString("acquisition_id"), "признак прежнего захвата очищен")
assert.True(t, after.GetDateTime("acquire_expires_at").IsZero(), "срок протухания тоже")
assert.True(t, after.GetDateTime("delay_time").IsZero(), "пауза снята")
assert.Equal(t, 0, after.GetInt("attempts"), "отказы сброшены")
entered := after.GetDateTime("state_entered_at").Time()
assert.WithinDuration(t, clock.Now(), entered, time.Minute,
"время входа в рубеж поставлено заново: иначе сторож простоя остановит запись снова")
// И ближайший захват её выдаёт — то есть перезапуск действительно работает.
acquired, err := NewAudioRecordRepository(app).FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "запись вернулась в выборку")
assert.Equal(t, record.Id, acquired.ID)
}
// Перезапуск виден в журнале событий с указанием, что его сделал человек: иначе
// запись, вернувшаяся в работу, выглядела бы как запись, которая туда и не
// уходила.
func TestPanelResumeIsLogged(t *testing.T) {
app := newPanelStorage(t)
record := haltedRecord(t, app)
updateByRequest(t, app, record.Id, map[string]any{"halted_at": ""})
events, err := app.FindAllRecords(migrations.RecordEventsCollection)
require.NoError(t, err)
var human int
for _, event := range events {
if event.GetString("record") == record.Id && event.GetString("origin") == entity.EventOriginHuman {
human++
assert.Equal(t, entity.EventOutcomeResumed, event.GetString("outcome"))
}
}
assert.Equal(t, 1, human, "ровно одна строка о перезапуске человеком")
}
// Правка рубежа руками чистит служебные поля прошлого захвата так же, как
// снятие остановки: иначе владелец, «вернувший запись в работу» сменой рубежа,
// получит запись, которая не выдаётся захвату до конца прежнего срока.
func TestPanelStateEditClearsGuards(t *testing.T) {
app := newPanelStorage(t)
record := newRecordOf(t, app, newAccount(t, app).Id)
record.Attempts = 4
record.AcquisitionID = ptrOf("прежний-захват")
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
after := updateByRequest(t, app, record.Id, map[string]any{"state": entity.StateNormalized})
assert.Equal(t, entity.StateNormalized, after.GetString("state"))
assert.Empty(t, after.GetString("acquisition_id"))
assert.Equal(t, 0, after.GetInt("attempts"))
}
// Правка соседнего поля служебных полей не трогает: хук судит смену рубежа и
// снятие остановки, а не всякое сохранение. Иначе владелец, поправивший
// заголовок, снял бы захват у работающего шага.
func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) {
app := newPanelStorage(t)
record := newRecordOf(t, app, newAccount(t, app).Id)
record.Attempts = 3
record.AcquisitionID = ptrOf("живой-захват")
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
after := updateByRequest(t, app, record.Id, map[string]any{"title": "Разговор с бабушкой"})
assert.Equal(t, "Разговор с бабушкой", after.GetString("title"))
assert.Equal(t, "живой-захват", after.GetString("acquisition_id"), "захват работающего шага не снят")
assert.Equal(t, 3, after.GetInt("attempts"), "отказы не сброшены")
}
// Захват отдаёт идентификатор и признак **этого** захвата, а срок протухания
// приезжает с рубежом: воркер не привязан к шагу и вывести срок из себя не
// может.
func TestAcquireCarriesStageDeadline(t *testing.T) {
app := newTestStorage(t)
repo := NewAudioRecordRepository(app)
record := newRecordOf(t, app, newAccount(t, app).Id)
acquired, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
require.Equal(t, record.Id, acquired.ID)
require.NotEmpty(t, acquired.Holder)
stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id)
require.NoError(t, err)
assert.Equal(t, acquired.Holder, stored.GetString("acquisition_id"))
stage, ok := entity.StageByName(entity.StateUploaded)
require.True(t, ok)
expected := clock.Now().Add(stage.AcquireTimeout)
assert.WithinDuration(t, expected, stored.GetDateTime("acquire_expires_at").Time(), time.Minute,
"срок протухания приехал с рубежа записи")
}
// Одна запись достаётся ровно одному захвату: на этом стоит инвариант «Принятая
// запись не теряется молча».
func TestAcquireHandsRecordToExactlyOne(t *testing.T) {
app := newTestStorage(t)
repo := NewAudioRecordRepository(app)
newRecordOf(t, app, newAccount(t, app).Id)
first, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "первому запись досталась")
require.NotEmpty(t, first.Holder)
for range 2 {
_, err = repo.FindAndAcquire(entity.WorkingStages())
require.Error(t, err, "остальным — признак «работы нет»")
}
}
// Протухший захват возвращает запись в работу, и признак нового захвата
// отличается от прежнего: условие записи результата сверяет именно значение.
func TestRottenAcquisitionIsHandedOutAgain(t *testing.T) {
app := newTestStorage(t)
repo := NewAudioRecordRepository(app)
record := newRecordOf(t, app, newAccount(t, app).Id)
first, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
stored, err := app.FindRecordById(migrations.RecordsCollection, record.Id)
require.NoError(t, err)
stored.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour))
require.NoError(t, app.Save(stored))
second, err := repo.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "протухший захват не мешает выдать запись следующему")
assert.Equal(t, record.Id, second.ID)
assert.NotEqual(t, first.Holder, second.Holder, "признак нового захвата отличается от прежнего")
}
@@ -1,161 +0,0 @@
package pocketbase
import (
"errors"
"fmt"
"io"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type RecognitionRepository struct {
app core.App
}
func NewRecognitionRepository(app core.App) *RecognitionRepository {
return &RecognitionRepository{app: app}
}
// Create заводит строку попытки **до** обращения к провайдеру.
//
// Порядок здесь несущий: окно между ответом провайдера и записью идентификатора
// операции — то место, где теряется оплаченное. Заведённая заранее строка даёт
// повторному шагу, чем проверить сделанное прежде, чем платить второй раз.
func (repo *RecognitionRepository) Create(r *entity.Recognition) error {
collection, err := findCollection(repo.app, migrations.RecognitionsCollection)
if err != nil {
return err
}
started := clock.Now()
record := core.NewRecord(collection)
record.Set("record", r.RecordID)
record.Set("provider", r.Provider)
record.Set("model", r.Model)
record.Set("external_id", r.ExternalID)
record.Set("source_uri", r.SourceURI)
record.Set("started_at", dateOrEmpty(&started))
if err := repo.app.Save(record); err != nil {
return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err)
}
r.Id = record.Id
r.StartedAt = &started
return nil
}
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
// последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз
// наружу не платит.
func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error {
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
if err != nil {
return fmt.Errorf("failed to find recognition attempt %s: %w", id, err)
}
record.Set("source_uri", sourceURI)
record.Set("external_id", externalID)
if err := repo.app.Save(record); err != nil {
return fmt.Errorf("failed to store operation id of attempt %s: %w", id, err)
}
return nil
}
// Finish кладёт сырой ответ провайдера вложением и отмечает завершение.
//
// Вложением, а не колонкой: шаг опроса читает эту строку раз в несколько секунд,
// а хранилище читает запись целиком — ответ на многочасовую запись ехал бы в
// память при каждом опросе. Хранится он потому, что результат операции у
// провайдера не переспрашивается.
func (repo *RecognitionRepository) Finish(id string, raw []byte) error {
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
if err != nil {
return fmt.Errorf("failed to find recognition attempt %s: %w", id, err)
}
if len(raw) > 0 {
// Имя вложения задаём мы: умолчание хранилища строит его из имени
// исходного файла, а имя, данное отправителем, в хранилище не попадает.
payload, err := filesystem.NewFileFromBytes(raw, id+".payload")
if err != nil {
return fmt.Errorf("failed to prepare provider payload of attempt %s", id)
}
record.Set("payload", payload)
}
finished := clock.Now()
record.Set("finished_at", dateOrEmpty(&finished))
if err := repo.app.Save(record); err != nil {
// Отказ хранилища несёт имя файла вложения целиком, а оно — последняя
// часть ссылки: цепочка `%w` уехала бы в журнал вместе с ним.
return fmt.Errorf("failed to store provider payload of attempt %s", id)
}
return nil
}
func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) {
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
}
return &entity.Recognition{
Id: record.Id,
RecordID: record.GetString("record"),
Provider: record.GetString("provider"),
Model: record.GetString("model"),
ExternalID: record.GetString("external_id"),
SourceURI: record.GetString("source_uri"),
StartedAt: timeOrNil(record.GetDateTime("started_at")),
FinishedAt: timeOrNil(record.GetDateTime("finished_at")),
}, nil
}
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ
// нужен: шаг опроса читает строку попытки без него.
func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) {
record, err := repo.app.FindRecordById(migrations.RecognitionsCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to find recognition attempt %s: %w", id, err)
}
names := record.GetStringSlice("payload")
if len(names) == 0 {
return nil, fmt.Errorf("recognition attempt %s has no stored payload", id)
}
fsys, err := repo.app.NewFilesystem()
if err != nil {
return nil, fmt.Errorf("failed to open storage filesystem: %w", err)
}
reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + names[0])
if err != nil {
// Отказ хранилища несёт имя вложения целиком, а имя — последняя часть
// ссылки на скачивание: наружу идёт идентификатор попытки, и только он.
return nil, errors.Join(
fmt.Errorf("failed to read stored payload of attempt %s", id),
fsys.Close(),
)
}
raw, readErr := io.ReadAll(reader)
closeErr := errors.Join(reader.Close(), fsys.Close())
if readErr != nil {
return nil, errors.Join(
fmt.Errorf("failed to read stored payload of attempt %s", id),
closeErr,
)
}
if closeErr != nil {
return nil, closeErr
}
return raw, nil
}
@@ -1,50 +0,0 @@
package pocketbase
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type RecordEventRepository struct {
app core.App
}
func NewRecordEventRepository(app core.App) *RecordEventRepository {
return &RecordEventRepository{app: app}
}
// Append пишет строку журнала событий записи.
//
// Журнал пишется на смену рубежа, на остановку и на снятие остановки, а не на
// каждое откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни
// один шаг конвейера его не читает, чтобы решить, что делать дальше: решение
// принимается по рубежу записи, и второй источник решения разошёлся бы с первым
// молча.
//
// Содержимое записи сюда не попадает — инвариант приватности действует здесь
// наравне с журналом сервиса.
func (repo *RecordEventRepository) Append(event *entity.RecordEvent) error {
collection, err := findCollection(repo.app, migrations.RecordEventsCollection)
if err != nil {
return err
}
record := core.NewRecord(collection)
record.Set("record", event.RecordID)
record.Set("origin", event.Origin)
record.Set("step", event.Step)
record.Set("outcome", event.Outcome)
record.Set("outcome_text", event.OutcomeText)
record.Set("duration_ms", event.DurationMs)
if err := repo.app.Save(record); err != nil {
return fmt.Errorf("failed to append event of record %s: %w", event.RecordID, err)
}
event.Id = record.Id
return nil
}
@@ -1,155 +0,0 @@
package pocketbase
import (
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Отображение аудиозаписи в запись коллекции и обратно живёт одним местом.
//
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
// колонки поимённо, а возвращает идентификатор и признак своего захвата.
// Инвариант проекта о колонках очереди этим съёживается и перестаёт расти с
// моделью — иначе каждая новая колонка записи попадала бы под него.
// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается
// конвейер. Поля, которые он не меняет никогда — владелец, вход, заголовок,
// краткое описание, темы и адресат ответа, — не трогаются вовсе.
//
// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до
// своего сохранения, а это часы. Всё, что владелец правил в панели за это время,
// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа в
// панели — владелец видел бы успешное сохранение и был бы уверен, что правка на
// месте.
func applyOwnedByPipeline(record *core.Record, r *entity.AudioRecord) {
record.Set("state", r.State)
record.Set("state_entered_at", dateOrEmpty(&r.StateEnteredAt))
record.Set("halted_at", dateOrEmpty(r.HaltedAt))
record.Set("halt_reason", derefString(r.HaltReason))
record.Set("error_text", derefString(r.ErrorText))
record.Set("acquisition_id", derefString(r.AcquisitionID))
record.Set("acquire_expires_at", dateOrEmpty(r.AcquireExpiresAt))
record.Set("delay_time", dateOrEmpty(r.DelayTime))
record.Set("attempts", r.Attempts)
record.Set("original_file", derefString(r.OriginalFileID))
record.Set("normalized_file", derefString(r.NormalizedFileID))
record.Set("transcript_text", derefString(r.TranscriptTextID))
record.Set("literary_text", derefString(r.LiteraryTextID))
record.Set("structure", derefString(r.StructureID))
record.Set("recognition", derefString(r.RecognitionID))
}
// applyToRecord кладёт запись целиком — это заведение, и спорить за поля здесь
// не с кем.
func applyToRecord(record *core.Record, r *entity.AudioRecord) {
applyOwnedByPipeline(record, r)
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
// записанный поверх, стёр бы его молча.
record.Set("owner", r.OwnerID)
record.Set("source", r.Source)
record.Set("title", derefString(r.Title))
record.Set("brief", derefString(r.Brief))
// Имя файла отправителя, длительность и размер кладёт приём и только он: это
// снимок принятого, и конвейер его не пересчитывает. В applyOwnedByPipeline их
// нет намеренно — снимок шага, записанный поверх, стёр бы их молча.
record.Set("original_filename", derefString(r.OriginalFilename))
record.Set("duration_ms", numberOrZero(r.DurationMs))
record.Set("size_bytes", numberOrZero(r.SizeBytes))
// Темы кладутся при заведении пустыми и конвейером не трогаются: считает их
// языковая модель отдельной задачей. Пишутся здесь ради симметрии с чтением —
// колонка, которую читают и не пишут, ничем не отличима от забытой.
record.Set("topics", r.TopicIDs)
}
func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
return &entity.AudioRecord{
Id: record.Id,
OwnerID: record.GetString("owner"),
Source: record.GetString("source"),
Title: nilIfEmpty(record.GetString("title")),
Brief: nilIfEmpty(record.GetString("brief")),
State: record.GetString("state"),
StateEnteredAt: record.GetDateTime("state_entered_at").Time(),
HaltedAt: timeOrNil(record.GetDateTime("halted_at")),
HaltReason: nilIfEmpty(record.GetString("halt_reason")),
ErrorText: nilIfEmpty(record.GetString("error_text")),
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
AcquireExpiresAt: timeOrNil(record.GetDateTime("acquire_expires_at")),
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
Attempts: record.GetInt("attempts"),
OriginalFileID: nilIfEmpty(record.GetString("original_file")),
NormalizedFileID: nilIfEmpty(record.GetString("normalized_file")),
TranscriptTextID: nilIfEmpty(record.GetString("transcript_text")),
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
StructureID: nilIfEmpty(record.GetString("structure")),
RecognitionID: nilIfEmpty(record.GetString("recognition")),
OriginalFilename: nilIfEmpty(record.GetString("original_filename")),
// Имя колонки стоит литералом рядом с `.Get…`, а не уезжает в аргумент
// помощника: сверка колонок в `internal/archrules` ищет именно эту форму, а
// инвариант о колонках компилятор не проверяет.
DurationMs: numberValue(record.GetInt("duration_ms")),
SizeBytes: numberValue(record.GetInt("size_bytes")),
TopicIDs: record.GetStringSlice("topics"),
CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(),
}
}
func derefString(v *string) string {
if v == nil {
return ""
}
return *v
}
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
// хранилище это пустая строка, и она же значит «времени нет».
func dateOrEmpty(v *time.Time) any {
if v == nil || v.IsZero() {
return ""
}
date, err := types.ParseDateTime(*v)
if err != nil {
return ""
}
return date
}
// numberOrZero отдаёт ноль вместо отсутствующего числа.
//
// «Неизвестно» числовая колонка хранилища не выражает вовсе: пустое значение она
// не держит и кладёт нулём. Отличимость потребовала бы четвёртой колонки-признака
// либо текстового типа у чисел, и платить за это нечем — обе величины ставит
// приём и ставит всегда. Решение владельца 2026-08-15.
func numberOrZero(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
// numberValue читает колонку числом. Ноль здесь означает ноль — см. numberOrZero.
func numberValue(value int) *int64 {
v := int64(value)
return &v
}
func nilIfEmpty(v string) *string {
if v == "" {
return nil
}
return &v
}
func timeOrNil(v types.DateTime) *time.Time {
if v.IsZero() {
return nil
}
t := v.Time()
return &t
}
@@ -1,236 +0,0 @@
package pocketbase
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
type AudioRecordRepository struct {
app core.App
}
func NewAudioRecordRepository(app core.App) *AudioRecordRepository {
return &AudioRecordRepository{app: app}
}
func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
collection, err := findCollection(repo.app, migrations.RecordsCollection)
if err != nil {
return err
}
record := core.NewRecord(collection)
if r.Id != "" {
record.Id = r.Id
}
applyToRecord(record, r)
if err := repo.app.Save(record); err != nil {
return fmt.Errorf("failed to insert audio record: %w", err)
}
r.Id = record.Id
r.CreatedAt = record.GetDateTime("created").Time()
r.UpdatedAt = record.GetDateTime("updated").Time()
return nil
}
// Save сохраняет запись, захват которой держит holder. Проверка и запись идут
// одной транзакцией: шаг, потерявший запись за время работы, получает
// LostAcquisitionError и результата не пишет.
//
// Сверяется **значение** признака захвата, а не занятость записи. Захват,
// перевыданный другому — по протуханию срока или после того, как человек снял
// признак остановки в панели, — обязан обратить запись первого в отказ; условие
// по непустоте признака пропустило бы обоих, и два шага записали бы в одну
// запись по очереди, портя её результат.
func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
return repo.app.RunInTransaction(func(txApp core.App) error {
record, err := txApp.FindRecordById(migrations.RecordsCollection, r.Id)
if err != nil {
return fmt.Errorf("failed to find audio record: %w", err)
}
if holder != "" && record.GetString("acquisition_id") != holder {
return &contract.LostAcquisitionError{JobID: r.Id}
}
// Кладём только то, чем распоряжается конвейер: правку владельца в
// панели снимок шага стирать не должен.
applyOwnedByPipeline(record, r)
if err := txApp.Save(record); err != nil {
return fmt.Errorf("failed to update audio record: %w", err)
}
r.UpdatedAt = record.GetDateTime("updated").Time()
return nil
})
}
// GetByID отдаёт запись, только если её владелец — ownerID.
//
// Чужая запись, запись без владельца и несуществующая дают одну и ту же ошибку:
// по разнице ответов иначе перебирается список заведённых записей, а
// идентификатор записи и есть то, что разграничение прячет.
//
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило это
// не стало избыточным с обязательностью колонки: схема запрещает **заводить**
// ничью запись, а здесь запрещено **спрашивать** ничьим именем — иначе
// вызывающий без учётной записи получил бы выборку вместо отказа.
func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
if ownerID == "" {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
record, err := repo.find(id)
if err != nil {
return nil, err
}
if record.GetString("owner") != ownerID {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
return recordToAudioRecord(record), nil
}
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья выборка
// владельцем не сужается.
func (repo *AudioRecordRepository) Get(id string) (*entity.AudioRecord, error) {
record, err := repo.find(id)
if err != nil {
return nil, err
}
return recordToAudioRecord(record), nil
}
func (repo *AudioRecordRepository) find(id string) (*core.Record, error) {
record, err := repo.app.FindRecordById(migrations.RecordsCollection, id)
if err != nil {
// «Такой записи нет» переводится в доменную ошибку **здесь**, у
// источника, как велит конвенция об ошибках. Иначе три исхода, которые
// разграничение обязано сделать неразличимыми, разъезжаются: чужая и
// ничья записи дают доменную ошибку, а несуществующая — отказ базы,
// неотличимый от настоящей аварии хранилища.
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
return nil, fmt.Errorf("failed to get audio record: %w", err)
}
return record, nil
}
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом:
// выбор подходящей и пометка её захваченной идут вместе.
//
// Возвращается **идентификатор и признак этого захвата**, а не перечень колонок.
// Колонки шаг читает обычным чтением: иначе всякая новая колонка записи попадала
// бы под инвариант проекта о колонках очереди, а забытая приезжала бы нулевой, и
// первое же сохранение писало бы этот ноль поверх сохранённого значения.
//
// Срок протухания захвата приезжает **с рубежом**, а не с воркером: воркер не
// привязан к шагу и не знает заранее, что вытянет. Перечень рубежей и их сроков
// приходит одним дескриптором — перечислять их порознь нельзя: рубеж, забытый в
// отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
// проекта не пишется в журнал и не считается в метрику.
//
// Запрос идёт сырым, мимо записей коллекции: `app.DB()` направляет всё, кроме
// выборок, в пул с единственным соединением, и захваты выстраиваются в очередь.
// Хуки коллекции на нём не срабатывают, поэтому время изменения проставляет сам
// запрос.
//
// Все времена кладутся и сравниваются тем же видом, каким хранилище пишет свои
// `created`/`updated`: сравнение строк побайтово, и вид, разошедшийся хоть
// разделителем, обратил бы условие срока в постоянную истину или постоянную
// ложь — молча.
func (repo *AudioRecordRepository) FindAndAcquire(stages []entity.Stage) (*contract.AcquiredRecord, error) {
if len(stages) == 0 {
return nil, &contract.JobNotFoundError{Message: "no working stages declared"}
}
// Метка времени берётся единой точкой, а не `types.NowDateTime()`: обёртка
// хранилища читает часы сама, и запрет линтера её не видит — новая метка в
// этом запросе обошла бы единую точку молча.
now, err := types.ParseDateTime(clock.Now())
if err != nil {
return nil, fmt.Errorf("failed to parse current time: %w", err)
}
holder := uuid.NewString()
params := dbx.Params{
"holder": holder,
"now": now.String(),
}
// Срок протухания у каждого рубежа свой, поэтому он выбирается по рубежу
// самой записи прямо в запросе: воркер, ещё не знающий, что вытянет,
// подставить его не может.
var expiry strings.Builder
expiry.WriteString("CASE state")
var states []string
for i, stage := range stages {
stateKey := fmt.Sprintf("state%d", i)
expiryKey := fmt.Sprintf("expiry%d", i)
deadline, err := types.ParseDateTime(clock.Now().Add(stage.AcquireTimeout))
if err != nil {
return nil, fmt.Errorf("failed to parse acquire deadline: %w", err)
}
fmt.Fprintf(&expiry, " WHEN {:%s} THEN {:%s}", stateKey, expiryKey)
params[stateKey] = stage.Name
params[expiryKey] = deadline.String()
states = append(states, "{:"+stateKey+"}")
}
expiry.WriteString(" END")
table := "{{" + migrations.RecordsCollection + "}}"
query := repo.app.DB().NewQuery(`
UPDATE ` + table + `
SET acquisition_id = {:holder},
acquire_expires_at = ` + expiry.String() + `,
attempts = attempts + 1,
updated = {:now}
WHERE id = (
SELECT id FROM ` + table + `
WHERE state IN (` + strings.Join(states, ", ") + `)
AND (halted_at = '' OR halted_at IS NULL)
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
AND (acquisition_id = '' OR acquisition_id IS NULL
OR acquire_expires_at = '' OR acquire_expires_at IS NULL
OR acquire_expires_at < {:now})
ORDER BY created, id
LIMIT 1
)
RETURNING id`)
query.Bind(params)
var row struct {
Id string `db:"id"`
}
if err := query.One(&row); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "no record is ready for work"}
}
return nil, fmt.Errorf("failed to acquire an audio record: %w", err)
}
return &contract.AcquiredRecord{ID: row.Id, Holder: holder}, nil
}
@@ -1,139 +0,0 @@
package pocketbase
import (
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
// тем же путём, каким это делает сервис при старте.
func newTestStorage(t *testing.T) core.App {
t.Helper()
app, err := New(t.TempDir())
require.NoError(t, err)
t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err)
}
})
return app
}
// Критерий приёмки 10. Содержимое записи закрыто во всех коллекциях, куда оно
// переехало.
//
// Прежде содержимое лежало одной колонкой задачи, и закрывала его одна норма про
// файл записи. Теперь оно живёт в шести коллекциях, и реализация, следующая
// только прежней норме, завела бы поле вложения с умолчанием библиотеки: ссылка
// на сырой ответ провайдера — а это полный текст речи — отдавала бы его любому,
// кто её знает, без сессии.
func TestRecordContentIsClosedEverywhere(t *testing.T) {
app := newTestStorage(t)
// Правило просмотра остаётся незаданным, то есть «только владелец панели».
// Содержимое отдаёт собственный адрес сервиса, а не поверхность хранилища;
// непустое правило открыло бы перечисление коллекции впрок.
for _, name := range []string{
migrations.RecordsCollection,
migrations.TextsCollection,
migrations.StructuresCollection,
migrations.RecognitionsCollection,
migrations.RecordEventsCollection,
migrations.TopicsCollection,
} {
collection, err := app.FindCollectionByNameOrId(name)
require.NoError(t, err, "коллекция %s заведена шагом схемы", name)
assert.Nil(t, collection.ListRule, "перечисление %s закрыто", name)
assert.Nil(t, collection.ViewRule, "чтение %s закрыто", name)
assert.Nil(t, collection.CreateRule, "заведение записи в %s закрыто", name)
assert.Nil(t, collection.UpdateRule, "правка %s закрыта", name)
assert.Nil(t, collection.DeleteRule, "удаление из %s закрыто", name)
}
// А поле вложения помечено защищённым: без пометки ссылка открывает
// содержимое любому, кто её знает, и знание ссылки становится правом.
recognitions, err := app.FindCollectionByNameOrId(migrations.RecognitionsCollection)
require.NoError(t, err)
field := recognitions.Fields.GetByName("payload")
require.NotNil(t, field, "поле сохранённого ответа заведено")
file, ok := field.(*core.FileField)
require.True(t, ok, "сохранённый ответ лежит вложением, а не колонкой")
assert.True(t, file.Protected, "поле вложения защищено")
}
// Прежняя коллекция задач уходит вместе с моделью: данных под ней не было, а
// пустая копия висела бы в панели вторым домом для понятия, которого больше нет.
// Поверхность коллекции пользователей закрыта наглухо — все пять правил.
//
// Проверка стоит отдельно от соседней намеренно: та сторожит коллекции, которые
// заводит наш шаг схемы, а эту заводит системный шаг библиотеки, и её умолчания
// открывают владельцу записи чтение, правку и удаление собственной строки. Пока
// узнавание жило под корнем приложения, до этой поверхности браузер не
// дотягивался вовсе; с узнаванием по заголовку она достижима, а ключ учётной
// записи лежит здесь обычной колонкой — правка своей записи и есть захват чужого
// имени.
func TestUsersCollectionSurfaceIsClosed(t *testing.T) {
app := newTestStorage(t)
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
require.NoError(t, err)
assert.Nil(t, users.ListRule, "перечисление учётных записей закрыто")
assert.Nil(t, users.ViewRule, "чтение учётной записи закрыто")
assert.Nil(t, users.CreateRule, "заведение учётной записи снаружи закрыто")
assert.Nil(t, users.UpdateRule, "правка учётной записи снаружи закрыта")
assert.Nil(t, users.DeleteRule, "удаление учётной записи снаружи закрыто")
// Собственные способы войти выключены там же: без этого узнавание по
// заголовку обходится двумя запросами — завести себе запись и войти паролем.
assert.False(t, users.PasswordAuth.Enabled, "вход по паролю выключен")
assert.False(t, users.OTP.Enabled, "вход по одноразовому коду выключен")
assert.False(t, users.OAuth2.Enabled, "обмен кода у внешнего провайдера выключен")
assert.Empty(t, users.OAuth2.Providers, "настроенных провайдеров не осталось")
}
func TestFormerJobsCollectionIsGone(t *testing.T) {
app := newTestStorage(t)
_, err := app.FindCollectionByNameOrId(migrations.JobsCollection)
assert.Error(t, err, "прежней коллекции задач не осталось")
}
// Пара «запись и вид» уникальна: повтор прерванного шага не заводит второго
// комплекта строк, и вопрос «какой текст отдавать человеку» не становится
// вопросом порядка записи.
func TestAppendicesAreUniquePerRecord(t *testing.T) {
app := newTestStorage(t)
indexes := map[string][]string{
migrations.TextsCollection: {"idx_texts_record_kind"},
migrations.StructuresCollection: {"idx_structures_record_version"},
migrations.TopicsCollection: {"idx_topics_owner_name"},
}
for name, expected := range indexes {
collection, err := app.FindCollectionByNameOrId(name)
require.NoError(t, err)
for _, index := range expected {
var found bool
for _, declared := range collection.Indexes {
if strings.Contains(declared, index) && strings.Contains(declared, "UNIQUE") {
found = true
}
}
assert.Truef(t, found, "у %s есть уникальный индекс %s", name, index)
}
}
}
@@ -1,170 +0,0 @@
package pocketbase
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type TextRepository struct {
app core.App
}
func NewTextRepository(app core.App) *TextRepository {
return &TextRepository{app: app}
}
// Put кладёт текст записи, заменяя прежний того же вида.
//
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния.
//
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
// умер, сохранение рубежа отказало, человек снял остановку в панели. Провайдер
// при этом вправе ответить пустым потоком, отказом это не считается, и
// безусловная замена стирала бы сохранённую расшифровку живого человека без
// следа и без возврата. Та же защита стоит у сырого ответа провайдера
// (`RecognitionRepository.Finish`), и разное правило у двух хранителей одного
// результата читалось бы как недосмотр.
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
collection, err := findCollection(repo.app, migrations.TextsCollection)
if err != nil {
return nil, err
}
record, err := repo.app.FindFirstRecordByFilter(
migrations.TextsCollection,
"record = {:record} && kind = {:kind}",
dbx.Params{"record": recordID, "kind": kind},
)
switch {
case err == nil:
// Строка есть — заменяем содержимое.
case errors.Is(err, sql.ErrNoRows):
record = core.NewRecord(collection)
record.Set("record", recordID)
record.Set("kind", kind)
default:
// Отказ хранилища «строкой нет» не является, и подменять его вставкой
// нельзя: она упрётся в уникальный индекс, и наверх уедет жалоба на
// запись вместо правды о недоступной базе.
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
}
// Прежнее непустое содержимое пустым не заменяется: строка остаётся как
// есть, и вызывающий получает её обратно.
if contents == "" && record.GetString("contents") != "" {
return textFromRecord(record), nil
}
record.Set("contents", contents)
if err := repo.app.Save(record); err != nil {
// Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от
// хранилища несёт значение поля.
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
}
return textFromRecord(record), nil
}
func (repo *TextRepository) GetByID(id string) (*entity.Text, error) {
record, err := repo.app.FindRecordById(migrations.TextsCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get text %s: %w", id, err)
}
return textFromRecord(record), nil
}
func textFromRecord(record *core.Record) *entity.Text {
return &entity.Text{
Id: record.Id,
RecordID: record.GetString("record"),
Kind: record.GetString("kind"),
Contents: record.GetString("contents"),
}
}
type StructureRepository struct {
app core.App
}
func NewStructureRepository(app core.App) *StructureRepository {
return &StructureRepository{app: app}
}
// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот
// же, что и у текста: повтор шага не должен заводить второй строки.
func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) {
collection, err := findCollection(repo.app, migrations.StructuresCollection)
if err != nil {
return nil, err
}
contents, err := json.Marshal(replicas)
if err != nil {
return nil, fmt.Errorf("failed to encode structure of record %s", recordID)
}
record, err := repo.app.FindFirstRecordByFilter(
migrations.StructuresCollection,
"record = {:record} && version = {:version}",
dbx.Params{"record": recordID, "version": version},
)
switch {
case err == nil:
// Строка есть — заменяем содержимое. Пустой перечень реплик поверх
// непустого не кладётся по тому же доводу, что и у текста: повторный
// опрос с пустым ответом провайдера стирал бы разбор живой записи.
if len(replicas) == 0 && len(record.GetString("contents")) > len("[]") {
return repo.GetByID(record.Id)
}
case errors.Is(err, sql.ErrNoRows):
record = core.NewRecord(collection)
record.Set("record", recordID)
record.Set("version", version)
default:
return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err)
}
record.Set("contents", string(contents))
if err := repo.app.Save(record); err != nil {
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
}
return &entity.Structure{
Id: record.Id,
RecordID: recordID,
Version: version,
Replicas: replicas,
}, nil
}
func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) {
record, err := repo.app.FindRecordById(migrations.StructuresCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get structure %s: %w", id, err)
}
var replicas []entity.Replica
raw := record.GetString("contents")
if raw != "" {
if err := json.Unmarshal([]byte(raw), &replicas); err != nil {
return nil, fmt.Errorf("failed to decode structure %s", id)
}
}
return &entity.Structure{
Id: record.Id,
RecordID: record.GetString("record"),
Version: record.GetInt("version"),
Replicas: replicas,
}, nil
}
+172
View File
@@ -0,0 +1,172 @@
// Package sqlite — хранилище сервиса: база на своей схеме и файлы записей своим
// каталогом.
//
// Пакет назван по драйверу, а не по роли: соседи в `internal/adapter` названы
// тем же способом — `converter`, `metaviewer`, `recognizer`, — и «repo/sqlite»
// читается как «репозитории поверх SQLite» без знания кода.
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
// Драйвер регистрируется загрузкой пакета. CGO ему не нужен — этим он и
// выбран: сборка бинарника остаётся без компилятора C.
_ "modernc.org/sqlite"
)
// driverName — имя, под которым драйвер регистрируется в `database/sql`.
const driverName = "sqlite"
// DatabaseFile — имя файла базы в каталоге данных. Рядом с ним драйвер кладёт
// журнал упреждающей записи и его указатель, поэтому каталог данных занят базой
// целиком, а не одним файлом.
const DatabaseFile = "transcriber.db"
// Settings — числа, которыми настраивается база. Оба приходят настройкой, а не
// константой кода: крутят их при одном и том же отказе — «база занята» под
// несколькими воркерами, — и подбор ответа на такой отказ не должен требовать
// пересборки образа.
type Settings struct {
// BusyTimeoutMs — сколько ждать занятую базу, миллисекунды.
BusyTimeoutMs int
// ReadConnections — сколько соединений держит читающий пул.
ReadConnections int
}
// Validate проверяет числа базы. Ноль и отрицательное — опечатка, а не режим:
// нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул
// чтения означает пул без предела, то есть настройку, которой не управляют.
func (s Settings) Validate() error {
if s.BusyTimeoutMs <= 0 {
return errors.New("storage: ожидание занятой базы задаётся положительным числом миллисекунд")
}
if s.ReadConnections <= 0 {
return errors.New("storage: число соединений читающего пула задаётся положительным числом")
}
return nil
}
// Обращения к базе идут с **собственным** контекстом, а не с контекстом
// запроса, и это решение, а не недосмотр. Репозитории отменять нечего: операции
// местные и короткие, а единственное ожидание — занятая база — задано числом. За
// отмену при этом платили бы дважды: шаг, прерванный остановкой сервиса,
// перестал бы освобождать захват и писать причину остановки — то есть отмена
// ломала бы ровно ту уборку, ради которой она и делается.
//
// Отмена, которой сервис распоряжается по-настоящему, доходит туда, где она
// стоит денег и времени: до `ffmpeg` и до платного распознавания.
// DB — база сервиса двумя пулами.
//
// Пишущий пул держит **одно** соединение: драйвер пишет единственным
// соединением, и несколько воркеров, пришедших писать разом мимо этого правила,
// получают отказ по занятости — на записи результата шага, то есть после
// оплаченной работы. Пул с одним соединением обращает их в очередь.
//
// Читающий пул отдельный: в журнале упреждающей записи читатели не мешают
// писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
type DB struct {
// writer — единственное пишущее соединение. Через него идёт всякая
// операция, которая читает состояние и следом его пишет: транзакцию,
// начатую на читающем соединении, SQLite до пишущей не повышает и отвечает
// отказом по занятости немедленно — заданное числом ожидание такой отказ не
// лечит, ждать там нечего.
writer *sql.DB
// reader — пул чтения.
reader *sql.DB
}
// Writer отдаёт пишущее соединение.
func (db *DB) Writer() *sql.DB { return db.writer }
// Reader отдаёт читающий пул.
func (db *DB) Reader() *sql.DB { return db.reader }
// Open открывает базу в каталоге данных, заводя каталог, если его ещё нет.
//
// Настройки соединения задаются **строкой подключения обоих пулов**, а не
// запросом после открытия. Соблюдение внешних ключей в SQLite — настройка
// соединения, а не базы, и по умолчанию она выключена; пул раздаёт соединения и
// заводит новые по мере надобности, поэтому запрос, выполненный один раз,
// настроил бы одно соединение из многих, а остальные остались бы с умолчанием —
// молча.
func Open(dataDir string, settings Settings) (*DB, error) {
if err := settings.Validate(); err != nil {
return nil, err
}
if err := os.MkdirAll(dataDir, 0o750); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err)
}
path := filepath.Join(dataDir, DatabaseFile)
// Пишущее соединение начинает транзакцию сразу пишущей (`immediate`):
// операция, которая читает и следом пишет, иначе взяла бы читающую
// транзакцию и упёрлась бы в отказ при первой же записи.
writer, err := open(path, settings, "immediate")
if err != nil {
return nil, err
}
writer.SetMaxOpenConns(1)
writer.SetMaxIdleConns(1)
reader, err := open(path, settings, "deferred")
if err != nil {
return nil, errors.Join(fmt.Errorf("failed to open read pool: %w", err), writer.Close())
}
reader.SetMaxOpenConns(settings.ReadConnections)
reader.SetMaxIdleConns(settings.ReadConnections)
db := &DB{writer: writer, reader: reader}
// Пробное обращение делается сразу: `sql.Open` соединения не открывает, и
// негодная строка подключения вылезла бы не на старте, а на первом запросе —
// то есть отказом каждого запроса вместо одной строки о причине.
if err := writer.PingContext(context.Background()); err != nil {
return nil, errors.Join(fmt.Errorf("failed to open database: %w", err), db.Close())
}
return db, nil
}
// open заводит один пул с общими настройками соединения.
func open(path string, settings Settings, txlock string) (*sql.DB, error) {
query := url.Values{}
query.Add("_pragma", "busy_timeout("+strconv.Itoa(settings.BusyTimeoutMs)+")")
query.Add("_pragma", "journal_mode(WAL)")
query.Add("_pragma", "foreign_keys(1)")
query.Set("_txlock", txlock)
db, err := sql.Open(driverName, "file:"+path+"?"+query.Encode())
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
return db, nil
}
// Close закрывает оба пула. Повторный вызов паники не даёт: закрытие уже
// закрытого пула отказом не считается.
func (db *DB) Close() error {
var errs []error
if db.reader != nil {
if err := db.reader.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close read pool: %w", err))
}
db.reader = nil
}
if db.writer != nil {
if err := db.writer.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close write pool: %w", err))
}
db.writer = nil
}
return errors.Join(errs...)
}
+554
View File
@@ -0,0 +1,554 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// testSettings — числа базы под проверками: те же по смыслу, что и умолчания
// конфига.
func testSettings() Settings {
return Settings{BusyTimeoutMs: 5000, ReadConnections: 4}
}
// newTestDB поднимает базу на пустом каталоге и накатывает схему — ровно тем же
// путём, каким это делает сервис при старте.
func newTestDB(t *testing.T) (*DB, *Store, string) {
t.Helper()
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Logf("не удалось закрыть базу: %v", err)
}
})
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
return db, NewStore(dir), dir
}
// newOwner заводит учётную запись и отдаёт её идентификатор.
func newOwner(t *testing.T, db *DB) string {
t.Helper()
account, _, err := NewUserRepository(db).EnsureUser(contract.Identity{Login: ident.New()})
require.NoError(t, err)
return account.ID
}
// Настройки соединения задаются строкой подключения **обоих** пулов: соблюдение
// внешних ключей в SQLite принадлежит соединению, а не базе, и запрос, сделанный
// один раз после открытия, настроил бы одно соединение из многих.
func TestSettingsApplyToEveryConnection(t *testing.T) {
db, _, _ := newTestDB(t)
var mode string
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA journal_mode").Scan(&mode))
assert.Equal(t, "wal", mode, "журнал упреждающей записи выключен")
var busy int
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&busy))
assert.Equal(t, testSettings().BusyTimeoutMs, busy, "ожидание занятой базы осталось умолчанием драйвера")
// Читающий пул раздаёт соединения по мере надобности, поэтому спрашиваем
// **несколько** разом: одно настроенное соединение из четырёх — ровно та
// поломка, ради которой настройка уехала в строку подключения.
var wg sync.WaitGroup
answers := make([]int, testSettings().ReadConnections)
start := make(chan struct{})
for i := range answers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
conn, err := db.Reader().Conn(context.Background())
if !assert.NoError(t, err) {
return
}
defer func() { assert.NoError(t, conn.Close()) }()
assert.NoError(t,
conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&answers[i]))
// Соединение придерживается, пока спрашивают остальные: иначе пул
// раздал бы всем одно и то же и правило проверило бы одну настройку
// вместо четырёх.
time.Sleep(10 * time.Millisecond)
}()
}
close(start)
wg.Wait()
for i, answer := range answers {
assert.Equal(t, 1, answer, "соединение %d читающего пула не соблюдает внешние ключи", i)
}
// И держатся внешние ключи **на деле**, а не только настройкой: вставка с
// несуществующим владельцем отвергается обоими пулами.
now := clock.Now().Format(timeLayout)
insert := `INSERT INTO audio_records
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`
_, err := db.Writer().ExecContext(context.Background(), insert,
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
require.Error(t, err, "пишущее соединение приняло запись с несуществующим владельцем")
_, err = db.Reader().ExecContext(context.Background(), insert,
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
require.Error(t, err, "читающее соединение приняло запись с несуществующим владельцем")
}
// Настройки проверяются на старте: ноль и отрицательное — опечатка, а не режим.
func TestSettingsAreValidated(t *testing.T) {
for name, settings := range map[string]Settings{
"нулевое ожидание": {BusyTimeoutMs: 0, ReadConnections: 4},
"нулевой пул чтения": {BusyTimeoutMs: 5000, ReadConnections: 0},
"отрицательный пул": {BusyTimeoutMs: 5000, ReadConnections: -1},
"отрицательный срок": {BusyTimeoutMs: -1, ReadConnections: 4},
} {
t.Run(name, func(t *testing.T) {
_, err := Open(t.TempDir(), settings)
assert.Error(t, err, "старт на негодном числе прошёл молча")
})
}
}
// Повторный запуск на заведённом каталоге схему второй раз не заводит и прежних
// записей не теряет.
func TestMigrateIsIdempotent(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
owner := newOwner(t, db)
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
var login string
require.NoError(t, db.Reader().
QueryRowContext(context.Background(),
"SELECT provider_login FROM users WHERE id = ?", owner).Scan(&login))
assert.NotEmpty(t, login, "повторный накат потерял прежние строки")
var applied int
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM goose_db_version").Scan(&applied))
assert.Equal(t, 2, applied, "шаг отмечен дважды: накат не идемпотентен")
}
// Накат держится исключающей блокировкой каталога данных: второй накат ждёт
// освобождения, а не применяет шаги параллельно.
//
// Библиотека шагов под SQLite блокировки не поставляет вовсе — её запиратели
// объявлены только для PostgreSQL, — поэтому замок наш, и проверка сторожит
// именно его.
func TestMigrationLockSerializesRuns(t *testing.T) {
dir := t.TempDir()
var (
mu sync.Mutex
inside int
overlap bool
)
hold := func() error {
mu.Lock()
inside++
if inside > 1 {
overlap = true
}
mu.Unlock()
time.Sleep(50 * time.Millisecond)
mu.Lock()
inside--
mu.Unlock()
return nil
}
var wg sync.WaitGroup
for range 3 {
wg.Add(1)
go func() {
defer wg.Done()
assert.NoError(t, withMigrationLock(dir, hold))
}()
}
wg.Wait()
assert.False(t, overlap, "два наката шли одновременно: замок не держит")
}
// Отказ шага роняет накат и называет шаг: сервис, поднявшийся на неприведённой
// схеме, отвечал бы отказом на каждый запрос.
func TestMigrateFailsLoudly(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
// Таблица уже занята чужой строкой: начальный шаг на такой базе не
// применяется.
_, err = db.Writer().ExecContext(context.Background(), "CREATE TABLE users (id TEXT)")
require.NoError(t, err)
err = Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))
require.Error(t, err, "отказ шага прошёл молча")
assert.Contains(t, err.Error(), "202608220002", "отказ не называет шаг")
// **Шаг и отметка о нём идут одной транзакцией**, поэтому отказавший шаг не
// оставляет за собой ни отметки, ни половины схемы. Полуприменённое
// состояние — то самое, из-за которого следующий запуск применил бы шаг
// второй раз и упал бы на заведённой таблице.
var version int
err = db.Reader().QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM goose_db_version WHERE version_id = 202608220002").Scan(&version)
if err == nil {
assert.Equal(t, 0, version, "отказавший шаг отмечен применённым")
}
var tables int
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'audio_records'").Scan(&tables))
assert.Equal(t, 0, tables, "отказавший шаг оставил за собой половину схемы")
}
// Все колонки времени объявлены одним типом и без умолчания: умолчание схемы
// писало бы свой вид времени, а вставка, забывшая проставить время, при нём
// прошла бы молча.
func TestSchemaHasOneTimeShapeWithoutDefaults(t *testing.T) {
db, _, _ := newTestDB(t)
tables := []string{
"users", "files", "topics", "audio_records",
"texts", "structures", "recognitions", "record_events",
}
seen := 0
for _, table := range tables {
rows, err := db.Reader().QueryContext(context.Background(),
"SELECT name, type, dflt_value FROM pragma_table_info(?)", table)
require.NoError(t, err)
for rows.Next() {
var (
name string
columnType string
dflt any
)
require.NoError(t, rows.Scan(&name, &columnType, &dflt))
if !isTimeColumn(name) {
continue
}
seen++
assert.Equal(t, "TEXT", columnType, "колонка %s.%s несёт время не текстом", table, name)
assert.Nil(t, dflt, "у колонки %s.%s есть умолчание времени", table, name)
}
require.NoError(t, rows.Err())
closeRows(t, rows)
}
require.Positive(t, seen, "колонок времени не найдено: правило потеряло предмет")
}
// closeRows закрывает выборку. Отдельной функцией, потому что закрывается она в
// цикле по таблицам: отложенное закрытие копилось бы до конца проверки.
func closeRows(t *testing.T, rows *sql.Rows) {
t.Helper()
require.NoError(t, rows.Close())
}
func isTimeColumn(name string) bool {
return strings.HasSuffix(name, "_at") || name == "delay_time"
}
// Строка, заведённая приёмом, и строка, заведённая запросом к базе, попадают в
// отбор захвата одинаково: вид времени в схеме один.
func TestHandwrittenRecordIsAcquiredToo(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
records := NewAudioRecordRepository(db)
byService := &entity.AudioRecord{
Id: ident.New(),
OwnerID: owner,
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
}
require.NoError(t, records.Create(byService))
byHand := ident.New()
now := clock.Now().Format(timeLayout)
_, err := db.Writer().ExecContext(context.Background(),
`INSERT INTO audio_records
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`,
byHand, owner, entity.StateUploaded, now, now, now,
)
require.NoError(t, err)
acquired := map[string]bool{}
for range 2 {
got, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
acquired[got.ID] = true
}
assert.True(t, acquired[byService.Id], "запись приёма захвату не досталась")
assert.True(t, acquired[byHand], "запись, заведённая запросом к базе, захвату не досталась")
}
// Горячие выборки опираются на индекс: полного сканирования таблицы аудиозаписей
// не показывает ни отбор захвата, ни список, сужаемый владельцем и страницей.
func TestHotQueriesUseIndexes(t *testing.T) {
db, _, _ := newTestDB(t)
acquire := explain(t, db, `
SELECT id FROM audio_records
WHERE state IN (?, ?)
AND halted_at IS NULL
AND (delay_time IS NULL OR delay_time < ?)
AND (acquisition_id IS NULL OR acquire_expires_at IS NULL OR acquire_expires_at < ?)
ORDER BY created_at, id
LIMIT 1`,
entity.StateUploaded, entity.StateNormalized, "now", "now")
list := explain(t, db, `
SELECT id FROM audio_records
WHERE owner_id = ?
AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC
LIMIT 31`,
"owner", "now", "now", "id")
for name, plan := range map[string]string{"отбор захвата": acquire, "список": list} {
assert.NotContains(t, plan, "SCAN audio_records",
"%s идёт полным сканированием таблицы аудиозаписей: %s", name, plan)
assert.Contains(t, plan, "USING", "%s не опирается на индекс: %s", name, plan)
assert.Contains(t, plan, "INDEX", "%s не опирается на индекс: %s", name, plan)
}
}
func explain(t *testing.T, db *DB, query string, args ...any) string {
t.Helper()
rows, err := db.Reader().QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...)
require.NoError(t, err)
defer func() { require.NoError(t, rows.Close()) }()
var plan strings.Builder
for rows.Next() {
var id, parent, notUsed int
var detail string
require.NoError(t, rows.Scan(&id, &parent, &notUsed, &detail))
plan.WriteString(detail)
plan.WriteString("; ")
}
require.NoError(t, rows.Err())
return plan.String()
}
// Мягкая остановка закрывает то же, что открыл подъём, и повторная остановка не
// даёт паники.
func TestCloseIsIdempotent(t *testing.T) {
db, err := Open(t.TempDir(), testSettings())
require.NoError(t, err)
require.NoError(t, db.Close())
assert.NoError(t, db.Close(), "повторное закрытие отказало")
}
// Укладка атомарна: источник, отдавший отказ на середине потока, не оставляет ни
// файла под рабочим именем, ни временного имени в подкаталоге записи.
func TestStorePutIsAtomic(t *testing.T) {
_, store, dir := newTestDB(t)
recordID := ident.New()
_, err := store.Put(recordID, "voice.mp3", &brokenReader{})
require.Error(t, err, "отказ источника прошёл молча")
_, err = os.Stat(filepath.Join(dir, recordsDir, recordID, "voice.mp3"))
assert.True(t, os.IsNotExist(err), "рабочее имя появилось при оборванном потоке")
temporary, err := store.HasTemporary(recordID)
require.NoError(t, err)
assert.False(t, temporary, "временное имя осталось в подкаталоге записи")
}
// brokenReader отдаёт часть потока и обрывается — так выглядит отправитель,
// закрывший соединение на середине.
type brokenReader struct {
sent bool
}
func (r *brokenReader) Read(p []byte) (int, error) {
if !r.sent {
r.sent = true
copy(p, strings.Repeat("a", min(len(p), 64)))
return min(len(p), 64), nil
}
return 0, errors.New("источник оборвался")
}
// Отказ укладки несёт причину и не несёт пути.
//
// Обе половины — одно требование, и порознь они друг друга отменяют. Причина
// нужна владельцу: исчерпание места, отсутствие прав и негодная раскладка
// каталога требуют трёх разных действий, а отказ укладки — единственная
// поверхность, на которой он их видит. Путь не нужен: он ведёт внутрь каталога
// данных, а отказ кончается в журнале, откуда строку потом не убрать.
func TestStoreFailureCarriesCauseWithoutPath(t *testing.T) {
_, store, dir := newTestDB(t)
// noPath судит вторую половину: ни каталога данных, ни временной приставки
// в цепочке отказа быть не должно.
noPath := func(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.NotContains(t, err.Error(), dir, "путь внутри каталога данных уехал в отказ")
assert.NotContains(t, err.Error(), tempPrefix, "временное имя укладки уехало в отказ")
}
t.Run("места на диске нет", func(t *testing.T) {
recordID := ident.New()
_, err := store.Put(recordID, "voice.mp3", &diskFullReader{
path: filepath.Join(dir, recordsDir, recordID, tempPrefix+"whatever"),
})
noPath(t, err)
assert.ErrorIs(t, err, syscall.ENOSPC, "причина отказа отброшена: место на диске неотличимо от прочего")
})
t.Run("прав на подкаталог записи нет", func(t *testing.T) {
recordID := ident.New()
recordDir := filepath.Join(dir, recordsDir, recordID)
require.NoError(t, os.MkdirAll(recordDir, 0o750))
require.NoError(t, os.Chmod(recordDir, 0o500))
t.Cleanup(func() {
if err := os.Chmod(recordDir, 0o750); err != nil {
t.Logf("не удалось вернуть права подкаталогу записи: %v", err)
}
})
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
noPath(t, err)
assert.ErrorIs(t, err, fs.ErrPermission, "причина отказа отброшена: отсутствие прав неотличимо от прочего")
})
t.Run("подкаталогом записи занято не то", func(t *testing.T) {
recordID := ident.New()
require.NoError(t, os.MkdirAll(filepath.Join(dir, recordsDir), 0o750))
require.NoError(t, os.WriteFile(filepath.Join(dir, recordsDir, recordID), []byte("не каталог"), 0o600))
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
noPath(t, err)
assert.ErrorIs(t, err, syscall.ENOTDIR, "причина отказа отброшена: негодная раскладка неотличима от прочего")
})
t.Run("копии нет", func(t *testing.T) {
_, err := store.Open(ident.New(), "voice.mp3")
noPath(t, err)
assert.ErrorIs(t, err, fs.ErrNotExist, "причина отказа отброшена: «файла нет» неотличимо от прочего")
})
}
// diskFullReader отказывает так, как отказывает диск: причина приходит обёрткой
// пакета `os`, и путь лежит в ней. Настоящим источником укладки служит `*os.File`
// рабочей копии, и его отказ приходит ровно этой формой.
type diskFullReader struct {
path string
}
func (r *diskFullReader) Read([]byte) (int, error) {
return 0, &os.PathError{Op: "write", Path: r.path, Err: syscall.ENOSPC}
}
// Копии одной записи лежат вместе — под её идентификатором, — и второго места,
// где лежит что-то из них, нет.
func TestCopiesOfRecordLiveTogether(t *testing.T) {
db, store, dir := newTestDB(t)
owner := newOwner(t, db)
files := NewFileRepository(db, store)
recordID := ident.New()
for _, name := range []string{"original.mp3", "normalized.ogg"} {
work, err := files.Stage(filepath.Ext(name), strings.NewReader("содержимое "+name))
require.NoError(t, err)
_, err = files.Create(recordID, name, work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
require.NoError(t, work.Close())
}
entries, err := os.ReadDir(filepath.Join(dir, recordsDir, recordID))
require.NoError(t, err)
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
assert.ElementsMatch(t, []string{"original.mp3", "normalized.ogg"}, names)
records, err := os.ReadDir(filepath.Join(dir, recordsDir))
require.NoError(t, err)
assert.Len(t, records, 1, "второго места для копий записи не появляется")
}
// Содержимое читается потоком с перемоткой: отдача по диапазону берёт кусок, а
// не файл целиком.
func TestOpenGivesSeekableStream(t *testing.T) {
db, store, _ := newTestDB(t)
owner := newOwner(t, db)
files := NewFileRepository(db, store)
recordID := ident.New()
work, err := files.Stage(".mp3", strings.NewReader("0123456789"))
require.NoError(t, err)
file, err := files.Create(recordID, "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
require.NoError(t, work.Close())
reader, err := files.Open(file.Id)
require.NoError(t, err)
defer func() { require.NoError(t, reader.Close()) }()
_, err = reader.Seek(4, io.SeekStart)
require.NoError(t, err)
slice := make([]byte, 3)
_, err = io.ReadFull(reader, slice)
require.NoError(t, err)
assert.Equal(t, "456", string(slice))
}
+237
View File
@@ -0,0 +1,237 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// workFile — рабочая копия файла на диске. Живёт во временном каталоге системы,
// а не в каталоге данных: последний смонтирован на сервере, и временному там не
// место.
type workFile struct {
path string
}
func (w *workFile) Path() string { return w.path }
func (w *workFile) Size() (int64, error) {
info, err := os.Stat(w.path)
if err != nil {
return 0, fmt.Errorf("failed to stat work file: %w", err)
}
return info.Size(), nil
}
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
// до его создания, и повторный Close тоже законен.
func (w *workFile) Close() error {
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove work file: %w", err)
}
return nil
}
// FileRepository — копии записей: строка в базе и содержимое в каталоге данных.
type FileRepository struct {
db *DB
store *Store
}
func NewFileRepository(db *DB, store *Store) *FileRepository {
return &FileRepository{db: db, store: store}
}
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
func newWorkFile(ext string) (*workFile, error) {
f, err := os.CreateTemp("", "transcriber-*"+ext)
if err != nil {
return nil, fmt.Errorf("failed to create work file: %w", err)
}
path := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("failed to close work file: %w", err)
}
return &workFile{path: path}, nil
}
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
return newWorkFile(ext)
}
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
work, err := newWorkFile(ext)
if err != nil {
return nil, err
}
if err := writeTo(work.path, content); err != nil {
// Отказ уборки не подменяет отказ записи, но и не теряется.
return nil, errors.Join(err, work.Close())
}
return work, nil
}
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
file, err := repo.GetByID(fileID)
if err != nil {
return nil, err
}
work, err := newWorkFile(filepath.Ext(file.FileName))
if err != nil {
return nil, err
}
src, err := repo.store.Open(file.RecordID, file.FileName)
if err != nil {
return nil, errors.Join(err, work.Close())
}
defer func() { _ = src.Close() }()
if err := writeTo(work.path, src); err != nil {
return nil, errors.Join(err, work.Close())
}
return work, nil
}
// Create кладёт рабочую копию в каталог данных и заводит строку о файле.
//
// Порядок один: строка заводится **после** того, как содержимое лежит целиком
// под рабочим именем. Обратный порядок оставлял бы в базе строку, указывающую на
// файл, которого ещё нет или который короче принятого.
//
// Отсюда и уборка: содержимое легло, а строка не сохранилась — уложенный файл
// убирается, и следа от него не остаётся. Файл, переживший свою строку, —
// штатное состояние только у приведённой копии, которую заводит шаг конвейера; у
// принятой это мусор, на который не ссылается ничто и о котором узнать неоткуда.
//
// Владелец обязателен и лежит своей колонкой: пустой отвергает схема — колонка
// объявлена связью с учётной записью, и пустое значение ей не отвечает.
func (repo *FileRepository) Create(
recordID, name string,
work contract.WorkFile,
meta contract.FileMeta,
ownerID string,
) (*entity.File, error) {
source, err := os.Open(work.Path())
if err != nil {
// Причина сохраняется, путь снимается: он ведёт к рабочей копии чужого
// аудио, а отказ кончается в журнале.
return nil, fmt.Errorf("failed to read work file: %w", causeOf(err))
}
size, putErr := repo.store.Put(recordID, name, source)
closeErr := source.Close()
if err := errors.Join(putErr, closeErr); err != nil {
return nil, err
}
file := &entity.File{
Id: ident.New(),
RecordID: recordID,
FileName: name,
Size: size,
Format: meta.Format,
DurationMs: meta.DurationMs,
CreatedAt: clock.Now(),
}
query, args := insertSQL("files", map[string]any{
"id": file.Id,
"owner_id": ownerID,
"record_id": file.RecordID,
"file_name": file.FileName,
"size_bytes": file.Size,
"format": file.Format,
"duration_ms": file.DurationMs,
"created_at": formatTime(file.CreatedAt),
})
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
// Уложенное содержимое убирается: строки о нём не будет, и ссылаться на
// него нечему. Имя файла в отказ не идёт — оно часть пути к чужому аудио.
return nil, errors.Join(
fmt.Errorf("failed to store the file row of record %s: %w", recordID, err),
repo.store.Remove(recordID, name),
)
}
return file, nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
file := &entity.File{}
var (
createdAt string
recordID string
fileName string
size int64
format string
durationMs int64
)
err := repo.db.Reader().QueryRowContext(context.Background(),
`SELECT record_id, file_name, size_bytes, format, duration_ms, created_at
FROM files WHERE id = ?`, id,
).Scan(&recordID, &fileName, &size, &format, &durationMs, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("file %s is not found", id)
}
return nil, fmt.Errorf("failed to get file %s: %w", id, err)
}
file.Id = id
file.RecordID = recordID
file.FileName = fileName
file.Size = size
file.Format = format
file.DurationMs = durationMs
file.CreatedAt = requiredTimeOf(createdAt)
return file, nil
}
// Open отдаёт содержимое хранимой копии потоком с перемоткой: отдача по
// диапазону читает кусок, а не файл целиком.
func (repo *FileRepository) Open(fileID string) (io.ReadSeekCloser, error) {
file, err := repo.GetByID(fileID)
if err != nil {
return nil, err
}
return repo.store.Open(file.RecordID, file.FileName)
}
// writeTo переливает содержимое в файл потоком. В память запись целиком не
// читается: расчётный потолок — шесть часов.
func writeTo(path string, content io.Reader) error {
dst, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to open work file: %w", err)
}
if _, err := io.Copy(dst, content); err != nil {
_ = dst.Close()
return fmt.Errorf("failed to write work file: %w", err)
}
if err := dst.Close(); err != nil {
return fmt.Errorf("failed to close work file: %w", err)
}
return nil
}
+159
View File
@@ -0,0 +1,159 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// UserRepository — учётные записи сервиса.
type UserRepository struct {
db *DB
}
func NewUserRepository(db *DB) *UserRepository {
return &UserRepository{db: db}
}
// EnsureUser находит учётную запись по логину у провайдера, а не найдя — заводит
// её.
//
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
// представиться — личные токены — возьмёт этот же метод; правило, уложенное
// куском в слой транспорта, пришлось бы тогда либо дублировать вторым куском,
// либо вытаскивать задним числом.
//
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
// его работы.
//
// **Поиск идёт читающим пулом, и пишущая транзакция открывается только тогда,
// когда запись не нашлась.** Узнавание одето на весь корень приложения, поэтому
// пишущая транзакция, взятая до поиска, доставалась бы всему узнанному потоку —
// опросу карточки и каждому запросу диапазона при проигрывании, — и вставала бы
// в очередь к единственному пишущему соединению. Ждать там нечего: заводится
// учётная запись один раз за жизнь человека.
//
// **Окно между двумя соединениями закрыто повторным поиском внутри
// транзакции.** Между поиском читающим пулом и открытием пишущей транзакции
// запись успевает завести сосед; ветвь ниже находит её и берёт заведённую, а
// уникальность ключа держит схема — не порядок обращений.
func (repo *UserRepository) EnsureUser(identity contract.Identity) (*contract.UserAccount, bool, error) {
login, ok := entity.AcceptProviderLogin(identity.Login)
if !ok {
return nil, false, contract.ErrLoginNotAcceptable
}
account, err := findUserByLogin(repo.db.Reader(), login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, nil
}
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
if err != nil {
return nil, false, fmt.Errorf("failed to open a transaction for the user account: %w", err)
}
defer func() { _ = tx.Rollback() }()
// Повторный поиск закрывает окно между читающим пулом и пишущей
// транзакцией: пока её ждали, запись мог завести сосед.
account, err = findUserByLogin(tx, login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, commitAccount(tx, account)
}
name := entity.AcceptDisplayName(identity.Name)
email, _ := entity.AcceptEmail(identity.Email)
account, err = insertUser(tx, login, name, email)
switch {
case err == nil:
return account, true, commitAccount(tx, account)
case !isUniqueViolation(err):
return nil, false, fmt.Errorf("failed to create user account: %w", err)
}
// **Два отказа уникальности различаются, и исход у них разный**, а какая
// колонка не сошлась, код отказа не называет. Различает их повторный поиск
// по ключу: нашёлся — это гонка двух первых обращений одним логином, и надо
// просто взять заведённую соседом запись.
account, err = findUserByLogin(tx, login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, commitAccount(tx, account)
}
// Не нашёлся — значит не сошлась другая колонка: адрес почты, пришедший от
// провайдера, занят другой учётной записью (общий ящик, семья, группа).
// Запись заводится **без почты**: она необязательна и ключом не служит. Без
// этого разреза второй человек с общим адресом не завёлся бы никогда —
// повторный поиск по логину снова ничего не находит.
account, err = insertUser(tx, login, name, "")
if err != nil {
return nil, false, fmt.Errorf("failed to create user account without email: %w", err)
}
return account, true, commitAccount(tx, account)
}
func commitAccount(tx *sql.Tx, account *contract.UserAccount) error {
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit the user account %s: %w", account.ID, err)
}
return nil
}
func insertUser(tx *sql.Tx, login, name, email string) (*contract.UserAccount, error) {
id := ident.New()
now := formatTime(clock.Now())
_, err := tx.ExecContext(context.Background(),
`INSERT INTO users (id, provider_login, name, email, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
id, login, name, email, now, now,
)
if err != nil {
return nil, err
}
return &contract.UserAccount{ID: id, Name: name}, nil
}
// rowQuerier — то общее, чем поиск учётной записи пользуется у читающего пула и
// у пишущей транзакции. Оба поиска — до транзакции и внутри неё — идут одним
// запросом: второй его копией они разошлись бы молча.
type rowQuerier interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// findUserByLogin ищет учётную запись по ключу. Значение уходит базе
// **параметром** запроса, а не подстановкой в текст: строка приходит снаружи, и
// подставленная в текст она правила бы сам запрос, а не только его аргумент.
func findUserByLogin(q rowQuerier, login string) (*contract.UserAccount, error) {
account := &contract.UserAccount{}
err := q.QueryRowContext(context.Background(),
"SELECT id, name FROM users WHERE provider_login = ?", login,
).Scan(&account.ID, &account.Name)
switch {
case err == nil:
return account, nil
case errors.Is(err, sql.ErrNoRows):
return nil, nil
default:
return nil, fmt.Errorf("failed to look up user account: %w", err)
}
}
+110
View File
@@ -0,0 +1,110 @@
package sqlite
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"syscall"
"github.com/pressly/goose/v3"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite/migrations"
)
// migrationLockFile — файл, на котором берётся замок наката. Лежит в каталоге
// данных рядом с базой: замок принадлежит каталогу, а не машине.
const migrationLockFile = "migrate.lock"
// Migrate приводит схему к последнему шагу.
//
// # Порядок
//
// Накат идёт **до подъёма входов и до старта воркеров**, а его отказ роняет
// старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом на каждый
// запрос и на каждый прогон воркера — вместо одной строки о причине их
// становятся сотни, и первопричина в них теряется.
//
// # Чем держится неделимость
//
// Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
// соединении и внутри выполняет и сам шаг, и вставку версии в таблицу учёта.
// Отменяет это только пометка `NO TRANSACTION` у самого шага, и мы её не ставим.
//
// Порядок шагов детерминирован и выводится из версии шага, а не из порядка
// чтения каталога: собранные шаги сортируются по версии, а две одинаковых версии
// дают отказ сбора, а не молчаливый выбор одного.
//
// # Почему замок наш
//
// Исключающей блокировки наката библиотека под SQLite не даёт вовсе: её
// запиратели объявлены только для PostgreSQL, а провайдер без запирателя
// накатывает без всякой блокировки. Замок поэтому берём сами — на файле в
// каталоге данных. С умершим процессом его снимает ядро, поэтому просроченного
// замка, который надо чистить руками, не остаётся.
//
// Накат идёт по **пишущему** соединению: он читает таблицу учёта и следом в неё
// пишет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
// повышает.
func Migrate(ctx context.Context, db *DB, dataDir string, logger *slog.Logger) error {
if logger == nil {
logger = slog.Default()
}
provider, err := goose.NewProvider(
goose.DialectSQLite3,
db.Writer(),
nil,
goose.WithGoMigrations(migrations.All()...),
// Глобальный список библиотеки не читается: перечень шагов приходит
// доводом, и два провайдера в одном процессе за общее состояние не
// спорят.
goose.WithDisableGlobalRegistry(true),
)
if err != nil {
return fmt.Errorf("failed to prepare schema migrations: %w", err)
}
return withMigrationLock(dataDir, func() error {
results, err := provider.Up(ctx)
if err != nil {
// Отказ называет шаг: библиотека кладёт версию в текст отказа, и
// владелец сервиса по ней находит файл шага.
return fmt.Errorf("failed to apply schema migration: %w", err)
}
for _, result := range results {
logger.Info("Schema migration applied",
"migration_version", result.Source.Version,
"duration_ms", result.Duration.Milliseconds())
}
return nil
})
}
// withMigrationLock берёт исключающий замок каталога данных на всё время наката.
//
// Замок блокирующий: второй процесс, поднятый на том же каталоге, ждёт его
// освобождения, а не применяет шаги параллельно. Два наката, разошедшихся на
// одном шаге, оставили бы схему в состоянии, которого не описывает ни один шаг.
func withMigrationLock(dataDir string, run func() error) error {
path := filepath.Join(dataDir, migrationLockFile)
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o640)
if err != nil {
return fmt.Errorf("failed to open migration lock: %w", err)
}
// Замок снимается **закрытием дескриптора**, и отдельного снятия не нужно:
// он принадлежит открытому файлу, а не процессу. С умершим процессом его
// снимает ядро тем же движением — просроченного замка, который надо чистить
// руками, не остаётся.
defer func() { _ = file.Close() }()
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
return fmt.Errorf("failed to lock the data directory for migration: %w", err)
}
return run()
}
@@ -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},
),
}
}
@@ -0,0 +1,163 @@
package sqlite
import (
"context"
"errors"
"fmt"
"io"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// payloadSuffix — окончание имени файла, под которым лежит сохранённый ответ
// провайдера. Имя задаёт сервис, как и у копий аудио.
const payloadSuffix = ".payload"
type RecognitionRepository struct {
db *DB
store *Store
}
func NewRecognitionRepository(db *DB, store *Store) *RecognitionRepository {
return &RecognitionRepository{db: db, store: store}
}
// Create заводит строку попытки **до** обращения к провайдеру.
//
// Порядок здесь несущий: окно между ответом провайдера и записью идентификатора
// операции — то место, где теряется оплаченное. Заведённая заранее строка даёт
// повторному шагу, чем проверить сделанное прежде, чем платить второй раз.
func (repo *RecognitionRepository) Create(r *entity.Recognition) error {
started := clock.Now()
if r.Id == "" {
r.Id = ident.New()
}
now := formatTime(started)
_, err := repo.db.Writer().ExecContext(context.Background(),
`INSERT INTO recognitions
(id, record_id, provider, model, external_id, source_uri, started_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.Id, r.RecordID, r.Provider, r.Model, r.ExternalID, r.SourceURI, now, now, now,
)
if err != nil {
return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err)
}
r.StartedAt = &started
return nil
}
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
// последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз
// наружу не платит.
func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error {
_, err := repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET source_uri = ?, external_id = ?, updated_at = ? WHERE id = ?",
sourceURI, externalID, formatTime(clock.Now()), id,
)
if err != nil {
return fmt.Errorf("failed to store operation id of attempt %s: %w", id, err)
}
return nil
}
// Finish кладёт сохранённый ответ провайдера **третьим файлом в подкаталоге
// записи** и отмечает завершение попытки.
//
// Файлом, а не колонкой: шаг опроса читает эту строку раз в несколько секунд, и
// ответ на многочасовую запись, положенный колонкой, ехал бы в память при каждом
// опросе. Хранится он потому, что результат операции у провайдера не
// переспрашивается.
//
// Пустой ответ поверх сохранённого не кладётся — тем же доводом, что и у текста:
// повторный опрос вправе вернуть пустое, и безусловная замена стёрла бы
// сохранённое без возврата.
func (repo *RecognitionRepository) Finish(id string, raw []byte) error {
attempt, err := repo.GetByID(id)
if err != nil {
return err
}
name := id + payloadSuffix
if len(raw) > 0 {
if _, err := repo.store.Put(attempt.RecordID, name, bytesReader(raw)); err != nil {
// Путь к сохранённому ответу наружу не идёт: отказ называет попытку
// её идентификатором.
return errors.Join(fmt.Errorf("failed to store provider payload of attempt %s", id), err)
}
}
finished := formatTime(clock.Now())
if len(raw) > 0 {
_, err = repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET payload_file = ?, finished_at = ?, updated_at = ? WHERE id = ?",
name, finished, finished, id,
)
} else {
_, err = repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET finished_at = ?, updated_at = ? WHERE id = ?",
finished, finished, id,
)
}
if err != nil {
return fmt.Errorf("failed to store provider payload of attempt %s", id)
}
return nil
}
func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) {
attempt := &entity.Recognition{Id: id}
var startedAt, finishedAt, payloadFile nullString
err := repo.db.Reader().QueryRowContext(context.Background(),
`SELECT record_id, provider, model, external_id, source_uri, payload_file, started_at, finished_at
FROM recognitions WHERE id = ?`, id,
).Scan(
&attempt.RecordID, &attempt.Provider, &attempt.Model,
&attempt.ExternalID, &attempt.SourceURI, &payloadFile,
&startedAt, &finishedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
}
attempt.StartedAt = timeOf(startedAt.NullString)
attempt.FinishedAt = timeOf(finishedAt.NullString)
return attempt, nil
}
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ
// нужен: шаг опроса читает строку попытки без него.
func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) {
attempt, err := repo.GetByID(id)
if err != nil {
return nil, err
}
var payloadFile string
if err := repo.db.Reader().QueryRowContext(context.Background(),
"SELECT payload_file FROM recognitions WHERE id = ?", id,
).Scan(&payloadFile); err != nil {
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
}
if payloadFile == "" {
return nil, fmt.Errorf("recognition attempt %s has no stored payload", id)
}
file, err := repo.store.Open(attempt.RecordID, payloadFile)
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
raw, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read stored payload of attempt %s", id)
}
return raw, nil
}
@@ -0,0 +1,62 @@
package sqlite
import (
"bytes"
"context"
"database/sql"
"fmt"
"io"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// nullString — обёртка ради читаемости выборок: колонка, допускающая пустое
// значение, читается в неё, а домену отдаётся указателем.
type nullString struct {
sql.NullString
}
// bytesReader отдаёт содержимое в памяти потоком: сохранённый ответ провайдера
// приходит целиком байтами, а укладка принимает поток.
func bytesReader(raw []byte) io.Reader {
return bytes.NewReader(raw)
}
type RecordEventRepository struct {
db *DB
}
func NewRecordEventRepository(db *DB) *RecordEventRepository {
return &RecordEventRepository{db: db}
}
// Append пишет строку журнала событий записи.
//
// Журнал пишется на смену рубежа, на остановку и на возврат в работу, а не на
// каждое откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни
// один шаг конвейера его не читает, чтобы решить, что делать дальше: решение
// принимается по рубежу записи, и второй источник решения разошёлся бы с первым
// молча.
//
// Содержимое записи сюда не попадает — инвариант приватности действует здесь
// наравне с журналом сервиса.
func (repo *RecordEventRepository) Append(event *entity.RecordEvent) error {
if event.Id == "" {
event.Id = ident.New()
}
_, err := repo.db.Writer().ExecContext(context.Background(),
`INSERT INTO record_events
(id, record_id, origin, step, outcome, outcome_text, duration_ms, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
event.Id, event.RecordID, event.Origin, event.Step,
event.Outcome, event.OutcomeText, event.DurationMs, formatTime(clock.Now()),
)
if err != nil {
return fmt.Errorf("failed to append event of record %s: %w", event.RecordID, err)
}
return nil
}
@@ -1,12 +1,10 @@
package pocketbase
package sqlite
import (
"context"
"fmt"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
@@ -47,69 +45,86 @@ func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.Recor
q.Limit = defaultListLimit
}
collection, err := findCollection(repo.app, migrations.RecordsCollection)
conditions := []string{"owner_id = ?"}
args := []any{q.OwnerID}
state, stateArgs, err := stateCondition(q.Filter)
if err != nil {
return nil, err
}
filter := dbx.HashExp{"owner": q.OwnerID}
conditions := []dbx.Expression{filter}
state, err := stateCondition(q.Filter)
if err != nil {
return nil, err
}
if state != nil {
if state != "" {
conditions = append(conditions, state)
args = append(args, stateArgs...)
}
total, err := repo.countRecords(conditions)
total, err := repo.countRecords(conditions, args)
if err != nil {
return nil, err
}
pageConditions := conditions
pageArgs := args
// Курсор режет ленту по паре: строго раньше по времени, а при равном времени
// — строго меньше по идентификатору. Идентификаторы хранилища монотонны в
// пределах одной миллисекунды не всегда, но сравнение по ним устойчиво, и
// этого довольно: задача ключа — не пропустить и не повторить.
// — строго меньше по идентификатору.
if q.Cursor != nil {
conditions = append(conditions, dbx.Or(
dbx.NewExp("created < {:created}", dbx.Params{"created": q.Cursor.CreatedAt}),
dbx.And(
dbx.NewExp("created = {:created}", dbx.Params{"created": q.Cursor.CreatedAt}),
dbx.NewExp("id < {:id}", dbx.Params{"id": q.Cursor.ID}),
),
))
pageConditions = append(append([]string{}, conditions...),
"(created_at < ? OR (created_at = ? AND id < ?))")
cursorTime := formatTime(q.Cursor.CreatedAt)
pageArgs = append(append([]any{}, args...),
cursorTime, cursorTime, q.Cursor.ID)
}
row := &recordRow{}
columns, targets := selectList(readRecordColumns(row), "")
// Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли
// следующая страница» без второго запроса и без вычислений по общему числу,
// которое к этому моменту могло измениться.
records := []*core.Record{}
err = repo.app.RecordQuery(collection).
AndWhere(dbx.And(conditions...)).
OrderBy("created DESC", "id DESC").
Limit(int64(q.Limit) + 1).
All(&records)
query := "SELECT " + columns + " FROM " + recordsTable +
" WHERE " + strings.Join(pageConditions, " AND ") +
" ORDER BY created_at DESC, id DESC LIMIT ?"
pageArgs = append(pageArgs, q.Limit+1)
rows, err := repo.db.Reader().QueryContext(context.Background(), query, pageArgs...)
if err != nil {
return nil, fmt.Errorf("failed to list audio records: %w", err)
}
defer func() { _ = rows.Close() }()
items := []*entity.AudioRecord{}
for rows.Next() {
if err := rows.Scan(targets...); err != nil {
return nil, fmt.Errorf("failed to read an audio record of the page: %w", err)
}
items = append(items, rowToAudioRecord(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read the page of audio records: %w", err)
}
page := &contract.RecordPage{TotalItems: total}
if len(records) > q.Limit {
last := records[q.Limit-1]
if len(items) > q.Limit {
last := items[q.Limit-1]
page.NextCursor = &contract.RecordCursor{
CreatedAt: last.GetDateTime("created").String(),
CreatedAt: last.CreatedAt,
ID: last.Id,
}
records = records[:q.Limit]
items = items[:q.Limit]
}
page.Items = make([]*entity.AudioRecord, 0, len(records))
for _, record := range records {
page.Items = append(page.Items, recordToAudioRecord(record))
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, item.Id)
}
topics, err := repo.topicsOf(ids)
if err != nil {
return nil, err
}
for _, item := range items {
item.TopicIDs = topics[item.Id]
}
page.Items = items
return page, nil
}
@@ -118,84 +133,128 @@ func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.Recor
// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор
// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе
// молча поменял бы состав всех трёх состояний.
func stateCondition(filter *entity.ListFilter) (dbx.Expression, error) {
func stateCondition(filter *entity.ListFilter) (string, []any, error) {
if filter == nil {
return nil, nil
return "", nil, nil
}
notHalted := dbx.NewExp("halted_at = ''")
halted := dbx.NewExp("halted_at != ''")
switch *filter {
case entity.ListFilterHalted:
return halted, nil
return "halted_at IS NOT NULL", nil, nil
case entity.ListFilterWorking:
return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.WorkingStages())...)), nil
condition, args := stateIn(entity.WorkingStages())
return "halted_at IS NULL AND " + condition, args, nil
case entity.ListFilterDone:
return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.TerminalStages())...)), nil
condition, args := stateIn(entity.TerminalStages())
return "halted_at IS NULL AND " + condition, args, nil
}
// Ветвь отказа, а не молчаливое «без сужения»: значение, добавленное в
// перечень состояний и забытое здесь, иначе вернуло бы человеку весь архив
// под именем отбора — и заметить это было бы нечем.
return nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter)
return "", nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter)
}
func stageNameValues(stages []entity.Stage) []any {
func stateIn(stages []entity.Stage) (string, []any) {
names := entity.StageNames(stages)
out := make([]any, 0, len(names))
for _, n := range names {
out = append(out, n)
args := make([]any, 0, len(names))
placeholders := make([]string, 0, len(names))
for _, name := range names {
args = append(args, name)
placeholders = append(placeholders, "?")
}
return out
return "state IN (" + strings.Join(placeholders, ", ") + ")", args
}
func (repo *AudioRecordRepository) countRecords(conditions []dbx.Expression) (int, error) {
var counter struct {
Total int `db:"total"`
}
err := repo.app.RecordQuery(migrations.RecordsCollection).
Select("count(*) as total").
AndWhere(dbx.And(conditions...)).
One(&counter)
if err != nil {
func (repo *AudioRecordRepository) countRecords(conditions []string, args []any) (int, error) {
var total int
query := "SELECT COUNT(*) FROM " + recordsTable + " WHERE " + strings.Join(conditions, " AND ")
if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(&total); err != nil {
return 0, fmt.Errorf("failed to count audio records: %w", err)
}
return counter.Total, nil
return total, nil
}
// topicsOf читает темы страницы **одним запросом**: страница в сотню записей
// иначе стоила бы сотни обращений к базе.
func (repo *AudioRecordRepository) topicsOf(recordIDs []string) (map[string][]string, error) {
out := map[string][]string{}
if len(recordIDs) == 0 {
return out, nil
}
placeholders := make([]string, 0, len(recordIDs))
args := make([]any, 0, len(recordIDs))
for _, id := range recordIDs {
placeholders = append(placeholders, "?")
args = append(args, id)
}
query := "SELECT record_id, topic_id FROM record_topics WHERE record_id IN (" +
strings.Join(placeholders, ", ") + ") ORDER BY topic_id"
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
if err != nil {
return nil, fmt.Errorf("failed to read record topics: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var recordID, topicID string
if err := rows.Scan(&recordID, &topicID); err != nil {
return nil, fmt.Errorf("failed to read a record topic: %w", err)
}
out[recordID] = append(out[recordID], topicID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read record topics: %w", err)
}
return out, nil
}
// ResolveTopicNames разрешает темы названиями **одним запросом на страницу**, а
// не по запросу на запись: страница в сотню записей иначе стоила бы сотни
// обращений к хранилищу.
// не по запросу на запись.
//
// Названия, а не идентификаторы, потому что экран показывает названия: отдай мы
// ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради
// чего контракт согласуется один раз.
//
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека — пара
// «владелец и название» уникальна, — и разрешение без сужения отдало бы название
// чужой темы, как только темы начнёт писать языковая модель.
func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) {
out := map[string]string{}
if len(ids) == 0 || ownerID == "" {
return out, nil
}
values := make([]any, 0, len(ids))
placeholders := make([]string, 0, len(ids))
args := []any{ownerID}
for _, id := range ids {
values = append(values, id)
placeholders = append(placeholders, "?")
args = append(args, id)
}
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека —
// пара «владелец и название» уникальна, — и разрешение без сужения отдало бы
// название чужой темы, как только темы начнёт писать языковая модель.
records := []*core.Record{}
err := repo.app.RecordQuery(migrations.TopicsCollection).
AndWhere(dbx.HashExp{"owner": ownerID}).
AndWhere(dbx.In("id", values...)).
All(&records)
query := "SELECT id, name FROM topics WHERE owner_id = ? AND id IN (" +
strings.Join(placeholders, ", ") + ")"
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
if err != nil {
return nil, fmt.Errorf("failed to resolve topics: %w", err)
}
defer func() { _ = rows.Close() }()
for _, record := range records {
out[record.Id] = record.GetString("name")
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err != nil {
return nil, fmt.Errorf("failed to read a topic: %w", err)
}
out[id] = name
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to resolve topics: %w", err)
}
return out, nil
}
@@ -0,0 +1,173 @@
package sqlite
import (
"database/sql"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Отображение аудиозаписи в строку базы и обратно живёт одним местом.
//
// Работает оно **по имени колонки**: именованные параметры запроса и место
// назначения, найденное по имени. Причина в самой сущности — у аудиозаписи поля
// одного типа идут длинным непрерывным рядом, и ссылки на файл, на структуру
// реплик, на два вида текста и на попытку распознавания стоят в нём подряд.
// Позиционный список дал бы сдвиг на одно поле, который компилируется молча и
// кладёт идентификатор файла в колонку текста. По имени такого сдвига не
// существует вовсе: лишнее имя или недостающее — отказ запроса, а не тихая
// подмена значения.
//
// Инвариант проекта о колонках записи эта форма не снимает: колонку по-прежнему
// можно забыть в отображении или в шаге схемы, и сверку держат правила
// `internal/archrules`.
// writeOwnedByPipeline — колонки, которыми распоряжается конвейер.
//
// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до
// своего сохранения, а это часы. Всё, что владелец правил за это время,
// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа
// тому, кто правил. Владелец, заголовок, краткое описание, имя файла
// отправителя, длительность, размер и темы не трогаются вовсе.
func writeOwnedByPipeline(r *entity.AudioRecord) map[string]any {
return map[string]any{
"state": r.State,
"state_entered_at": formatTime(r.StateEnteredAt),
"halted_at": timeValue(r.HaltedAt),
"halt_reason": stringValue(r.HaltReason),
"error_text": stringValue(r.ErrorText),
"acquisition_id": stringValue(r.AcquisitionID),
"acquire_expires_at": timeValue(r.AcquireExpiresAt),
"delay_time": timeValue(r.DelayTime),
"attempts": r.Attempts,
"original_file_id": stringValue(r.OriginalFileID),
"normalized_file_id": stringValue(r.NormalizedFileID),
"transcript_text_id": stringValue(r.TranscriptTextID),
"literary_text_id": stringValue(r.LiteraryTextID),
"structure_id": stringValue(r.StructureID),
"recognition_id": stringValue(r.RecognitionID),
"updated_at": formatTime(r.UpdatedAt),
}
}
// writeRecord — запись целиком: это заведение, и спорить за поля здесь не с кем.
//
// Колонки заведения перечислены той же формой, что и колонки конвейера, — картой
// «имя колонки в значение»: сверка колонок в `internal/archrules` читает именно
// её, и колонка, положенная присваиванием мимо карты, выпала бы из-под правила
// молча.
func writeRecord(r *entity.AudioRecord) map[string]any {
values := writeOwnedByPipeline(r)
own := map[string]any{
"id": r.Id,
// Владелец кладётся только здесь, при заведении. В перечне конвейера его
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок
// шага, записанный поверх, стёр бы его молча.
"owner_id": r.OwnerID,
"title": stringValue(r.Title),
"brief": stringValue(r.Brief),
// Имя файла отправителя, длительность и размер кладёт приём и только он:
// это снимок принятого, и конвейер его не пересчитывает.
"original_filename": stringValue(r.OriginalFilename),
"duration_ms": numberValue(r.DurationMs),
"size_bytes": numberValue(r.SizeBytes),
"created_at": formatTime(r.CreatedAt),
}
for name, value := range own {
values[name] = value
}
return values
}
// recordRow — сырые значения одной строки аудиозаписи.
type recordRow struct {
id string
ownerID string
title sql.NullString
brief sql.NullString
originalFilename sql.NullString
durationMs int64
sizeBytes int64
state string
stateEnteredAt string
haltedAt sql.NullString
haltReason sql.NullString
errorText sql.NullString
acquisitionID sql.NullString
acquireExpiresAt sql.NullString
delayTime sql.NullString
attempts int
originalFileID sql.NullString
normalizedFileID sql.NullString
transcriptTextID sql.NullString
literaryTextID sql.NullString
structureID sql.NullString
recognitionID sql.NullString
createdAt string
updatedAt string
}
// readRecordColumns — куда кладётся каждая колонка при чтении.
//
// Перечень колонок выборки собирается из этой же карты, поэтому расхождению
// между тем, что спрошено, и тем, куда оно ляжет, взяться неоткуда.
func readRecordColumns(row *recordRow) map[string]any {
return map[string]any{
"id": &row.id,
"owner_id": &row.ownerID,
"title": &row.title,
"brief": &row.brief,
"original_filename": &row.originalFilename,
"duration_ms": &row.durationMs,
"size_bytes": &row.sizeBytes,
"state": &row.state,
"state_entered_at": &row.stateEnteredAt,
"halted_at": &row.haltedAt,
"halt_reason": &row.haltReason,
"error_text": &row.errorText,
"acquisition_id": &row.acquisitionID,
"acquire_expires_at": &row.acquireExpiresAt,
"delay_time": &row.delayTime,
"attempts": &row.attempts,
"original_file_id": &row.originalFileID,
"normalized_file_id": &row.normalizedFileID,
"transcript_text_id": &row.transcriptTextID,
"literary_text_id": &row.literaryTextID,
"structure_id": &row.structureID,
"recognition_id": &row.recognitionID,
"created_at": &row.createdAt,
"updated_at": &row.updatedAt,
}
}
// rowToAudioRecord собирает доменную запись из прочитанной строки.
func rowToAudioRecord(row *recordRow) *entity.AudioRecord {
return &entity.AudioRecord{
Id: row.id,
OwnerID: row.ownerID,
Title: stringOf(row.title),
Brief: stringOf(row.brief),
OriginalFilename: stringOf(row.originalFilename),
DurationMs: numberOf(row.durationMs),
SizeBytes: numberOf(row.sizeBytes),
State: row.state,
StateEnteredAt: requiredTimeOf(row.stateEnteredAt),
HaltedAt: timeOf(row.haltedAt),
HaltReason: stringOf(row.haltReason),
ErrorText: stringOf(row.errorText),
AcquisitionID: stringOf(row.acquisitionID),
AcquireExpiresAt: timeOf(row.acquireExpiresAt),
DelayTime: timeOf(row.delayTime),
Attempts: row.attempts,
OriginalFileID: stringOf(row.originalFileID),
NormalizedFileID: stringOf(row.normalizedFileID),
TranscriptTextID: stringOf(row.transcriptTextID),
LiteraryTextID: stringOf(row.literaryTextID),
StructureID: stringOf(row.structureID),
RecognitionID: stringOf(row.recognitionID),
TopicIDs: []string{},
CreatedAt: requiredTimeOf(row.createdAt),
UpdatedAt: requiredTimeOf(row.updatedAt),
}
}
+237
View File
@@ -0,0 +1,237 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// recordsTable — таблица аудиозаписей.
const recordsTable = "audio_records"
type AudioRecordRepository struct {
db *DB
}
func NewAudioRecordRepository(db *DB) *AudioRecordRepository {
return &AudioRecordRepository{db: db}
}
// Create заводит аудиозапись.
//
// Идентификатор приходит готовым, когда его назначил вызывающий: приём знает его
// раньше, чем кладёт файл, — копии записи лежат подкаталогом под этим самым
// идентификатором. Пустой заполняется единой точкой выдачи.
func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
if r.Id == "" {
r.Id = ident.New()
}
now := clock.Now()
if r.CreatedAt.IsZero() {
r.CreatedAt = now
}
r.UpdatedAt = now
if r.StateEnteredAt.IsZero() {
r.StateEnteredAt = now
}
query, args := insertSQL(recordsTable, writeRecord(r))
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
return fmt.Errorf("failed to insert audio record: %w", err)
}
return nil
}
// Save сохраняет запись, захват которой держит holder.
//
// Сверка захвата и запись идут **одним запросом**: значение признака стоит
// условием правки, поэтому между проверкой и записью не остаётся окна. Сверяется
// именно значение, а не занятость записи — захват, перевыданный другому по
// протуханию срока или после того, как человек вернул запись в работу, обязан
// обратить запись первого в отказ; условие по непустоте признака пропустило бы
// обоих, и два шага записали бы в одну запись по очереди, портя её результат.
//
// Пустой holder снимает условность и в конвейере не употребляется: все его шаги
// получают признак захвата от FindAndAcquire.
func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
r.UpdatedAt = clock.Now()
where := "id = :id"
whereArgs := []any{sql.Named("id", r.Id)}
if holder != "" {
where += " AND acquisition_id = :holder"
whereArgs = append(whereArgs, sql.Named("holder", holder))
}
query, args := updateSQL(recordsTable, writeOwnedByPipeline(r), where, whereArgs)
result, err := repo.db.Writer().ExecContext(context.Background(), query, args...)
if err != nil {
return fmt.Errorf("failed to update audio record: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to read the outcome of an audio record update: %w", err)
}
if affected > 0 {
return nil
}
// Строк не тронуто по одной из двух причин, и различить их можно только
// чтением: записи нет вовсе либо захват достался другому. Разница несущая —
// первая означает поломку, вторая штатный исход шага, потерявшего запись.
if _, err := repo.Get(r.Id); err != nil {
return err
}
return &contract.LostAcquisitionError{JobID: r.Id}
}
// GetByID отдаёт запись, только если её владелец — ownerID.
//
// Чужая запись и несуществующая дают одну и ту же ошибку: по разнице ответов
// иначе перебирается список заведённых записей, а идентификатор записи и есть
// то, что разграничение прячет.
//
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило не
// стало избыточным с обязательностью колонки: схема запрещает **заводить** ничью
// запись, а здесь запрещено **спрашивать** ничьим именем — иначе вызывающий без
// учётной записи получил бы выборку вместо отказа.
func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
if ownerID == "" {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
record, err := repo.read("id = ? AND owner_id = ?", id, ownerID)
if err != nil {
return nil, err
}
return record, nil
}
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья выборка
// владельцем не сужается.
func (repo *AudioRecordRepository) Get(id string) (*entity.AudioRecord, error) {
return repo.read("id = ?", id)
}
// read читает одну запись по условию.
func (repo *AudioRecordRepository) read(where string, args ...any) (*entity.AudioRecord, error) {
row := &recordRow{}
columns, targets := selectList(readRecordColumns(row), "")
query := "SELECT " + columns + " FROM " + recordsTable + " WHERE " + where + " LIMIT 1"
if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(targets...); err != nil {
// «Такой записи нет» переводится в доменную ошибку здесь, у источника,
// как велит конвенция об ошибках. Иначе исходы, которые разграничение
// обязано сделать неразличимыми, разъезжаются: чужая запись даёт
// доменную ошибку, а несуществующая — отказ базы, неотличимый от
// настоящей аварии хранилища.
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
return nil, fmt.Errorf("failed to get audio record: %w", err)
}
record := rowToAudioRecord(row)
topics, err := repo.topicsOf([]string{record.Id})
if err != nil {
return nil, err
}
record.TopicIDs = topics[record.Id]
return record, nil
}
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом:
// выбор подходящей и пометка её захваченной идут вместе, одним оператором с
// возвратом.
//
// Возвращается **идентификатор и признак этого захвата**, а не перечень колонок.
// Колонки шаг читает обычным чтением: иначе всякая новая колонка записи попадала
// бы под инвариант проекта о колонках очереди, а забытая приезжала бы нулевой, и
// первое же сохранение писало бы этот ноль поверх сохранённого значения.
//
// Срок протухания захвата приезжает **с рубежом**, а не с воркером: воркер не
// привязан к шагу и не знает заранее, что вытянет. Перечень рубежей и их сроков
// приходит одним дескриптором — перечислять их порознь нельзя: рубеж, забытый в
// отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
// проекта не пишется в журнал и не считается в метрику.
//
// Запрос идёт по **пишущему** соединению: он читает состояние, которое сам же
// меняет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
// повышает.
func (repo *AudioRecordRepository) FindAndAcquire(stages []entity.Stage) (*contract.AcquiredRecord, error) {
if len(stages) == 0 {
return nil, &contract.JobNotFoundError{Message: "no working stages declared"}
}
now := clock.Now()
holder := ident.New()
args := []any{
sql.Named("holder", holder),
sql.Named("now", formatTime(now)),
}
// Срок протухания у каждого рубежа свой, поэтому он выбирается по рубежу
// самой записи прямо в запросе: воркер, ещё не знающий, что вытянет,
// подставить его не может.
var expiry strings.Builder
expiry.WriteString("CASE state")
states := make([]string, 0, len(stages))
for i, stage := range stages {
stateKey := fmt.Sprintf("state%d", i)
expiryKey := fmt.Sprintf("expiry%d", i)
fmt.Fprintf(&expiry, " WHEN :%s THEN :%s", stateKey, expiryKey)
args = append(args,
sql.Named(stateKey, stage.Name),
sql.Named(expiryKey, formatTime(now.Add(stage.AcquireTimeout))),
)
states = append(states, ":"+stateKey)
}
expiry.WriteString(" END")
// Порядок выборки определён однозначно: время заведения плюс ключ записи.
// Сравнения по неуникальному значению для этого мало — порядок обработки
// стал бы невоспроизводимым.
query := `
UPDATE ` + recordsTable + `
SET acquisition_id = :holder,
acquire_expires_at = ` + expiry.String() + `,
attempts = attempts + 1,
updated_at = :now
WHERE id = (
SELECT id FROM ` + recordsTable + `
WHERE state IN (` + strings.Join(states, ", ") + `)
AND halted_at IS NULL
AND (delay_time IS NULL OR delay_time < :now)
AND (acquisition_id IS NULL
OR acquire_expires_at IS NULL
OR acquire_expires_at < :now)
ORDER BY created_at, id
LIMIT 1
)
RETURNING id`
var id string
if err := repo.db.Writer().QueryRowContext(context.Background(), query, args...).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "no record is ready for work"}
}
return nil, fmt.Errorf("failed to acquire an audio record: %w", err)
}
return &contract.AcquiredRecord{ID: id, Holder: holder}, nil
}
+626
View File
@@ -0,0 +1,626 @@
package sqlite
import (
"context"
"log/slog"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// newWorkingRecord заводит запись, пригодную к захвату.
func newWorkingRecord(t *testing.T, db *DB, owner string) *entity.AudioRecord {
t.Helper()
record := &entity.AudioRecord{
Id: ident.New(),
OwnerID: owner,
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
}
require.NoError(t, NewAudioRecordRepository(db).Create(record))
return record
}
// Ничьей записи не бывает, и держит это схема: колонка владельца объявлена
// связью с учётной записью и пустого значения не принимает.
func TestRecordWithoutOwnerIsRejected(t *testing.T) {
db, _, _ := newTestDB(t)
records := NewAudioRecordRepository(db)
t.Run("пустой владелец", func(t *testing.T) {
err := records.Create(&entity.AudioRecord{
Id: ident.New(), State: entity.StateUploaded, StateEnteredAt: clock.Now(),
})
assert.Error(t, err, "запись с пустым владельцем сохранилась")
})
t.Run("владельца нет среди учётных записей", func(t *testing.T) {
err := records.Create(&entity.AudioRecord{
Id: ident.New(), OwnerID: ident.New(),
State: entity.StateUploaded, StateEnteredAt: clock.Now(),
})
assert.Error(t, err, "запись с выдуманным владельцем сохранилась")
})
t.Run("запросом к базе тоже", func(t *testing.T) {
now := clock.Now().Format(timeLayout)
_, err := db.Writer().ExecContext(context.Background(),
`INSERT INTO audio_records
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
VALUES (?, '', 0, 0, ?, ?, ?, ?)`,
ident.New(), entity.StateUploaded, now, now, now,
)
assert.Error(t, err, "ничья запись завелась запросом к базе")
})
}
// Файл без владельца не сохраняется — тем же правилом схемы.
func TestFileWithoutOwnerIsRejected(t *testing.T) {
db, store, _ := newTestDB(t)
files := NewFileRepository(db, store)
work, err := files.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, "")
assert.Error(t, err, "файл с пустым владельцем сохранился")
}
// Учётная запись, у которой остались аудиозаписи, файлы либо темы, не удаляется:
// запрет держит схема обязательной связью, а не проверка вызывающего.
func TestAccountWithBelongingsIsNotDeletable(t *testing.T) {
db, store, _ := newTestDB(t)
t.Run("с аудиозаписями", func(t *testing.T) {
owner := newOwner(t, db)
newWorkingRecord(t, db, owner)
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.Error(t, err, "учётная запись с аудиозаписями удалилась")
})
t.Run("с одними файлами", func(t *testing.T) {
owner := newOwner(t, db)
files := NewFileRepository(db, store)
work, err := files.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.Error(t, err, "учётная запись с файлами удалилась")
})
t.Run("с одними темами", func(t *testing.T) {
owner := newOwner(t, db)
now := clock.Now().Format(timeLayout)
_, err := db.Writer().ExecContext(context.Background(),
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
ident.New(), owner, "тема", now, now,
)
require.NoError(t, err)
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.Error(t, err, "учётная запись с темами удалилась")
})
t.Run("пустая удаляется", func(t *testing.T) {
owner := newOwner(t, db)
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.NoError(t, err, "учётная запись без принадлежностей не удалилась")
})
}
// У записи не больше пяти тем, и держит это схема.
func TestRecordTopicsAreCapped(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
now := clock.Now().Format(timeLayout)
for i := range entity.MaxTopicsPerRecord + 1 {
topicID := ident.New()
_, err := db.Writer().ExecContext(context.Background(),
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
topicID, owner, "тема-"+topicID, now, now,
)
require.NoError(t, err)
_, err = db.Writer().ExecContext(context.Background(),
"INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", record.Id, topicID,
)
if i < entity.MaxTopicsPerRecord {
require.NoError(t, err, "тема %d не назначилась", i)
continue
}
assert.Error(t, err, "шестая тема назначилась")
}
}
// TestAcquireGivesRecordToExactlyOne — **критерий приёмки**: захват неделим.
//
// Одна пригодная запись, несколько захватов разом: запись достаётся ровно
// одному, остальные получают признак «работы сейчас нет».
func TestAcquireGivesRecordToExactlyOne(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
const racers = 8
var (
mu sync.Mutex
acquired []*contract.AcquiredRecord
empty int
)
start := make(chan struct{})
var wg sync.WaitGroup
for range racers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
got, err := records.FindAndAcquire(entity.WorkingStages())
mu.Lock()
defer mu.Unlock()
var missing *contract.JobNotFoundError
switch {
case err == nil:
acquired = append(acquired, got)
case assert.ErrorAs(t, err, &missing):
empty++
}
}()
}
close(start)
wg.Wait()
require.Len(t, acquired, 1, "запись досталась не одному захвату")
assert.Equal(t, racers-1, empty, "остальные получили не признак «работы нет»")
assert.Equal(t, record.Id, acquired[0].ID)
assert.NotEmpty(t, acquired[0].Holder, "захват не отдал своего признака")
}
// Результат пишет только держатель захвата, и держатель узнаётся **значением**
// признака, а не занятостью записи.
func TestSaveIsConditionalOnHolderValue(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
first, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
// Захват уходит другому: срок протухает, и запись достаётся следующему.
_, err = db.Writer().ExecContext(context.Background(),
"UPDATE audio_records SET acquire_expires_at = ? WHERE id = ?",
clock.Now().Add(-time.Hour).Format(timeLayout), record.Id,
)
require.NoError(t, err)
second, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
assert.NotEqual(t, first.Holder, second.Holder, "признак перевыданного захвата совпал с прежним")
// Прежний держатель пишет свой результат — и не пишет.
stale, err := records.Get(record.Id)
require.NoError(t, err)
stale.MoveToState(entity.StateNormalized)
err = records.Save(stale, first.Holder)
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost, "шаг, потерявший захват, записал результат")
after, err := records.Get(record.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateUploaded, after.State, "чужая запись изменила состояние")
}
// Правка владельца переживает сохранение шага: конвейер пишет только те поля,
// которыми распоряжается сам.
func TestPipelineSaveKeepsOwnerFields(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
acquired, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
held, err := records.Get(record.Id)
require.NoError(t, err)
// Владелец за это время правит поле, которого шаг не касается.
_, err = db.Writer().ExecContext(context.Background(), "UPDATE audio_records SET title = ? WHERE id = ?", "название", record.Id)
require.NoError(t, err)
held.MoveToState(entity.StateNormalized)
require.NoError(t, records.Save(held, acquired.Holder))
after, err := records.Get(record.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateNormalized, after.State, "результат шага не записан")
require.NotNil(t, after.Title)
assert.Equal(t, "название", *after.Title, "правка владельца стёрта снимком шага")
}
// Составная операция, которая читает и следом пишет, идёт по пишущему
// соединению и по занятости базы не отказывает — сколько бы потоков её ни вело.
func TestComposedOperationsDoNotFailOnBusyDatabase(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
texts := NewTextRepository(db)
var wg sync.WaitGroup
start := make(chan struct{})
for i := range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, err := texts.Put(record.Id, entity.TextKindTranscript, "разбор")
assert.NoError(t, err, "поток %d отказал по занятости базы", i)
}()
}
close(start)
wg.Wait()
var count int
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM texts").Scan(&count))
assert.Equal(t, 1, count, "восемь потоков завели больше одной строки текста")
}
// Пустая замена не стирает ни сохранённый текст, ни сохранённый ответ
// провайдера: повторный опрос вправе вернуть пустое, и безусловная замена
// стёрла бы расшифровку живого человека без следа.
func TestEmptyReplacementKeepsStoredResult(t *testing.T) {
db, store, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
texts := NewTextRepository(db)
stored, err := texts.Put(record.Id, entity.TextKindTranscript, "живая расшифровка")
require.NoError(t, err)
again, err := texts.Put(record.Id, entity.TextKindTranscript, "")
require.NoError(t, err)
assert.Equal(t, stored.Id, again.Id, "строка та же")
read, err := texts.GetByID(stored.Id)
require.NoError(t, err)
assert.Equal(t, "живая расшифровка", read.Contents, "пустое стёрло сохранённую расшифровку")
structures := NewStructureRepository(db)
first, err := structures.Put(record.Id, entity.StructureVersion,
[]entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
require.NoError(t, err)
_, err = structures.Put(record.Id, entity.StructureVersion, nil)
require.NoError(t, err)
structure, err := structures.GetByID(first.Id)
require.NoError(t, err)
assert.Len(t, structure.Replicas, 1, "пустой разбор стёр сохранённые реплики")
recognitions := NewRecognitionRepository(db, store)
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
require.NoError(t, recognitions.Create(attempt))
require.NoError(t, recognitions.Finish(attempt.Id, []byte("ответ провайдера")))
require.NoError(t, recognitions.Finish(attempt.Id, nil))
raw, err := recognitions.ReadRaw(attempt.Id)
require.NoError(t, err)
assert.Equal(t, "ответ провайдера", string(raw), "пустое стёрло сохранённый ответ провайдера")
}
// Сохранённый ответ провайдера лежит третьим файлом в подкаталоге записи, и шаг
// опроса читает строку попытки без него.
func TestProviderPayloadLivesInRecordDirectory(t *testing.T) {
db, store, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
recognitions := NewRecognitionRepository(db, store)
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
require.NoError(t, recognitions.Create(attempt))
require.NoError(t, recognitions.Finish(attempt.Id, []byte("полный ответ провайдера")))
// Строка попытки читается без ответа: он не колонка.
read, err := recognitions.GetByID(attempt.Id)
require.NoError(t, err)
require.NotNil(t, read.FinishedAt)
file, err := store.Open(record.Id, attempt.Id+payloadSuffix)
require.NoError(t, err)
require.NoError(t, file.Close())
}
// Два одновременных первых обращения одним значением дают ровно одну учётную
// запись: уникальность держит схема, а не порядок обращений.
func TestConcurrentFirstRequestsGiveOneAccount(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
login := ident.New()
var (
mu sync.Mutex
ids = map[string]bool{}
)
start := make(chan struct{})
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
account, _, err := users.EnsureUser(contract.Identity{Login: login})
if assert.NoError(t, err) {
mu.Lock()
ids[account.ID] = true
mu.Unlock()
}
}()
}
close(start)
wg.Wait()
assert.Len(t, ids, 1, "одновременные первые обращения дали разные учётные записи")
var rows int
require.NoError(t, db.Reader().
QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM users WHERE provider_login = ?", login).Scan(&rows))
assert.Equal(t, 1, rows, "в таблице пользователей больше одной строки")
}
// Занятая почта не мешает завести запись: она необязательна и ключом не служит.
func TestBusyEmailDoesNotBlockAccount(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
first, _, err := users.EnsureUser(contract.Identity{Login: "one", Email: "shared@example.com"})
require.NoError(t, err)
second, created, err := users.EnsureUser(contract.Identity{Login: "two", Email: "shared@example.com"})
require.NoError(t, err)
require.True(t, created)
assert.NotEqual(t, first.ID, second.ID)
var email string
require.NoError(t, db.Reader().
QueryRowContext(context.Background(),
"SELECT email FROM users WHERE id = ?", second.ID).Scan(&email))
assert.Empty(t, email, "вторая запись завелась с чужой почтой")
}
// Найденную запись повторное обращение не переписывает: иначе правка имени у
// провайдера меняла бы карточку человека молча, посреди его работы.
func TestSecondRequestDoesNotRewriteAccount(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
first, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Первое имя"})
require.NoError(t, err)
require.True(t, created)
again, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Второе имя"})
require.NoError(t, err)
assert.False(t, created)
assert.Equal(t, first.ID, again.ID)
assert.Equal(t, "Первое имя", again.Name, "имя переписано вторым обращением")
}
// Узнавание известного не берёт пишущего соединения: поиск идёт читающим пулом,
// и занятый писатель его не держит.
//
// Проверка нужна потому, что цена ошибки здесь не видна ни отказом, ни строкой в
// журнале. Слой узнавания одет на весь корень приложения, поэтому пишущая
// транзакция, взятая до поиска, досталась бы всему узнанному потоку — опросу
// карточки раз в четыре секунды и каждому запросу диапазона при проигрывании, —
// и встала бы в очередь к единственному пишущему соединению. Замером триажа
// такое ожидание доходило до сотен миллисекунд и **отказом не кончалось**:
// очередь к соединению ожиданием занятой базы не ограничена.
func TestKnownAccountDoesNotTakeWriter(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
login := ident.New()
first, created, err := users.EnsureUser(contract.Identity{Login: login})
require.NoError(t, err)
require.True(t, created)
// Писателя занимает открытая транзакция: соединение у пишущего пула одно,
// поэтому пока она держится, второй транзакции не начаться.
tx, err := db.Writer().BeginTx(context.Background(), nil)
require.NoError(t, err)
defer func() { _ = tx.Rollback() }()
type answer struct {
account *contract.UserAccount
created bool
err error
took time.Duration
}
done := make(chan answer, 1)
go func() {
started := time.Now()
account, created, err := users.EnsureUser(contract.Identity{Login: login})
done <- answer{account: account, created: created, err: err, took: time.Since(started)}
}()
// Предел взят с запасом: запрос читающим пулом идёт по месту, а ждущее
// узнавание не дождётся вовсе — транзакцию отпускают уже после проверки.
const limit = time.Second
select {
case got := <-done:
require.NoError(t, got.err)
assert.False(t, got.created, "узнавание известного завело вторую учётную запись")
assert.Equal(t, first.ID, got.account.ID)
t.Logf("узнавание известного заняло %s при занятом писателе", got.took)
case <-time.After(limit):
t.Errorf("узнавание известного ждёт писателя дольше %s", limit)
}
}
// Негодный логин учётной записи не заводит: пустой заголовок прокси шлёт штатно
// там, где никого не назвал.
func TestUnacceptableLoginCreatesNothing(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
for name, login := range map[string]string{
"пустой": "",
"одни пробелы": " ",
"управляющий знак": "ali\x00ce",
"длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1),
} {
t.Run(name, func(t *testing.T) {
_, _, err := users.EnsureUser(contract.Identity{Login: login})
assert.ErrorIs(t, err, contract.ErrLoginNotAcceptable)
})
}
var rows int
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&rows))
assert.Equal(t, 0, rows, "негодный логин завёл учётную запись")
}
// TestResumeReleasesEveryGuard — то, что делает подкоманда возврата в работу.
//
// Возврат идёт **через домен**: тот, кто его делает, называет запись, а поля
// сбрасывает домен одним действием. Перечень назван целиком, потому что забытое
// поле не даёт ни отказа, ни строки в журнале: оставленный признак захвата
// держит запись занятой до протухания срока, оставленное время входа в рубеж
// останавливает её снова первым же захватом, а оставленные отказы и пауза
// откладывают первый прогон на накопленный срок.
func TestResumeReleasesEveryGuard(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
events := NewRecordEventRepository(db)
// Так выглядит запись, остановленная после долгих отказов: захват на ней
// стоит, срок его далеко впереди, отказы накоплены, пауза назначена, а в
// рубеже она простояла дольше предела.
_, err := db.Writer().ExecContext(context.Background(), `
UPDATE audio_records
SET halted_at = ?, halt_reason = ?, error_text = ?,
acquisition_id = ?, acquire_expires_at = ?,
delay_time = ?, attempts = 7, state_entered_at = ?
WHERE id = ?`,
clock.Now().Format(timeLayout), entity.HaltReasonAttempts, "исчерпаны отказы",
ident.New(), clock.Now().Add(8*time.Hour).Format(timeLayout),
clock.Now().Add(time.Hour).Format(timeLayout),
clock.Now().Add(-48*time.Hour).Format(timeLayout),
record.Id,
)
require.NoError(t, err)
// Остановленная запись захвату не выдаётся.
_, err = records.FindAndAcquire(entity.WorkingStages())
var missing *contract.JobNotFoundError
require.ErrorAs(t, err, &missing, "остановленная запись досталась захвату")
halted, err := records.Get(record.Id)
require.NoError(t, err)
halted.Resume()
require.NoError(t, records.Save(halted, ""))
require.NoError(t, events.Append(&entity.RecordEvent{
RecordID: record.Id,
Origin: entity.EventOriginHuman,
Step: "resume",
Outcome: entity.EventOutcomeResumed,
}))
after, err := records.Get(record.Id)
require.NoError(t, err)
assert.False(t, after.IsHalted(), "признак остановки остался")
assert.Nil(t, after.HaltReason, "причина остановки осталась")
assert.Nil(t, after.ErrorText, "машинный текст отказа остался")
assert.Nil(t, after.AcquisitionID, "признак захвата остался")
assert.Nil(t, after.AcquireExpiresAt, "срок протухания захвата остался")
assert.Nil(t, after.DelayTime, "пауза перед повтором осталась")
assert.Equal(t, 0, after.Attempts, "число отказов осталось")
assert.WithinDuration(t, clock.Now(), after.StateEnteredAt, time.Minute,
"время входа в рубеж не сброшено: запись остановится снова первым же захватом")
assert.Equal(t, entity.StateUploaded, after.State, "рубеж не пережил возврата в работу")
// Ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока.
acquired, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "возвращённая в работу запись захвату не досталась")
assert.Equal(t, record.Id, acquired.ID)
// И возврат виден в журнале событий записи — происхождением «человек».
var origin, outcome string
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
"SELECT origin, outcome FROM record_events WHERE record_id = ?", record.Id,
).Scan(&origin, &outcome))
assert.Equal(t, entity.EventOriginHuman, origin)
assert.Equal(t, entity.EventOutcomeResumed, outcome)
}
// Подъём на чистом каталоге данных не оставляет в журнале ни одного отказа: до
// строки о готовности схема приведена целиком.
func TestCleanStartLeavesNoFailureInJournal(t *testing.T) {
dir := t.TempDir()
journal := &strings.Builder{}
logger := slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug}))
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
require.NoError(t, Migrate(context.Background(), db, dir, logger))
assert.NotContains(t, journal.String(), "level=ERROR", "подъём оставил отказ в журнале")
assert.NotContains(t, journal.String(), "level=WARN", "подъём оставил предупреждение в журнале")
assert.Contains(t, journal.String(), "Schema migration applied", "накат не отчитался")
// И хранилище готово принимать записи сразу: ручного шага между подъёмом и
// первым приёмом нет.
owner := newOwner(t, db)
newWorkingRecord(t, db, owner)
}
+170
View File
@@ -0,0 +1,170 @@
package sqlite
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// recordsDir — раздел каталога данных, в котором лежат файлы записей.
const recordsDir = "records"
// tempPrefix — приставка временного имени укладки. Точка в начале уводит такие
// имена из обычного перечисления каталога, а сама приставка отличает
// незавершённую укладку от рабочего имени.
const tempPrefix = ".partial-"
// Store — файлы записей в каталоге данных.
//
// Раскладка: подкаталог на запись, названный её идентификатором, и в нём копии
// под именами, которые задаёт сервис. Так копии одной записи лежат вместе, а
// запись убирается целиком одним движением — плоский каталог, где копии
// различаются приставкой в имени, обращал бы уборку в перебор по маске.
//
// Имя, данное отправителем, в раскладку не попадает ни одной частью: ни именем
// файла, ни именем каталога.
type Store struct {
root string
}
// NewStore заводит раздел записей в каталоге данных.
func NewStore(dataDir string) *Store {
return &Store{root: filepath.Join(dataDir, recordsDir)}
}
// dir — подкаталог одной записи.
func (s *Store) dir(recordID string) string {
return filepath.Join(s.root, recordID)
}
// path — путь копии. Наружу не отдаётся: путь на диске не идёт ни в журнал, ни
// в ответ, ни в метку метрики.
func (s *Store) path(recordID, name string) string {
return filepath.Join(s.dir(recordID), name)
}
// Put кладёт содержимое под рабочим именем **атомарно**.
//
// Содержимое пишется во временное имя в том же подкаталоге записи и
// переименовывается в рабочее только после того, как поток дочитан до конца без
// отказа. Временное имя берётся в том же каталоге потому, что переименование в
// его пределах не копирует содержимое и не может оборваться на середине.
//
// Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины
// записи со строкой файла не сверяются. Усечённая запись поэтому уехала бы в
// конвейер, оплатила распознавание и отдала расшифровку половины как готовый
// результат — атомарная укладка единственное, что этого не допускает.
//
// Отказ источника и отмена посреди потока кончаются одним исходом: временного
// имени не остаётся, рабочего имени не появляется.
func (s *Store) Put(recordID, name string, src io.Reader) (int64, error) {
if err := os.MkdirAll(s.dir(recordID), 0o750); err != nil {
return 0, fmt.Errorf("failed to create the directory of record %s: %w", recordID, causeOf(err))
}
temp := s.path(recordID, tempPrefix+ident.New())
file, err := os.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
if err != nil {
return 0, fmt.Errorf("failed to open the incoming copy of record %s: %w", recordID, causeOf(err))
}
size, copyErr := io.Copy(file, src)
syncErr := file.Sync()
closeErr := file.Close()
if err := errors.Join(copyErr, syncErr, closeErr); err != nil {
_ = os.Remove(temp)
// Путь и имя файла в цепочку не идут, а причина идёт: отказ кончается в
// журнале, журнал уезжает в собранные логи, откуда строку не убрать, —
// но по причине владелец различает исчерпание места, отсутствие прав и
// файловую систему только для чтения.
return 0, fmt.Errorf("failed to store a copy of record %s: %w", recordID, causeOf(err))
}
if err := os.Rename(temp, s.path(recordID, name)); err != nil {
_ = os.Remove(temp)
return 0, fmt.Errorf("failed to publish a copy of record %s: %w", recordID, causeOf(err))
}
return size, nil
}
// Open отдаёт содержимое копии потоком с возможностью перемотки: отдача файла
// по диапазону читает кусок, а не файл целиком.
func (s *Store) Open(recordID, name string) (*os.File, error) {
file, err := os.Open(s.path(recordID, name))
if err != nil {
// Отказ называет запись её идентификатором и не несёт имени файла:
// имя — часть пути к чужому аудио. Причина при этом остаётся: «файла
// нет» и «прав нет» ведут владельца к разным действиям.
return nil, fmt.Errorf("failed to read a copy of record %s: %w", recordID, causeOf(err))
}
return file, nil
}
// Remove убирает копию. Отсутствие файла отказом не считается: уборка зовётся и
// там, где укладка до него не дошла.
func (s *Store) Remove(recordID, name string) error {
if err := os.Remove(s.path(recordID, name)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove a copy of record %s: %w", recordID, causeOf(err))
}
return nil
}
// HasTemporary говорит, осталось ли в подкаталоге записи незавершённое имя.
// Нужен проверкам: обещание атомарной укладки иначе судилось бы только по
// отсутствию рабочего имени.
func (s *Store) HasTemporary(recordID string) (bool, error) {
entries, err := os.ReadDir(s.dir(recordID))
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("failed to read the directory of record %s: %w", recordID, causeOf(err))
}
for _, entry := range entries {
if len(entry.Name()) > len(tempPrefix) && entry.Name()[:len(tempPrefix)] == tempPrefix {
return true, nil
}
}
return false, nil
}
// causeOf снимает с отказа файловой операции путь, оставляя причину.
//
// Обе половины обязательны, и порознь они друг друга отменяют. Причина нужна:
// по ней владелец различает исчерпание места, отсутствие прав и файловую систему
// только для чтения — три поломки, требующие трёх разных действий, а отказ
// укладки — единственная поверхность, на которой он их видит. Путь не нужен и
// вреден: он ведёт внутрь каталога данных, а отказ кончается в журнале, откуда
// строку потом не убрать.
//
// Пакет `os` отдаёт причину обёрнутой в `*os.PathError` либо `*os.LinkError` —
// именно там и лежит путь. Заворачивается поэтому `.Err`, а не обёртка целиком:
// `errors.Is` до `fs.ErrPermission` и `syscall.ENOSPC` сравнивает значение под
// обёрткой и от её снятия не страдает.
//
// Соединённый отказ разбирается по частям: укладка складывает отказы записи,
// сброса и закрытия, и путь лежит в каждой из них.
func causeOf(err error) error {
switch typed := err.(type) { //nolint:errorlint // разбирается сам отказ, а не цепочка: обёртку и надо снять
case *os.PathError:
return typed.Err
case *os.LinkError:
return typed.Err
case interface{ Unwrap() []error }:
parts := typed.Unwrap()
causes := make([]error, 0, len(parts))
for _, part := range parts {
causes = append(causes, causeOf(part))
}
return errors.Join(causes...)
}
return err
}
+205
View File
@@ -0,0 +1,205 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
type TextRepository struct {
db *DB
}
func NewTextRepository(db *DB) *TextRepository {
return &TextRepository{db: db}
}
// Put кладёт текст записи, заменяя прежний того же вида.
//
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния.
//
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
// умер, сохранение рубежа отказало, человек вернул запись в работу. Провайдер
// при этом вправе ответить пустым потоком, отказом это не считается, и
// безусловная замена стирала бы сохранённую расшифровку живого человека без
// следа и без возврата. Та же защита стоит у сохранённого ответа провайдера, и
// разное правило у двух хранителей одного результата читалось бы как недосмотр.
//
// **Граница транзакции — весь метод.** Он читает состояние, которое сам же
// пишет, и идёт целиком по пишущему соединению: разорванный надвое, он завёл бы
// вторую строку на гонке двух шагов.
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
if err != nil {
return nil, fmt.Errorf("failed to open a transaction for the text of record %s: %w", recordID, err)
}
defer func() { _ = tx.Rollback() }()
var (
id string
existing string
)
err = tx.QueryRowContext(context.Background(),
"SELECT id, contents FROM texts WHERE record_id = ? AND kind = ?", recordID, kind,
).Scan(&id, &existing)
now := formatTime(clock.Now())
switch {
case err == nil:
// Прежнее непустое содержимое пустым не заменяется: строка остаётся как
// есть, и вызывающий получает её обратно.
if contents == "" && existing != "" {
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: existing}, nil
}
if _, err := tx.ExecContext(context.Background(),
"UPDATE texts SET contents = ?, updated_at = ? WHERE id = ?", contents, now, id,
); err != nil {
// Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от
// драйвера несёт значение поля.
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
}
case errors.Is(err, sql.ErrNoRows):
id = ident.New()
if _, err := tx.ExecContext(context.Background(),
`INSERT INTO texts (id, record_id, kind, contents, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
id, recordID, kind, contents, now, now,
); err != nil {
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
}
default:
// Отказ базы «строкой нет» не является, и подменять его вставкой нельзя:
// она упрётся в уникальный индекс, и наверх уедет жалоба на запись
// вместо правды о недоступной базе.
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit the text of record %s: %w", recordID, err)
}
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: contents}, nil
}
func (repo *TextRepository) GetByID(id string) (*entity.Text, error) {
text := &entity.Text{Id: id}
err := repo.db.Reader().QueryRowContext(context.Background(),
"SELECT record_id, kind, contents FROM texts WHERE id = ?", id,
).Scan(&text.RecordID, &text.Kind, &text.Contents)
if err != nil {
return nil, fmt.Errorf("failed to get text %s: %w", id, err)
}
return text, nil
}
type StructureRepository struct {
db *DB
}
func NewStructureRepository(db *DB) *StructureRepository {
return &StructureRepository{db: db}
}
// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот
// же, что и у текста: повтор шага не должен заводить второй строки, а пустой
// перечень реплик поверх непустого не кладётся.
func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) {
if replicas == nil {
replicas = []entity.Replica{}
}
contents, err := json.Marshal(replicas)
if err != nil {
return nil, fmt.Errorf("failed to encode structure of record %s", recordID)
}
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
if err != nil {
return nil, fmt.Errorf("failed to open a transaction for the structure of record %s: %w", recordID, err)
}
defer func() { _ = tx.Rollback() }()
var (
id string
existing string
)
err = tx.QueryRowContext(context.Background(),
"SELECT id, contents FROM structures WHERE record_id = ? AND version = ?", recordID, version,
).Scan(&id, &existing)
now := formatTime(clock.Now())
switch {
case err == nil:
if len(replicas) == 0 && len(existing) > len("[]") {
stored, decodeErr := decodeReplicas(id, existing)
if decodeErr != nil {
return nil, decodeErr
}
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: stored}, nil
}
if _, err := tx.ExecContext(context.Background(),
"UPDATE structures SET contents = ?, updated_at = ? WHERE id = ?", string(contents), now, id,
); err != nil {
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
}
case errors.Is(err, sql.ErrNoRows):
id = ident.New()
if _, err := tx.ExecContext(context.Background(),
`INSERT INTO structures (id, record_id, version, contents, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
id, recordID, version, string(contents), now, now,
); err != nil {
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
}
default:
return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit the structure of record %s: %w", recordID, err)
}
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: replicas}, nil
}
func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) {
structure := &entity.Structure{Id: id}
var contents string
err := repo.db.Reader().QueryRowContext(context.Background(),
"SELECT record_id, version, contents FROM structures WHERE id = ?", id,
).Scan(&structure.RecordID, &structure.Version, &contents)
if err != nil {
return nil, fmt.Errorf("failed to get structure %s: %w", id, err)
}
replicas, err := decodeReplicas(id, contents)
if err != nil {
return nil, err
}
structure.Replicas = replicas
return structure, nil
}
// decodeReplicas разбирает сохранённые реплики. Текст расшифровки наружу
// отказом не выходит: сообщение несёт идентификатор строки, и только его.
func decodeReplicas(id, contents string) ([]entity.Replica, error) {
if contents == "" {
return nil, nil
}
var replicas []entity.Replica
if err := json.Unmarshal([]byte(contents), &replicas); err != nil {
return nil, fmt.Errorf("failed to decode structure %s", id)
}
return replicas, nil
}
+190
View File
@@ -0,0 +1,190 @@
package sqlite
import (
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
sqlitedriver "modernc.org/sqlite"
sqlitelib "modernc.org/sqlite/lib"
)
// timeLayout — единственный вид времени в схеме: RFC 3339, UTC, суффикс `Z`,
// секундная точность.
//
// Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT`
// совпадает с хронологией, и отбор по колонке времени работает без разбора
// значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё
// положили, — колонка, заполненная то одним видом, то другим, обратила бы
// условие срока протухания захвата в постоянную истину или ложь молча, и запись
// не выдавалась бы ни одному воркеру никогда.
const timeLayout = "2006-01-02T15:04:05Z"
// formatTime приводит метку времени к виду колонки.
func formatTime(v time.Time) string {
return v.UTC().Format(timeLayout)
}
// timeValue кладёт время в колонку, допускающую пустое значение. Нулевое время
// и отсутствующее — одно и то же: «времени нет».
func timeValue(v *time.Time) any {
if v == nil || v.IsZero() {
return nil
}
return formatTime(*v)
}
// timeOf читает колонку времени. Нечитаемое значение отдаётся нулевым: колонка
// пишется только нами, и разбор здесь — сторож, а не ветвь поведения.
func timeOf(v sql.NullString) *time.Time {
if !v.Valid || v.String == "" {
return nil
}
parsed, err := time.Parse(timeLayout, v.String)
if err != nil {
return nil
}
parsed = parsed.UTC()
return &parsed
}
// requiredTimeOf читает обязательную колонку времени.
func requiredTimeOf(v string) time.Time {
parsed, err := time.Parse(timeLayout, v)
if err != nil {
return time.Time{}
}
return parsed.UTC()
}
// stringValue кладёт необязательную строку: пустая и отсутствующая — одно и то
// же.
func stringValue(v *string) any {
if v == nil || *v == "" {
return nil
}
return *v
}
// stringOf читает необязательную строку.
func stringOf(v sql.NullString) *string {
if !v.Valid || v.String == "" {
return nil
}
out := v.String
return &out
}
// numberOf читает необязательное число.
//
// Указатель здесь не выражает «неизвестно»: обе величины записи ставит приём и
// ставит всегда, а колонки объявлены обязательными. Форма осталась указателем
// потому, что её несёт домен, а ответ приложению обязан различать поле и его
// отсутствие.
func numberOf(v int64) *int64 {
out := v
return &out
}
// numberValue кладёт необязательное число нулём: колонка обязательна.
func numberValue(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
// insertSQL собирает вставку из карты «колонка → значение».
//
// Именованными параметрами, а не позиционным списком: у аудиозаписи поля одного
// типа идут длинным непрерывным рядом, и позиционный сдвиг на одно поле
// скомпилировался бы молча, положив идентификатор файла в колонку текста. По
// имени такого сдвига не существует вовсе.
//
// Порядок колонок берётся сортировкой, а не порядком обхода карты: обход карты
// в Go случаен, и текст запроса менялся бы от прогона к прогону — отладка по
// журналу читала бы каждый раз новый запрос.
func insertSQL(table string, values map[string]any) (string, []any) {
names := sortedNames(values)
placeholders := make([]string, 0, len(names))
args := make([]any, 0, len(names))
for _, name := range names {
placeholders = append(placeholders, ":"+name)
args = append(args, sql.Named(name, values[name]))
}
query := fmt.Sprintf(
"INSERT INTO %s (%s) VALUES (%s)",
table,
strings.Join(names, ", "),
strings.Join(placeholders, ", "),
)
return query, args
}
// updateSQL собирает правку из карты «колонка → значение» и условия.
func updateSQL(table string, values map[string]any, where string, whereArgs []any) (string, []any) {
names := sortedNames(values)
assignments := make([]string, 0, len(names))
args := make([]any, 0, len(names)+len(whereArgs))
for _, name := range names {
assignments = append(assignments, name+" = :"+name)
args = append(args, sql.Named(name, values[name]))
}
args = append(args, whereArgs...)
query := fmt.Sprintf(
"UPDATE %s SET %s WHERE %s",
table,
strings.Join(assignments, ", "),
where,
)
return query, args
}
func sortedNames(values map[string]any) []string {
names := make([]string, 0, len(values))
for name := range values {
names = append(names, name)
}
sort.Strings(names)
return names
}
// selectList собирает перечень колонок для выборки из той же карты, по которой
// потом идёт чтение. Один источник у обеих половин: колонка, забытая в перечне,
// не имеет места назначения, и наоборот — расхождению взяться неоткуда.
func selectList(targets map[string]any, prefix string) (string, []any) {
names := sortedNames(targets)
columns := make([]string, 0, len(names))
scan := make([]any, 0, len(names))
for _, name := range names {
columns = append(columns, prefix+name)
scan = append(scan, targets[name])
}
return strings.Join(columns, ", "), scan
}
// isUniqueViolation говорит, отказала ли запись по уникальному индексу.
//
// Судится **код** отказа, а не его текст: текст у драйвера свой на каждую
// версию, а узнавание ошибки по тексту запрещено правилом проекта. Какая именно
// колонка не сошлась, код не называет — и это не мешает: заведение учётной
// записи различает два отказа повторным поиском по ключу, а не разбором текста.
func isUniqueViolation(err error) bool {
var sqliteErr *sqlitedriver.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_UNIQUE ||
sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY
}