записано решение уйти с PocketBase на SQLite со своим каталогом файлов

- разведка storage-without-pocketbase: шесть ролей библиотеки в этом коде, отпавший довод перевода, шесть модулей достижимы только через неё
- ADR-2026-08-22-storage-without-pocketbase заменяет три записи: перевод в PocketBase, очередь коллекцией, файл за защищённым полем
- задача storage-without-pocketbase встала первой строкой плана стройки
This commit is contained in:
av
2026-08-22 21:00:14 +03:00
parent a8fb4793be
commit ad5b5e377f
9 changed files with 262 additions and 3 deletions
+127
View File
@@ -0,0 +1,127 @@
# Хранилище: PocketBase против голого SQLite с каталогом файлов
Отвечает на вопрос владельца от 2026-08-22: не окажется ли голый SQLite с
каталогом аудиозаписей гибче встроенной PocketBase. Записи в каталоге задач у
вопроса нет — он поднят по ходу работы, и разведка идёт от него, а не от
постановки.
Мерилось **дерево репозитория**, а не внешний сервис: вопрос о том, что
библиотека уже держит в этом коде и чем за это плачено. Замеров на живом потоке
нет.
## Как снималось
Дата — 2026-08-22, коммит `a8fb479`, дерево чисто. Всё считано в самом
репозитории, боевые данные не участвовали.
- **Пакеты в сборке:** `go list -deps ./... | wc -l`**584**; из них с
`pocketbase` в пути — `go list -deps ./... | grep -c pocketbase`**40**.
- **Кто тянет тяжёлый модуль:** `go mod why -m <модуль>` по семи модулям.
- **Объём кода:** `find <каталог> -name '*.go' [! -name '*_test.go'] | xargs wc -l`.
- **Протечка библиотеки за адаптер:** `grep -l "pocketbase/" internal/controller/http/*.go`.
- **Узлы для варианта с монтированием** — чтением кэша модулей:
`router.Router.BuildMux()` отдаёт `http.Handler`
(`tools/router/router.go:61`), а `apis.Serve` присваивает `e.Server.Handler`
(`apis/serve.go:223`). Прототипа на этих двух узлах не собирал — проверено
только их существование.
## PocketBase здесь — фреймворк приложения, а не хранилище
Разрез «хранилище против хранилища» вопроса не покрывает: библиотека держит
шесть ролей сразу, и только две из них про хранение.
| Роль | Где | Чем заменяется |
| --- | --- | --- |
| SQLite без CGO, пул записи одним соединением | `pbrepo.New` | `modernc.org/sqlite` напрямую, свои WAL, `busy_timeout` и единственный писатель |
| Схема и шаги миграций | `internal/adapter/repo/pocketbase/migrations`, 1039 строк | свой раннер либо `goose`, который тут уже был |
| Файлы записей на диске и отдача `/api/files/…` по токену | `file_repo.go`, `FileTokenPath` | каталог и свой обработчик отдачи |
| Маршрутизатор, цепочка слоёв с приоритетами, ограничитель частоты | `internal/controller/http` целиком | `net/http` и счётчик по ключу |
| Учётная запись значением `e.Auth` | `identity.go` | строка своей таблицы |
| Панель `/_/` | покупалась ради неё | ничем |
**Библиотека вышла за адаптер.** Из пяти не-тестовых файлов
`internal/controller/http` её импортируют **все пять**, из шести тестовых —
**все шесть**: 1497 строк кода контроллера и 3075 строк его проверок написаны
на `*core.RequestEvent`. Адаптер хранилища — ещё 1787 строк без шагов схемы.
## Три довода прежнего решения: один умер 2026-08-22
[ADR-2026-08-11-pocketbase-storage-with-admin-panel](../adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
покупал библиотеку за три вещи разом, и отвергал половинчатые пути тем, что
порознь ни одна перевода не оправдывает.
1. **Правка записей в панели** — работает. Единственная роль, которой сегодня
нет замены.
2. **Файлы видны в панели, и копия накрывает их вместе с базой** — работает; тот
же ADR оговаривает, что копии сервер делает своими средствами.
3. **Вход через её провайдер OIDC****отпал**. Цитата, которой ADR отверг
вариант «взять библиотеку только хранилищем»:
> Панель показывает свою коллекцию пользователей и ничего больше. Отсюда
> следствие для целевого входа: **пользователи Authelia в панели не появятся,
> если вход делает само приложение**.
Вход делает само приложение с 2026-08-22
([ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md)):
пришедшего называет заголовок прокси, а учётную запись заводит наш
`EnsureUser`. Пользователи в коллекции есть **потому, что их пишет наш код**,
а не провайдер библиотеки. Довод, которым отвергнут отвергнутый вариант,
перестал быть верным — и вместе с ним отпало основание держать вход в
библиотеке.
## Что оплачено и не работает
- **Захват записи идёт сырым запросом мимо записей коллекции**
(`record_repo.go`, `FindAndAcquire`): `app.DB().NewQuery` с `UPDATE … RETURNING`.
На самом горячем месте абстракция не помогает, а её ограничения действуют —
хуки коллекции не срабатывают, время изменения проставляет наш запрос, времена
сравниваются строками побайтово.
- **Налог соседства двух периметров в одном процессе и на одном порту.**
Появился секрет, которого не было, — пароль суперпользователя. Панель
опубликована в интернет, и её барьер обходится подменой знака: `/%5f/`
попадает в ту же группу, что `/_/`, а правило прокси написано на литерал
([../security.md](../security.md)); дефект не закрыт. Узнавание нельзя
повесить на всю поверхность хранилища, иначе узнанный перепишет себе
`provider_login` и заберёт чужой архив — область слоя сужена, и причина стоит
абзацем в `identity.go`.
- **Пространство `/api/` нельзя закрыть на прокси**, потому что за файлами
ходит туда браузер пользователя. Своя отдача файла снимает это ограничение:
закрыть можно всё пространство хранилища разом.
## Чем платит уход
- **Панель теряется целиком.** Правка записи, возврат остановленной в работу,
просмотр очереди фильтром, прослушивание файла — всё это сегодня живёт только
там.
- **Пишем сами** шаги схемы, отдачу файла, ограничитель частоты и настройку
базы. Последняя — не формальность: у `modernc.org/sqlite` запись идёт
единственным соединением, и библиотека держит это за нас двумя пулами.
- **Раскладка каталога данных** названа необратимой в `../../CLAUDE.md`.
На стройке цена нулевая: на сервере пусто, переносить нечего.
## Что уходит из сборки, а что остаётся
`go mod why -m` показал, что шесть модулей достижимы **только** через
PocketBase: `disintegration/imaging` (через `tools/filesystem`),
`domodwyer/mailyak/v3` и `golang-jwt/jwt/v5` (через `core`),
`ganigeorgiev/fexpr` (через `apis`), `spf13/cobra` (набор команд),
`go-sql-driver/mysql` (через `pocketbase/dbx`). `modernc.org/sqlite` остаётся:
он нужен и без библиотеки, и требование обходиться без CGO с ним сохраняется.
## Варианты и что выбрано
| Вариант | Что делает | Чем платит |
| --- | --- | --- |
| Оставить как есть | ноль работы | долг растёт с каждой задачей на `RequestEvent` |
| **Уйти целиком** | SQLite напрямую, каталог файлов, свои шаги схемы и маршруты | панель теряется сразу, работа одной порцией |
| Уйти в два шага | сначала снять с периметра HTTP, панель оставить; потом с хранилища | панель жива в промежутке, работа та же, но двумя порциями |
| Локализовать протечку | библиотека не выходит за `internal/adapter/repo` | решение откладывается, панель остаётся |
**Решением владельца от 2026-08-22 взят уход целиком, и панель не заменяется
ничем**: пока стройка не кончилась, остановленную запись возвращают в работу
запросом к базе. Решение записано в
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
Обстоятельство, которое решило дело: на сервере данных нет и сервис остановлен,
поэтому смена стоит только кода. Дешевле она не станет никогда — каталог
`internal/controller/http` прирастает кодом на чужих типах с каждой задачей.