- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
94 lines
5.7 KiB
Go
94 lines
5.7 KiB
Go
package pocketbase
|
||
|
||
import (
|
||
"fmt"
|
||
|
||
"github.com/pocketbase/dbx"
|
||
"github.com/pocketbase/pocketbase/core"
|
||
"github.com/pocketbase/pocketbase/tools/router"
|
||
|
||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||
)
|
||
|
||
// GuardOwnerDeletion отвергает удаление учётной записи, у которой остались
|
||
// аудиозаписи, их файлы либо темы её словаря.
|
||
//
|
||
// Колонка владельца — связь с выключенным каскадным удалением, и одного этого
|
||
// мало: при выключенном каскаде хранилище не удаляет ссылающуюся запись, а
|
||
// **вынимает** идентификатор из поля связи и сохраняет её без проверок. Задачи
|
||
// остались бы на месте, но стали бы ничьими, а ничья задача не достаётся по API
|
||
// никому — архив человека исчез бы молча и восстановлению не подлежал:
|
||
// прежнего владельца не остаётся нигде.
|
||
//
|
||
// Цена запрета названа прямо: владелец панели упирается в отказ, а способа
|
||
// удалить записи в сервисе пока нет вовсе — его приносит отдельная задача. До
|
||
// неё удаление учётной записи с записями невозможно, и это осознанный тупик.
|
||
//
|
||
// Слой стоит на удалении записи, а не на запросе к панели: панель ходит правами
|
||
// суперпользователя, и правило коллекции её не судит. Удаление при этом не
|
||
// только панельное — умолчание библиотеки разрешает вошедшему удалить свою
|
||
// учётную запись запросом, так что страж закрывает и публичную поверхность.
|
||
//
|
||
// Считаются **все** коллекции с владельцем, и перечень их живёт одним списком
|
||
// ниже. Файл переживает свою запись: шаг конвейера заводит его до сохранения, и
|
||
// потерянный захват оставляет файл с владельцем и без ссылки. Учётная запись, у
|
||
// которой остались одни такие файлы, без этого счёта удалялась бы штатно, а
|
||
// аудио становилось бы ничьим.
|
||
func GuardOwnerDeletion(app core.App) {
|
||
app.OnRecordDelete(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
|
||
count, err := countOwned(e.App, e.Record.Id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if count > 0 {
|
||
// Отказ отдаётся ошибкой роутера, а не обычной: библиотека пропускает
|
||
// наружу только `*router.ApiError`, а всякую другую подменяет своим
|
||
// сообщением — «убедитесь, что запись не участвует в обязательной
|
||
// связи». Подсказка эта не просто бесполезная, а **ведущая**:
|
||
// единственная обязательная связь у задачи — файл, и владелец панели,
|
||
// поверив ей, пойдёт удалять задачи и файлы руками. То есть сделает
|
||
// ровно то необратимое, ради предотвращения чего страж и заведён.
|
||
//
|
||
// Число в отказе — не содержимое записей, а их счёт: он говорит
|
||
// владельцу панели, почему удаление не прошло, и не выносит наружу
|
||
// ничего о самих записях.
|
||
return router.NewBadRequestError(fmt.Sprintf(
|
||
"у учётной записи остались записи (%d): сервис — архив, и удаление сделало бы их ничьими",
|
||
count,
|
||
), nil)
|
||
}
|
||
|
||
return e.Next()
|
||
})
|
||
}
|
||
|
||
// ownedCollections — коллекции с колонкой владельца. Перечень живёт здесь одним
|
||
// списком, и разойтись с шагом схемы ему нельзя: пропущенная коллекция
|
||
// пропускает удаление вперёд, а наружу приезжает не наш отказ с причиной, а
|
||
// подсказка библиотеки про обязательную связь — та самая, по которой владелец
|
||
// панели пойдёт удалять записи руками.
|
||
//
|
||
// Так уже случилось однажды: `topics` завелась третьей и в списке не появилась.
|
||
var ownedCollections = []string{
|
||
migrations.RecordsCollection,
|
||
migrations.FilesCollection,
|
||
migrations.TopicsCollection,
|
||
}
|
||
|
||
// countOwned считает всё, что принадлежит учётной записи, — по всем коллекциям
|
||
// с колонкой владельца.
|
||
func countOwned(app core.App, ownerID string) (int64, error) {
|
||
var total int64
|
||
|
||
for _, collection := range ownedCollections {
|
||
count, err := app.CountRecords(collection, dbx.HashExp{"owner": ownerID})
|
||
if err != nil {
|
||
return 0, fmt.Errorf("failed to count owned records in %s: %w", collection, err)
|
||
}
|
||
total += count
|
||
}
|
||
|
||
return total, nil
|
||
}
|