From 4a052ec99b1647894db066211ff9c2c91bf8c61c Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Wed, 12 Aug 2026 22:03:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A8=D0=B0=D0=B3=D0=B8=20=D1=81=D1=85=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=B2=20=D1=81=D0=B2=D0=BE=D0=B9=20=D0=BA=D0=B0=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3,=20=D0=B8=20=D1=81=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B8=D1=85=20=D1=81=D0=BD=D0=BE=D0=B2=D0=B0=20=D0=B2?= =?UTF-8?q?=D0=B8=D0=B4=D0=B8=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - шаги PocketBase переехали из файла в пакет internal/adapter/repo/pocketbase/migrations, файл на шаг с именем зарегистрированного шага; туда же имена коллекций, срок сессии — в provider.go - ключ migrations в docs/.docs.json наведён на этот каталог: прежнее значение указывало на несуществующий migrations/, и шаг гейта проходил зелёным при всякой правке схемы - app.go подключает пакет шагов явным пустым импортом: пропавшая ссылка на константы унесла бы регистрацию, и хранилище поднялось бы без коллекций --- README.md | 6 +- docs/.docs.json | 2 +- docs/architecture.md | 3 +- docs/conventions/README.md | 2 +- docs/conventions/database.md | 11 +- docs/database.md | 16 ++- docs/review.md | 3 +- internal/adapter/repo/pocketbase/app.go | 14 +- internal/adapter/repo/pocketbase/file_repo.go | 12 +- .../migrations/202608110001_init.go | 109 +++++++++++++++ .../202608120001_oidc_login.go} | 130 +----------------- .../repo/pocketbase/migrations/migrations.go | 35 +++++ internal/adapter/repo/pocketbase/panel.go | 4 +- internal/adapter/repo/pocketbase/provider.go | 11 ++ .../repo/pocketbase/transcript_job_repo.go | 12 +- .../pocketbase/transcript_job_repo_test.go | 10 +- internal/controller/http/auth_test.go | 13 +- internal/controller/http/login_test.go | 5 +- internal/controller/http/transcribe_test.go | 7 +- internal/service/pipeline_test.go | 9 +- internal/service/recognition_test.go | 4 +- 21 files changed, 235 insertions(+), 183 deletions(-) create mode 100644 internal/adapter/repo/pocketbase/migrations/202608110001_init.go rename internal/adapter/repo/pocketbase/{migrations.go => migrations/202608120001_oidc_login.go} (50%) create mode 100644 internal/adapter/repo/pocketbase/migrations/migrations.go diff --git a/README.md b/README.md index 473da89..7b4f255 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,9 @@ transcriber/ ## Разработка Схему двигают шаги миграций PocketBase на Go — -`internal/adapter/repo/pocketbase`. Непринятые шаги накатываются при подъёме -хранилища, прежде чем стартуют воркеры и сервер. Применённый шаг не -переписывается: изменение — только новым файлом шага. +`internal/adapter/repo/pocketbase/migrations`, файл на шаг. Непринятые шаги +накатываются при подъёме хранилища, прежде чем стартуют воркеры и сервер. +Применённый шаг не переписывается: изменение — только новым файлом шага. Проверки перед коммитом — одной командой: diff --git a/docs/.docs.json b/docs/.docs.json index 37ee485..0439d82 100644 --- a/docs/.docs.json +++ b/docs/.docs.json @@ -1,4 +1,4 @@ { "canon": 14, - "migrations": "migrations" + "migrations": "internal/adapter/repo/pocketbase/migrations" } diff --git a/docs/architecture.md b/docs/architecture.md index 81cd0dc..bbf825b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,7 +74,8 @@ | Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit | | Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам | | Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом | -| Панель владельца | там же, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода | +| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций | +| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода | diff --git a/docs/conventions/README.md b/docs/conventions/README.md index 1565bcf..cf78c7b 100644 --- a/docs/conventions/README.md +++ b/docs/conventions/README.md @@ -59,7 +59,7 @@ htmx, а здесь решено делать SPA — и перенесённы | Форматирование исходников | `.golangci.yml` → `gofmt` | | Подозрительные конструкции языка | `.golangci.yml` → `govet`, `staticcheck`, `ineffassign`, `unused` | | Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` | -| Раскладка документов, битые ссылки, миграция без правки `database.md` | `docs.py check` | +| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` в `docs/.docs.json` | | Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) | Не названное здесь место механизации означает, что проход по конвенциям будет diff --git a/docs/conventions/database.md b/docs/conventions/database.md index c9791b3..8e18c78 100644 --- a/docs/conventions/database.md +++ b/docs/conventions/database.md @@ -8,8 +8,10 @@ и разбора нет. Правила действуют на новый код; переписывание существующего — отдельная работа, и до неё расхождение читается как долг, а не как нарушение. -**Механизировано:** ничего. Ни правила линтера, ни теста-сканера под эти пункты -в transcriber нет. +**Механизировано:** одно — сверка изменённого шага схемы с +[../database.md](../database.md), шаг гейта `docs.py check` +([README.md](README.md), «Механизировано»). Под прочие пункты ни правила +линтера, ни теста-сканера в transcriber нет. ## Первичные ключи — ULID, не автоинкремент @@ -55,8 +57,9 @@ лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`). Единая точка генерации — приложение, а не умолчание в схеме: так забытая вставка падает громко. Измерение длительности — не метка времени. -- Миграции — шаги PocketBase на Go (`internal/adapter/repo/pocketbase`): - коллекции и их поля заводятся кодом. При изменении структуры обновляем схему +- Миграции — шаги PocketBase на Go + (`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их + поля заводятся кодом. При изменении структуры обновляем схему [../database.md](../database.md) тем же изменением. - Время в **сыром запросе** кладётся и сравнивается тем же видом, каким хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и diff --git a/docs/database.md b/docs/database.md index 129bd81..fd4aed6 100644 --- a/docs/database.md +++ b/docs/database.md @@ -8,11 +8,17 @@ CGO сборке не нужен. Схему двигают **шаги миграций PocketBase** на Go, каталог -`internal/adapter/repo/pocketbase`, файл шага — `migrations.go`. Шаг -регистрируется при загрузке пакета, а накатывается при подъёме хранилища -(`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не -переписывается — изменение только новым шагом: применённое хранилище считает по -имени файла. +`internal/adapter/repo/pocketbase/migrations`, файл на шаг и имя файла — имя +шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме +хранилища (`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый +шаг не переписывается — изменение только новым шагом: применённое хранилище +считает по имени шага. + +Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя: +шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу +пути** (`docs/.docs.json`, ключ `migrations`), а префикс наводится только на +каталог. Имена коллекций живут там же, рядом с шагом, который их заводит; пакет +репозитория берёт их оттуда. **Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита. Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в diff --git a/docs/review.md b/docs/review.md index bc7faf4..3105841 100644 --- a/docs/review.md +++ b/docs/review.md @@ -38,7 +38,8 @@ - вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в успех молча. -**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`): +**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы — +подпакетом `migrations`): - список колонок совпадает во всех четырёх местах — `applyToRecord`, `recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант diff --git a/internal/adapter/repo/pocketbase/app.go b/internal/adapter/repo/pocketbase/app.go index ae6587d..ef51caf 100644 --- a/internal/adapter/repo/pocketbase/app.go +++ b/internal/adapter/repo/pocketbase/app.go @@ -10,13 +10,15 @@ import ( pb "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/core" -) -// Имена коллекций. Они же — часть пути к файлу в раскладке хранилища и часть -// адреса ссылки на него, поэтому меняются только новым шагом схемы. -const ( - FilesCollection = "files" - JobsCollection = "transcribe_jobs" + // Шаги схемы регистрируются загрузкой своего пакета, а накатывает их + // `RunAllMigrations` ниже. Импорт здесь пустой и явный, хотя соседние файлы + // пакета и так берут оттуда имена коллекций: день, когда имена перестанут + // читаться отсюда, унёс бы вместе с последней ссылкой и регистрацию — список + // шагов остался бы пустым, `RunAllMigrations` вернул бы `nil`, и приложение + // поднялось бы здоровым, но без коллекций. Отказ вылез бы не на старте, а на + // первом приёме записи. + _ "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" ) // New создаёт приложение хранилища на заданном каталоге данных и приводит его в diff --git a/internal/adapter/repo/pocketbase/file_repo.go b/internal/adapter/repo/pocketbase/file_repo.go index 78821fb..b5c3401 100644 --- a/internal/adapter/repo/pocketbase/file_repo.go +++ b/internal/adapter/repo/pocketbase/file_repo.go @@ -12,6 +12,8 @@ import ( "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 — рабочая копия файла на диске. Живёт во временном каталоге @@ -82,7 +84,7 @@ func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkF } func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) { - record, err := repo.app.FindRecordById(FilesCollection, fileID) + record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID) if err != nil { return nil, fmt.Errorf("failed to find file %s: %w", fileID, err) } @@ -115,7 +117,7 @@ func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) { // хранилище не попадает — путь к файлу читается в журнале, и инвариант // приватности этого не допускает. Свой суффикс хранилище допишет само. func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) { - collection, err := findCollection(repo.app, FilesCollection) + collection, err := findCollection(repo.app, migrations.FilesCollection) if err != nil { return nil, err } @@ -142,7 +144,7 @@ func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*e } func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) { - collection, err := findCollection(repo.app, FilesCollection) + collection, err := findCollection(repo.app, migrations.FilesCollection) if err != nil { return nil, err } @@ -160,7 +162,7 @@ func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity. } func (repo *FileRepository) GetByID(id string) (*entity.File, error) { - record, err := repo.app.FindRecordById(FilesCollection, id) + record, err := repo.app.FindRecordById(migrations.FilesCollection, id) if err != nil { return nil, fmt.Errorf("failed to get file: %w", err) } @@ -168,7 +170,7 @@ func (repo *FileRepository) GetByID(id string) (*entity.File, error) { } func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) { - record, err := repo.app.FindRecordById(FilesCollection, fileID) + record, err := repo.app.FindRecordById(migrations.FilesCollection, fileID) if err != nil { return nil, fmt.Errorf("failed to find file %s: %w", fileID, err) } diff --git a/internal/adapter/repo/pocketbase/migrations/202608110001_init.go b/internal/adapter/repo/pocketbase/migrations/202608110001_init.go new file mode 100644 index 0000000..aa07dfc --- /dev/null +++ b/internal/adapter/repo/pocketbase/migrations/202608110001_init.go @@ -0,0 +1,109 @@ +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 +} diff --git a/internal/adapter/repo/pocketbase/migrations.go b/internal/adapter/repo/pocketbase/migrations/202608120001_oidc_login.go similarity index 50% rename from internal/adapter/repo/pocketbase/migrations.go rename to internal/adapter/repo/pocketbase/migrations/202608120001_oidc_login.go index 3c1dcb9..bbfd63e 100644 --- a/internal/adapter/repo/pocketbase/migrations.go +++ b/internal/adapter/repo/pocketbase/migrations/202608120001_oidc_login.go @@ -1,138 +1,12 @@ -package pocketbase +package migrations import ( "errors" "fmt" "github.com/pocketbase/pocketbase/core" - "github.com/pocketbase/pocketbase/migrations" - - "git.vakhrushev.me/av/transcriber/internal/entity" ) -// Схема заводится версионированными шагами, и применённый шаг не переписывается -// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает -// применённое по имени файла шага. -// -// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его -// `apis.Serve` прежде, чем поднять сервер. -func init() { - migrations.Register(up202608110001, down202608110001, "202608110001_init.go") - migrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go") -} - -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 -} - -// SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано -// решением владельца от 2026-08-12; умолчание библиотеки в пять суток не -// применяется, потому что оно никем не выбрано. -// -// Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками -// провайдера. Причина та же: применённый шаг не переписывается, и число, -// положенное туда, разошлось бы со сроком жизни куки при первой же правке — -// браузер получил бы новый срок, а хранилище продолжило выдавать прежний. -const SessionDuration = 7 * 24 * 60 * 60 - // defaultAuthTokenDuration — умолчание библиотеки, к которому возвращает откат. const defaultAuthTokenDuration = 1209600 @@ -248,5 +122,3 @@ func down202608120001(app core.App) error { return nil } - -func ptr[T any](v T) *T { return &v } diff --git a/internal/adapter/repo/pocketbase/migrations/migrations.go b/internal/adapter/repo/pocketbase/migrations/migrations.go new file mode 100644 index 0000000..dfaf3cd --- /dev/null +++ b/internal/adapter/repo/pocketbase/migrations/migrations.go @@ -0,0 +1,35 @@ +// Package migrations — шаги схемы хранилища и имена коллекций, которые они +// заводят. +// +// Схема заводится версионированными шагами, и применённый шаг не переписывается +// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает +// применённое по **имени шага**, а не по пути файла, поэтому имена в +// `Register` ниже не переносятся и не переименовываются, даже если файл переехал. +// +// Шаги лежат своим каталогом, а не файлом внутри пакета репозитория, и причина +// внешняя: сверка документов ловит изменённый шаг схемы при нетронутом +// `docs/database.md` по префиксу пути (`docs/.docs.json`, ключ `migrations`), а +// префикс наводится только на каталог. Пока шаги лежали файлом, наводить его +// было не на что, и проверка молчала на всякой правке схемы. +package migrations + +import ( + pbmigrations "github.com/pocketbase/pocketbase/migrations" +) + +// Имена коллекций живут здесь, рядом с шагом, который их заводит. Они же — часть +// пути к файлу в раскладке хранилища и часть адреса ссылки на него, поэтому +// меняются только новым шагом схемы. +const ( + FilesCollection = "files" + JobsCollection = "transcribe_jobs" +) + +// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его +// `apis.Serve` прежде, чем поднять сервер. +func init() { + pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go") + pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go") +} + +func ptr[T any](v T) *T { return &v } diff --git a/internal/adapter/repo/pocketbase/panel.go b/internal/adapter/repo/pocketbase/panel.go index e1b40aa..5908189 100644 --- a/internal/adapter/repo/pocketbase/panel.go +++ b/internal/adapter/repo/pocketbase/panel.go @@ -2,6 +2,8 @@ package pocketbase import ( "github.com/pocketbase/pocketbase/core" + + "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" ) // BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что @@ -20,7 +22,7 @@ import ( // стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое // переход хранит намеренно — приходило бы владельцу нулём. func BindPanelRules(app core.App) { - app.OnRecordUpdateRequest(JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error { + app.OnRecordUpdateRequest(migrations.JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error { original := e.Record.Original() if original == nil || original.GetString("state") == e.Record.GetString("state") { return e.Next() diff --git a/internal/adapter/repo/pocketbase/provider.go b/internal/adapter/repo/pocketbase/provider.go index 5172de2..10e3061 100644 --- a/internal/adapter/repo/pocketbase/provider.go +++ b/internal/adapter/repo/pocketbase/provider.go @@ -10,6 +10,17 @@ import ( // как обобщённый OIDC и по нему же ищет настройку при обмене кода. const ProviderName = "oidc" +// SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано +// решением владельца от 2026-08-12; умолчание библиотеки в пять суток не +// применяется, потому что оно никем не выбрано. +// +// Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками +// провайдера, и потому живёт здесь, а не в каталоге шагов. Причина та же: +// применённый шаг не переписывается, и число, положенное туда, разошлось бы со +// сроком жизни куки при первой же правке — браузер получил бы новый срок, а +// хранилище продолжило выдавать прежний. +const SessionDuration = 7 * 24 * 60 * 60 + // ProviderSettings — то, что приезжает из конфига и приводится к настройкам // коллекции. type ProviderSettings struct { diff --git a/internal/adapter/repo/pocketbase/transcript_job_repo.go b/internal/adapter/repo/pocketbase/transcript_job_repo.go index f788d48..9357c5f 100644 --- a/internal/adapter/repo/pocketbase/transcript_job_repo.go +++ b/internal/adapter/repo/pocketbase/transcript_job_repo.go @@ -12,6 +12,8 @@ import ( "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" + + "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" ) type TranscriptJobRepository struct { @@ -23,7 +25,7 @@ func NewTranscriptJobRepository(app core.App) *TranscriptJobRepository { } func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error { - collection, err := findCollection(repo.app, JobsCollection) + collection, err := findCollection(repo.app, migrations.JobsCollection) if err != nil { return err } @@ -50,7 +52,7 @@ func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error { // LostAcquisitionError и результата не пишет. func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error { err := repo.app.RunInTransaction(func(txApp core.App) error { - record, err := txApp.FindRecordById(JobsCollection, job.Id) + record, err := txApp.FindRecordById(migrations.JobsCollection, job.Id) if err != nil { return fmt.Errorf("failed to find transcribe job: %w", err) } @@ -77,7 +79,7 @@ func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder stri } func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) { - record, err := repo.app.FindRecordById(JobsCollection, id) + record, err := repo.app.FindRecordById(migrations.JobsCollection, id) if err != nil { return nil, fmt.Errorf("failed to get transcribe job: %w", err) } @@ -108,13 +110,13 @@ func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, now := types.NowDateTime() query := repo.app.DB().NewQuery(` - UPDATE {{` + JobsCollection + `}} + UPDATE {{` + migrations.JobsCollection + `}} SET acquisition_id = {:acquisition_id}, acquire_time = {:now}, attempts = attempts + 1, updated = {:now} WHERE id = ( - SELECT id FROM {{` + JobsCollection + `}} + SELECT id FROM {{` + migrations.JobsCollection + `}} WHERE state = {:state} AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now}) AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting}) diff --git a/internal/adapter/repo/pocketbase/transcript_job_repo_test.go b/internal/adapter/repo/pocketbase/transcript_job_repo_test.go index ffcfac0..a3eb1a2 100644 --- a/internal/adapter/repo/pocketbase/transcript_job_repo_test.go +++ b/internal/adapter/repo/pocketbase/transcript_job_repo_test.go @@ -16,6 +16,8 @@ import ( "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" + + "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" ) // newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же @@ -136,7 +138,7 @@ func TestFindAndAcquire_RottenAcquisitionIsHandedOutAgain(t *testing.T) { // Задним числом — записью коллекции, то есть тем же слоем, который пишет // собственные времена хранилища. - record, err := app.FindRecordById(JobsCollection, job.Id) + record, err := app.FindRecordById(migrations.JobsCollection, job.Id) require.NoError(t, err) record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour)) require.NoError(t, app.Save(record)) @@ -232,7 +234,7 @@ func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) { require.NoError(t, err) // Задача досталась другому, пока шаг работал. - record, err := app.FindRecordById(JobsCollection, mine.Id) + record, err := app.FindRecordById(migrations.JobsCollection, mine.Id) require.NoError(t, err) record.Set("acquisition_id", "someone-else") require.NoError(t, app.Save(record)) @@ -279,7 +281,7 @@ func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) { require.NoError(t, err) require.NotNil(t, acquired.AcquisitionID) - record, err := app.FindRecordById(JobsCollection, job.Id) + record, err := app.FindRecordById(migrations.JobsCollection, job.Id) require.NoError(t, err) record.Set("attempts", 5) record.Set("state", entity.StateDead) @@ -360,7 +362,7 @@ func patchRecord(t *testing.T, app core.App, recordID, body string) { req := httptest.NewRequest( http.MethodPatch, - "/api/collections/"+JobsCollection+"/records/"+recordID, + "/api/collections/"+migrations.JobsCollection+"/records/"+recordID, strings.NewReader(body), ) req.Header.Set("Content-Type", "application/json") diff --git a/internal/controller/http/auth_test.go b/internal/controller/http/auth_test.go index 6229262..e6234d7 100644 --- a/internal/controller/http/auth_test.go +++ b/internal/controller/http/auth_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" + "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" ) // Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем @@ -33,11 +34,11 @@ func TestApiRequiresSession(t *testing.T) { // Ни файла, ни задачи: отказ наступает раньше, чем запись попадает в // хранилище. - files, err := env.app.FindAllRecords(pbrepo.FilesCollection) + files, err := env.app.FindAllRecords(migrations.FilesCollection) require.NoError(t, err) assert.Empty(t, files) - jobs, err := env.app.FindAllRecords(pbrepo.JobsCollection) + jobs, err := env.app.FindAllRecords(migrations.JobsCollection) require.NoError(t, err) assert.Empty(t, jobs) }) @@ -63,7 +64,7 @@ func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) { env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio"))) require.Equal(t, http.StatusCreated, created.Code) - jobs, err := env.app.FindAllRecords(pbrepo.JobsCollection) + jobs, err := env.app.FindAllRecords(migrations.JobsCollection) require.NoError(t, err) require.Len(t, jobs, 1) @@ -459,7 +460,7 @@ func TestProviderSecretRotationReachesStorage(t *testing.T) { func TestRecordFileIsProtected(t *testing.T) { app := newTestStorage(t) - files, err := app.FindCollectionByNameOrId(pbrepo.FilesCollection) + files, err := app.FindCollectionByNameOrId(migrations.FilesCollection) require.NoError(t, err) field, ok := files.Fields.GetByName("file").(*core.FileField) @@ -478,14 +479,14 @@ func TestRecordFileNeedsSession(t *testing.T) { env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content"))) require.Equal(t, http.StatusCreated, created.Code) - files, err := env.app.FindAllRecords(pbrepo.FilesCollection) + files, err := env.app.FindAllRecords(migrations.FilesCollection) require.NoError(t, err) require.Len(t, files, 1) names := files[0].GetStringSlice("file") require.Len(t, names, 1) - link := "/api/files/" + pbrepo.FilesCollection + "/" + files[0].Id + "/" + names[0] + link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0] anonymous := httptest.NewRecorder() env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil)) diff --git a/internal/controller/http/login_test.go b/internal/controller/http/login_test.go index 1128659..073ad37 100644 --- a/internal/controller/http/login_test.go +++ b/internal/controller/http/login_test.go @@ -15,6 +15,7 @@ import ( "github.com/stretchr/testify/require" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" + "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" ) // Проверки этого файла проходят вход целиком — от увода к провайдеру до куки @@ -315,13 +316,13 @@ func TestRecordFileNeedsSessionAndToken(t *testing.T) { env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content"))) require.Equal(t, http.StatusCreated, created.Code) - files, err := env.app.FindAllRecords(pbrepo.FilesCollection) + files, err := env.app.FindAllRecords(migrations.FilesCollection) require.NoError(t, err) require.Len(t, files, 1) names := files[0].GetStringSlice("file") require.Len(t, names, 1) - link := "/api/files/" + pbrepo.FilesCollection + "/" + files[0].Id + "/" + names[0] + link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0] // Аноним не проходит. anonymous := httptest.NewRecorder() diff --git a/internal/controller/http/transcribe_test.go b/internal/controller/http/transcribe_test.go index a058506..45c8f8b 100644 --- a/internal/controller/http/transcribe_test.go +++ b/internal/controller/http/transcribe_test.go @@ -22,6 +22,7 @@ import ( "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" + "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/service" @@ -232,7 +233,7 @@ func createMultipartRequestWithField(t *testing.T, field, fileName string, conte // storedFileNames отдаёт имена, под которыми файлы легли в хранилище. func storedFileNames(t *testing.T, env *testEnv) []string { - records, err := env.app.FindAllRecords(pbrepo.FilesCollection) + records, err := env.app.FindAllRecords(migrations.FilesCollection) require.NoError(t, err) var names []string @@ -244,14 +245,14 @@ func storedFileNames(t *testing.T, env *testEnv) []string { // countFiles считает записи о файлах. func countFiles(t *testing.T, env *testEnv) int { - records, err := env.app.FindAllRecords(pbrepo.FilesCollection) + records, err := env.app.FindAllRecords(migrations.FilesCollection) require.NoError(t, err) return len(records) } // countJobs считает заведённые задачи расшифровки. func countJobs(t *testing.T, env *testEnv) int { - records, err := env.app.FindAllRecords(pbrepo.JobsCollection) + records, err := env.app.FindAllRecords(migrations.JobsCollection) require.NoError(t, err) return len(records) } diff --git a/internal/service/pipeline_test.go b/internal/service/pipeline_test.go index 9f4bf21..4a86493 100644 --- a/internal/service/pipeline_test.go +++ b/internal/service/pipeline_test.go @@ -17,6 +17,7 @@ import ( "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" + "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) @@ -110,7 +111,7 @@ func newTelegramJob(t *testing.T, env *pipelineEnv) *entity.TranscribeJob { func clearDelay(t *testing.T, env *pipelineEnv, jobID string) { t.Helper() - record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID) + record, err := env.app.FindRecordById(migrations.JobsCollection, jobID) require.NoError(t, err) record.Set("delay_time", "") require.NoError(t, env.app.Save(record)) @@ -121,7 +122,7 @@ func clearDelay(t *testing.T, env *pipelineEnv, jobID string) { func rotAcquisition(t *testing.T, env *pipelineEnv, jobID string) { t.Helper() - record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID) + record, err := env.app.FindRecordById(migrations.JobsCollection, jobID) require.NoError(t, err) record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour)) require.NoError(t, env.app.Save(record)) @@ -179,7 +180,7 @@ func TestDeadJobReturnsAfterStateEdit(t *testing.T) { } require.Error(t, env.service.FindAndRunConversionJob()) - record, err := env.app.FindRecordById(pbrepo.JobsCollection, job.Id) + record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id) require.NoError(t, err) record.Set("state", entity.StateCreated) require.NoError(t, env.app.Save(record)) @@ -202,7 +203,7 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) { empty, err := env.fileRepo.CreateRemote("object-key", 1) require.NoError(t, err) - record, err := env.app.FindRecordById(pbrepo.JobsCollection, job.Id) + record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id) require.NoError(t, err) record.Set("file", empty.Id) require.NoError(t, env.app.Save(record)) diff --git a/internal/service/recognition_test.go b/internal/service/recognition_test.go index a1e5c1e..8cd4008 100644 --- a/internal/service/recognition_test.go +++ b/internal/service/recognition_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" + "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/entity" ) @@ -249,7 +249,7 @@ func TestCheckJobWritesNothingWhenAcquisitionLost(t *testing.T) { acquired, err := env.jobRepo.FindAndAcquire(entity.StateTranscribe, "mine", time.Now().Add(-time.Hour)) require.NoError(t, err) - record, err := env.app.FindRecordById(pocketbase.JobsCollection, job.Id) + record, err := env.app.FindRecordById(migrations.JobsCollection, job.Id) require.NoError(t, err) record.Set("acquisition_id", "someone-else") require.NoError(t, env.app.Save(record))