Шаги схемы вынесены в свой каталог, и сверка их снова видит

- шаги PocketBase переехали из файла в пакет
  internal/adapter/repo/pocketbase/migrations, файл на шаг с именем
  зарегистрированного шага; туда же имена коллекций, срок сессии — в provider.go
- ключ migrations в docs/.docs.json наведён на этот каталог: прежнее значение
  указывало на несуществующий migrations/, и шаг гейта проходил зелёным при
  всякой правке схемы
- app.go подключает пакет шагов явным пустым импортом: пропавшая ссылка на
  константы унесла бы регистрацию, и хранилище поднялось бы без коллекций
This commit is contained in:
av
2026-08-12 22:03:48 +03:00
parent b46be019fc
commit 4a052ec99b
21 changed files with 235 additions and 183 deletions
+3 -3
View File
@@ -114,9 +114,9 @@ transcriber/
## Разработка ## Разработка
Схему двигают шаги миграций PocketBase на Go — Схему двигают шаги миграций PocketBase на Go —
`internal/adapter/repo/pocketbase`. Непринятые шаги накатываются при подъёме `internal/adapter/repo/pocketbase/migrations`, файл на шаг. Непринятые шаги
хранилища, прежде чем стартуют воркеры и сервер. Применённый шаг не накатываются при подъёме хранилища, прежде чем стартуют воркеры и сервер.
переписывается: изменение — только новым файлом шага. Применённый шаг не переписывается: изменение — только новым файлом шага.
Проверки перед коммитом — одной командой: Проверки перед коммитом — одной командой:
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"canon": 14, "canon": 14,
"migrations": "migrations" "migrations": "internal/adapter/repo/pocketbase/migrations"
} }
+2 -1
View File
@@ -74,7 +74,8 @@
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit | | Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам | | Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам |
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом | | Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом |
| Панель владельца | там же, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода | | Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода |
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано --> <!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано -->
+1 -1
View File
@@ -59,7 +59,7 @@ htmx, а здесь решено делать SPA — и перенесённы
| Форматирование исходников | `.golangci.yml``gofmt` | | Форматирование исходников | `.golangci.yml``gofmt` |
| Подозрительные конструкции языка | `.golangci.yml``govet`, `staticcheck`, `ineffassign`, `unused` | | Подозрительные конструкции языка | `.golangci.yml``govet`, `staticcheck`, `ineffassign`, `unused` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` | | Секреты в коммите | `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`) | | Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) |
Не названное здесь место механизации означает, что проход по конвенциям будет Не названное здесь место механизации означает, что проход по конвенциям будет
+7 -4
View File
@@ -8,8 +8,10 @@
и разбора нет. Правила действуют на новый код; переписывание существующего — и разбора нет. Правила действуют на новый код; переписывание существующего —
отдельная работа, и до неё расхождение читается как долг, а не как нарушение. отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
**Механизировано:** ничего. Ни правила линтера, ни теста-сканера под эти пункты **Механизировано:** одно — сверка изменённого шага схемы с
в transcriber нет. [../database.md](../database.md), шаг гейта `docs.py check`
([README.md](README.md), «Механизировано»). Под прочие пункты ни правила
линтера, ни теста-сканера в transcriber нет.
## Первичные ключи — ULID, не автоинкремент ## Первичные ключи — ULID, не автоинкремент
@@ -55,8 +57,9 @@
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`). лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени. вставка падает громко. Измерение длительности — не метка времени.
- Миграции — шаги PocketBase на Go (`internal/adapter/repo/pocketbase`): - Миграции — шаги PocketBase на Go
коллекции и их поля заводятся кодом. При изменении структуры обновляем схему (`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
поля заводятся кодом. При изменении структуры обновляем схему
[../database.md](../database.md) тем же изменением. [../database.md](../database.md) тем же изменением.
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким - Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
+11 -5
View File
@@ -8,11 +8,17 @@
CGO сборке не нужен. CGO сборке не нужен.
Схему двигают **шаги миграций PocketBase** на Go, каталог Схему двигают **шаги миграций PocketBase** на Go, каталог
`internal/adapter/repo/pocketbase`, файл шага — `migrations.go`. Шаг `internal/adapter/repo/pocketbase/migrations`, файл на шаг и имя файла — имя
регистрируется при загрузке пакета, а накатывается при подъёме хранилища шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме
(`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не хранилища (`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый
переписывается — изменение только новым шагом: применённое хранилище считает по шаг не переписывается — изменение только новым шагом: применённое хранилище
имени файла. считает по имени шага.
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
пути** (`docs/.docs.json`, ключ `migrations`), а префикс наводится только на
каталог. Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
репозитория берёт их оттуда.
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита. **Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
+2 -1
View File
@@ -38,7 +38,8 @@
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в - вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
успех молча. успех молча.
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`): **Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
подпакетом `migrations`):
- список колонок совпадает во всех четырёх местах — `applyToRecord`, - список колонок совпадает во всех четырёх местах — `applyToRecord`,
`recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант `recordToJob`, `acquireColumns`, `acquiredRow` — и в шаге схемы (инвариант
+8 -6
View File
@@ -10,13 +10,15 @@ import (
pb "github.com/pocketbase/pocketbase" pb "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
)
// Имена коллекций. Они же — часть пути к файлу в раскладке хранилища и часть // Шаги схемы регистрируются загрузкой своего пакета, а накатывает их
// адреса ссылки на него, поэтому меняются только новым шагом схемы. // `RunAllMigrations` ниже. Импорт здесь пустой и явный, хотя соседние файлы
const ( // пакета и так берут оттуда имена коллекций: день, когда имена перестанут
FilesCollection = "files" // читаться отсюда, унёс бы вместе с последней ссылкой и регистрацию — список
JobsCollection = "transcribe_jobs" // шагов остался бы пустым, `RunAllMigrations` вернул бы `nil`, и приложение
// поднялось бы здоровым, но без коллекций. Отказ вылез бы не на старте, а на
// первом приёме записи.
_ "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
) )
// New создаёт приложение хранилища на заданном каталоге данных и приводит его в // New создаёт приложение хранилища на заданном каталоге данных и приводит его в
@@ -12,6 +12,8 @@ import (
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
) )
// workFile — рабочая копия файла на диске. Живёт во временном каталоге // 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) { 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 { if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err) 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) { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, err 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) { 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 { if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err) 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) { 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 { if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err) return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
} }
@@ -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
}
@@ -1,138 +1,12 @@
package pocketbase package migrations
import ( import (
"errors" "errors"
"fmt" "fmt"
"github.com/pocketbase/pocketbase/core" "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 — умолчание библиотеки, к которому возвращает откат. // defaultAuthTokenDuration — умолчание библиотеки, к которому возвращает откат.
const defaultAuthTokenDuration = 1209600 const defaultAuthTokenDuration = 1209600
@@ -248,5 +122,3 @@ func down202608120001(app core.App) error {
return nil return nil
} }
func ptr[T any](v T) *T { return &v }
@@ -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 }
+3 -1
View File
@@ -2,6 +2,8 @@ package pocketbase
import ( import (
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
) )
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что // BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
@@ -20,7 +22,7 @@ import (
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое // стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
// переход хранит намеренно — приходило бы владельцу нулём. // переход хранит намеренно — приходило бы владельцу нулём.
func BindPanelRules(app core.App) { 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() original := e.Record.Original()
if original == nil || original.GetString("state") == e.Record.GetString("state") { if original == nil || original.GetString("state") == e.Record.GetString("state") {
return e.Next() return e.Next()
@@ -10,6 +10,17 @@ import (
// как обобщённый OIDC и по нему же ищет настройку при обмене кода. // как обобщённый OIDC и по нему же ищет настройку при обмене кода.
const ProviderName = "oidc" const ProviderName = "oidc"
// SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано
// решением владельца от 2026-08-12; умолчание библиотеки в пять суток не
// применяется, потому что оно никем не выбрано.
//
// Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками
// провайдера, и потому живёт здесь, а не в каталоге шагов. Причина та же:
// применённый шаг не переписывается, и число, положенное туда, разошлось бы со
// сроком жизни куки при первой же правке — браузер получил бы новый срок, а
// хранилище продолжило выдавать прежний.
const SessionDuration = 7 * 24 * 60 * 60
// ProviderSettings — то, что приезжает из конфига и приводится к настройкам // ProviderSettings — то, что приезжает из конфига и приводится к настройкам
// коллекции. // коллекции.
type ProviderSettings struct { type ProviderSettings struct {
@@ -12,6 +12,8 @@ import (
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
) )
type TranscriptJobRepository struct { type TranscriptJobRepository struct {
@@ -23,7 +25,7 @@ func NewTranscriptJobRepository(app core.App) *TranscriptJobRepository {
} }
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error { func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
collection, err := findCollection(repo.app, JobsCollection) collection, err := findCollection(repo.app, migrations.JobsCollection)
if err != nil { if err != nil {
return err return err
} }
@@ -50,7 +52,7 @@ func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
// LostAcquisitionError и результата не пишет. // LostAcquisitionError и результата не пишет.
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error { func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error {
err := repo.app.RunInTransaction(func(txApp core.App) 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 { if err != nil {
return fmt.Errorf("failed to find transcribe job: %w", err) 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) { 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 { if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err) 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() now := types.NowDateTime()
query := repo.app.DB().NewQuery(` query := repo.app.DB().NewQuery(`
UPDATE {{` + JobsCollection + `}} UPDATE {{` + migrations.JobsCollection + `}}
SET acquisition_id = {:acquisition_id}, SET acquisition_id = {:acquisition_id},
acquire_time = {:now}, acquire_time = {:now},
attempts = attempts + 1, attempts = attempts + 1,
updated = {:now} updated = {:now}
WHERE id = ( WHERE id = (
SELECT id FROM {{` + JobsCollection + `}} SELECT id FROM {{` + migrations.JobsCollection + `}}
WHERE state = {:state} WHERE state = {:state}
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now}) AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting}) AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting})
@@ -16,6 +16,8 @@ import (
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
) )
// newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же // 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) require.NoError(t, err)
record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour)) record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour))
require.NoError(t, app.Save(record)) require.NoError(t, app.Save(record))
@@ -232,7 +234,7 @@ func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// Задача досталась другому, пока шаг работал. // Задача досталась другому, пока шаг работал.
record, err := app.FindRecordById(JobsCollection, mine.Id) record, err := app.FindRecordById(migrations.JobsCollection, mine.Id)
require.NoError(t, err) require.NoError(t, err)
record.Set("acquisition_id", "someone-else") record.Set("acquisition_id", "someone-else")
require.NoError(t, app.Save(record)) require.NoError(t, app.Save(record))
@@ -279,7 +281,7 @@ func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, acquired.AcquisitionID) require.NotNil(t, acquired.AcquisitionID)
record, err := app.FindRecordById(JobsCollection, job.Id) record, err := app.FindRecordById(migrations.JobsCollection, job.Id)
require.NoError(t, err) require.NoError(t, err)
record.Set("attempts", 5) record.Set("attempts", 5)
record.Set("state", entity.StateDead) record.Set("state", entity.StateDead)
@@ -360,7 +362,7 @@ func patchRecord(t *testing.T, app core.App, recordID, body string) {
req := httptest.NewRequest( req := httptest.NewRequest(
http.MethodPatch, http.MethodPatch,
"/api/collections/"+JobsCollection+"/records/"+recordID, "/api/collections/"+migrations.JobsCollection+"/records/"+recordID,
strings.NewReader(body), strings.NewReader(body),
) )
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
+7 -6
View File
@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" 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) require.NoError(t, err)
assert.Empty(t, files) assert.Empty(t, files)
jobs, err := env.app.FindAllRecords(pbrepo.JobsCollection) jobs, err := env.app.FindAllRecords(migrations.JobsCollection)
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, jobs) assert.Empty(t, jobs)
}) })
@@ -63,7 +64,7 @@ func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio"))) env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
require.Equal(t, http.StatusCreated, created.Code) 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.NoError(t, err)
require.Len(t, jobs, 1) require.Len(t, jobs, 1)
@@ -459,7 +460,7 @@ func TestProviderSecretRotationReachesStorage(t *testing.T) {
func TestRecordFileIsProtected(t *testing.T) { func TestRecordFileIsProtected(t *testing.T) {
app := newTestStorage(t) app := newTestStorage(t)
files, err := app.FindCollectionByNameOrId(pbrepo.FilesCollection) files, err := app.FindCollectionByNameOrId(migrations.FilesCollection)
require.NoError(t, err) require.NoError(t, err)
field, ok := files.Fields.GetByName("file").(*core.FileField) 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"))) env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
require.Equal(t, http.StatusCreated, created.Code) 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.NoError(t, err)
require.Len(t, files, 1) require.Len(t, files, 1)
names := files[0].GetStringSlice("file") names := files[0].GetStringSlice("file")
require.Len(t, names, 1) 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() anonymous := httptest.NewRecorder()
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil)) env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
+3 -2
View File
@@ -15,6 +15,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" 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"))) env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
require.Equal(t, http.StatusCreated, created.Code) 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.NoError(t, err)
require.Len(t, files, 1) require.Len(t, files, 1)
names := files[0].GetStringSlice("file") names := files[0].GetStringSlice("file")
require.Len(t, names, 1) 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() anonymous := httptest.NewRecorder()
+4 -3
View File
@@ -22,6 +22,7 @@ import (
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" 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/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
@@ -232,7 +233,7 @@ func createMultipartRequestWithField(t *testing.T, field, fileName string, conte
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище. // storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
func storedFileNames(t *testing.T, env *testEnv) []string { 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) require.NoError(t, err)
var names []string var names []string
@@ -244,14 +245,14 @@ func storedFileNames(t *testing.T, env *testEnv) []string {
// countFiles считает записи о файлах. // countFiles считает записи о файлах.
func countFiles(t *testing.T, env *testEnv) int { 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) require.NoError(t, err)
return len(records) return len(records)
} }
// countJobs считает заведённые задачи расшифровки. // countJobs считает заведённые задачи расшифровки.
func countJobs(t *testing.T, env *testEnv) int { 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) require.NoError(t, err)
return len(records) return len(records)
} }
+5 -4
View File
@@ -17,6 +17,7 @@ import (
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" 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/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "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) { func clearDelay(t *testing.T, env *pipelineEnv, jobID string) {
t.Helper() t.Helper()
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID) record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
require.NoError(t, err) require.NoError(t, err)
record.Set("delay_time", "") record.Set("delay_time", "")
require.NoError(t, env.app.Save(record)) 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) { func rotAcquisition(t *testing.T, env *pipelineEnv, jobID string) {
t.Helper() t.Helper()
record, err := env.app.FindRecordById(pbrepo.JobsCollection, jobID) record, err := env.app.FindRecordById(migrations.JobsCollection, jobID)
require.NoError(t, err) require.NoError(t, err)
record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour)) record.Set("acquire_time", types.NowDateTime().Add(-24*time.Hour))
require.NoError(t, env.app.Save(record)) require.NoError(t, env.app.Save(record))
@@ -179,7 +180,7 @@ func TestDeadJobReturnsAfterStateEdit(t *testing.T) {
} }
require.Error(t, env.service.FindAndRunConversionJob()) 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) require.NoError(t, err)
record.Set("state", entity.StateCreated) record.Set("state", entity.StateCreated)
require.NoError(t, env.app.Save(record)) require.NoError(t, env.app.Save(record))
@@ -202,7 +203,7 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
empty, err := env.fileRepo.CreateRemote("object-key", 1) empty, err := env.fileRepo.CreateRemote("object-key", 1)
require.NoError(t, err) 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) require.NoError(t, err)
record.Set("file", empty.Id) record.Set("file", empty.Id)
require.NoError(t, env.app.Save(record)) require.NoError(t, env.app.Save(record))
+2 -2
View File
@@ -10,7 +10,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "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)) acquired, err := env.jobRepo.FindAndAcquire(entity.StateTranscribe, "mine", time.Now().Add(-time.Hour))
require.NoError(t, err) 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) require.NoError(t, err)
record.Set("acquisition_id", "someone-else") record.Set("acquisition_id", "someone-else")
require.NoError(t, env.app.Save(record)) require.NoError(t, env.app.Save(record))