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