приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
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
|
||||
}
|
||||
@@ -50,6 +50,7 @@ func init() {
|
||||
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")
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"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/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// defaultListLimit — умолчание, к которому приводится непозитивный предел.
|
||||
// Значение своё, а не занятое у транспорта: адаптер о транспорте не знает.
|
||||
const defaultListLimit = 30
|
||||
|
||||
// List отдаёт страницу записей владельца, новыми сверху.
|
||||
//
|
||||
// **Страница берётся ключом, а не смещением.** Приём пишет в голову той же
|
||||
// ленты, которую читает список, и человек, загрузивший запись и листающий свой
|
||||
// архив, — штатный сценарий. Смещение сдвинуло бы окно на единицу: последний
|
||||
// элемент первой страницы пришёл бы вторым разом первым элементом второй, а один
|
||||
// элемент между ними не пришёл бы никогда — и оба раза молча.
|
||||
//
|
||||
// Ключ полный: пара «время заведения и идентификатор». Одного времени мало — у
|
||||
// записей, принятых одним запросом, оно совпадает, и порядок между ними иначе не
|
||||
// определён вовсе.
|
||||
//
|
||||
// Ни расшифровки, ни структуры реплик выборка не читает: обе лежат порознь от
|
||||
// записи ровно затем, чтобы список их не тянул. Длительность и размер берутся
|
||||
// колонками самой записи.
|
||||
func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.RecordPage, error) {
|
||||
// Пустой владелец не совпадает ни с одной записью. Правило записано со
|
||||
// стороны спрашивающего: обязательность, которую держит одна лишь схема,
|
||||
// пустую строку пропустила бы.
|
||||
if q.OwnerID == "" {
|
||||
return &contract.RecordPage{Items: []*entity.AudioRecord{}}, nil
|
||||
}
|
||||
|
||||
// Непозитивный предел приводится к умолчанию, а не роняет процесс: ниже
|
||||
// стоит обращение по индексу `q.Limit-1`, и нулевой предел дал бы индекс −1.
|
||||
// Сегодня отсекает его обработчик, но метод — часть интерфейса, и второй
|
||||
// вызывающий с забытым полем структуры получил бы панику, а восстановления у
|
||||
// воркеров нет вовсе.
|
||||
if q.Limit <= 0 {
|
||||
q.Limit = defaultListLimit
|
||||
}
|
||||
|
||||
collection, err := findCollection(repo.app, migrations.RecordsCollection)
|
||||
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 {
|
||||
conditions = append(conditions, state)
|
||||
}
|
||||
|
||||
total, err := repo.countRecords(conditions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Курсор режет ленту по паре: строго раньше по времени, а при равном времени
|
||||
// — строго меньше по идентификатору. Идентификаторы хранилища монотонны в
|
||||
// пределах одной миллисекунды не всегда, но сравнение по ним устойчиво, и
|
||||
// этого довольно: задача ключа — не пропустить и не повторить.
|
||||
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}),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
// Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли
|
||||
// следующая страница» без второго запроса и без вычислений по общему числу,
|
||||
// которое к этому моменту могло измениться.
|
||||
records := []*core.Record{}
|
||||
err = repo.app.RecordQuery(collection).
|
||||
AndWhere(dbx.And(conditions...)).
|
||||
OrderBy("created DESC", "id DESC").
|
||||
Limit(int64(q.Limit) + 1).
|
||||
All(&records)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list audio records: %w", err)
|
||||
}
|
||||
|
||||
page := &contract.RecordPage{TotalItems: total}
|
||||
if len(records) > q.Limit {
|
||||
last := records[q.Limit-1]
|
||||
page.NextCursor = &contract.RecordCursor{
|
||||
CreatedAt: last.GetDateTime("created").String(),
|
||||
ID: last.Id,
|
||||
}
|
||||
records = records[:q.Limit]
|
||||
}
|
||||
|
||||
page.Items = make([]*entity.AudioRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
page.Items = append(page.Items, recordToAudioRecord(record))
|
||||
}
|
||||
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// stateCondition переводит состояние отбора в условие запроса.
|
||||
//
|
||||
// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор
|
||||
// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе
|
||||
// молча поменял бы состав всех трёх состояний.
|
||||
func stateCondition(filter *entity.ListFilter) (dbx.Expression, error) {
|
||||
if filter == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
notHalted := dbx.NewExp("halted_at = ''")
|
||||
halted := dbx.NewExp("halted_at != ''")
|
||||
|
||||
switch *filter {
|
||||
case entity.ListFilterHalted:
|
||||
return halted, nil
|
||||
case entity.ListFilterWorking:
|
||||
return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.WorkingStages())...)), nil
|
||||
case entity.ListFilterDone:
|
||||
return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.TerminalStages())...)), nil
|
||||
}
|
||||
|
||||
// Ветвь отказа, а не молчаливое «без сужения»: значение, добавленное в
|
||||
// перечень состояний и забытое здесь, иначе вернуло бы человеку весь архив
|
||||
// под именем отбора — и заметить это было бы нечем.
|
||||
return nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter)
|
||||
}
|
||||
|
||||
func stageNameValues(stages []entity.Stage) []any {
|
||||
names := entity.StageNames(stages)
|
||||
out := make([]any, 0, len(names))
|
||||
for _, n := range names {
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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 {
|
||||
return 0, fmt.Errorf("failed to count audio records: %w", err)
|
||||
}
|
||||
return counter.Total, 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))
|
||||
for _, id := range ids {
|
||||
values = append(values, id)
|
||||
}
|
||||
|
||||
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека —
|
||||
// пара «владелец и название» уникальна, — и разрешение без сужения отдало бы
|
||||
// название чужой темы, как только темы начнёт писать языковая модель.
|
||||
records := []*core.Record{}
|
||||
err := repo.app.RecordQuery(migrations.TopicsCollection).
|
||||
AndWhere(dbx.HashExp{"owner": ownerID}).
|
||||
AndWhere(dbx.In("id", values...)).
|
||||
All(&records)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve topics: %w", err)
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
out[record.Id] = record.GetString("name")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -54,6 +54,16 @@ func applyToRecord(record *core.Record, r *entity.AudioRecord) {
|
||||
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 {
|
||||
@@ -78,8 +88,15 @@ func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
|
||||
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
|
||||
StructureID: nilIfEmpty(record.GetString("structure")),
|
||||
RecognitionID: nilIfEmpty(record.GetString("recognition")),
|
||||
CreatedAt: record.GetDateTime("created").Time(),
|
||||
UpdatedAt: record.GetDateTime("updated").Time(),
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +120,25 @@ func dateOrEmpty(v *time.Time) any {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user