записано решение уйти с 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
@@ -3,6 +3,7 @@
- **Дата:** 2026-08-11
- **Источник:** [../research/pocketbase.md](../research/pocketbase.md) — записка
разведки `pocketbase-admin-fit`
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
## Решение
@@ -3,6 +3,7 @@
- **Дата:** 2026-08-11
- **Источник:** [../research/job-queue.md](../research/job-queue.md) — записка
разведки `job-queue-choice`
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
## Решение
@@ -5,6 +5,7 @@
раздел «Что изменило ревью кода», плюс отчёт триажа
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт 3
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
## Решение
@@ -0,0 +1,79 @@
# Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком
- **Дата:** 2026-08-22
- **Источник:** [../research/storage-without-pocketbase.md](../research/storage-without-pocketbase.md) —
записка разведки о выборе хранилища
## Решение
PocketBase уходит из проекта целиком: состояние записей и метаданные переезжают
в SQLite, с которым сервис работает напрямую через `modernc.org/sqlite`, файлы
записей — в свой каталог со своей раскладкой, маршруты и слои — на `net/http`,
шаги схемы — на свой раннер. Панель администратора теряется и **не заменяется
ничем**: пока идёт стройка, остановленную запись возвращает в работу запрос к
базе.
Запись заменяет три:
[ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md),
[ADR-2026-08-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md) и
[ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md).
**Что из заменённых решений подтверждается, а не отменяется.** Очередь остаётся
своей таблицей, захват — одним запросом с `RETURNING`, готовую библиотеку
очереди по-прежнему не берём: замер, которым это решено, снят на
`modernc.org/sqlite` — том самом драйвере, который остаётся и после ухода.
Отменяется у той записи одно слово: таблица перестаёт быть коллекцией.
Приложение остаётся в своём корне `/app/`
([ADR-2026-08-15-app-namespace](ADR-2026-08-15-app-namespace.md)). Файл записи
остаётся закрытым — но проверкой владельца в своём обработчике, а не защищённым
полем коллекции и коротким токеном.
## Почему
Разведка мерила не «хранилище против хранилища», а то, что библиотека держит в
этом коде. Цитата из источника:
> Разрез «хранилище против хранилища» вопроса не покрывает: библиотека держит
> шесть ролей сразу, и только две из них про хранение.
Довод, на котором стоял перевод, отпал сам. Источник, раздел о трёх доводах:
> Вход делает само приложение с 2026-08-22 […]: пришедшего называет заголовок
> прокси, а учётную запись заводит наш `EnsureUser`. Пользователи в коллекции
> есть **потому, что их пишет наш код**, а не провайдер библиотеки. Довод,
> которым отвергнут отвергнутый вариант, перестал быть верным.
Отвергнут вариант «уйти в два шага», оставив панель жить в промежутке, и
отвергнут решением владельца: панель на стройке заменяется запросом к базе, а
вторая порция работы стоит дороже, чем то, что она сберегает.
Обстоятельство, которое назначило момент:
> на сервере данных нет и сервис остановлен, поэтому смена стоит только кода.
> Дешевле она не станет никогда — каталог `internal/controller/http` прирастает
> кодом на чужих типах с каждой задачей.
## Последствия
- `+` периметр сервиса становится только нашим. Панель `/_/` исчезает вместе с
дефектом `/%5f/` из [../security.md](../security.md), а пространство
хранилища `/api/` — вместе с необходимостью держать его открытым ради файлов.
- `+` пропадает секрет, которого не было до перевода, — пароль суперпользователя
панели.
- `+` файлы ложатся своей раскладкой, и загрузка частями, узнавание по хеш-сумме,
удаление записи и вторая копия рядом становятся обычной работой с файлами.
- `+` из сборки уходят шесть модулей, достижимых только через библиотеку:
`imaging`, `mailyak`, `jwt`, `fexpr`, `cobra`, драйвер MySQL.
`modernc.org/sqlite` остаётся, и сборка по-прежнему обходится без CGO.
- `` владелец сервиса остаётся без панели. Правку записи, возврат остановленной
в работу и просмотр очереди до появления экранов делает запрос к базе руками.
Задачи `audiorecord-actions` и `play-recording-in-app` этим становятся не
улучшением, а заменой утраченного инструмента.
- `` шаги схемы, отдачу файла, ограничитель частоты и настройку базы пишем и
сопровождаем сами. Единственный писатель у `modernc.org/sqlite` — наша забота
с этого дня.
- `` раскладка каталога данных меняется необратимо. Цена сегодня нулевая:
стройка, на сервере пусто; после первой боевой записи она перестаёт быть
нулевой.
- `` 1497 строк контроллера и 3075 строк его проверок написаны на
`*core.RequestEvent` и переписываются целиком.
+4 -3
View File
@@ -35,6 +35,7 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-22 | [Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком](ADR-2026-08-22-storage-without-pocketbase.md) | |
| 2026-08-22 | [Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC](ADR-2026-08-22-login-by-trusted-header.md) | |
| 2026-08-15 | [Node зовётся контейнером, а не ставится на машину разработчика](ADR-2026-08-15-node-in-container-not-on-machine.md) | |
| 2026-08-15 | [Приложение живёт своим пространством адресов, а не общим с хранилищем](ADR-2026-08-15-app-namespace.md) | |
@@ -49,7 +50,7 @@
| 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.md) | |
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
@@ -61,8 +62,8 @@
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
| 2026-08-11 | [Приложение пишем на Vue, а Node входит в гейт и в образ](ADR-2026-08-11-spa-on-vue.md) | |
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | |
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | |
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
| 2026-08-11 | [Проверки не зовут внешних программ](ADR-2026-08-11-stub-adapters-in-tests.md) | |
Решения, принятые до заведения канона 2026-08-10, источника в архиве изменений
+1
View File
@@ -22,6 +22,7 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
| Дата | Запись | О чём |
| --- | --- | --- |
| 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё |
| 2026-08-15 | [Раздача приложения: что делают за нас библиотека и сборщик](webapp-serving.md) | Раскодированный путь у маршрутизатора, второй журнал у PocketBase, нулевое время у вшитого файла, зависание установщика без сети |
| 2026-08-13 | [Разбор TOML: какое семейство отказов несёт значения из файла](toml-decode-errors.md) | Значения только в `ParseError.Message`, врущее поле `Line`, отказ значением в BurntSushi/toml v1.5.0 |
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
+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` прирастает кодом на чужих типах с каждой задачей.
+1
View File
@@ -43,6 +43,7 @@
## Очередь
- [✨ Уйти с PocketBase на SQLite со своим каталогом файлов](items/storage-without-pocketbase.md) — Библиотека держит шесть ролей и вышла за адаптер: её типы стоят во всех пяти файлах контроллера и во всех шести его проверках, панель опубликована в интернет вместе с необойдённым /%5f/, а пространство /api/ нельзя закрыть на прокси, потому что за файлами туда ходит браузер пользователя.
- [🧹 Поднимать сервис для локальной работы одной командой](items/dev-run-task.md) — Локальная проверка требует ручной чистки каталога данных и остановки процесса, а владельца панели заводят по одноразовой ссылке из журнала.
- [✨ Сделать экран загрузки записи и её состояния](items/upload-and-status-screen.md) — Первое, ради чего приложение открывают: отдать файл и увидеть, что с ним происходит.
- [✨ Сделать экран списка своих записей и чтения текста](items/records-list-screen.md) — Расшифровка сегодня доходит одним сообщением и теряется в переписке; вернуться к ней через неделю нечем.
+47
View File
@@ -0,0 +1,47 @@
# ✨ Уйти с PocketBase на SQLite со своим каталогом файлов
- **Тип:** feature
- **Категория:** Очередь — Всё остальное строится поверх хранилища: экраны пишутся на типах контроллера, а dev-run-task заводит владельца панели, которой не станет
- **Зачем:** Библиотека держит шесть ролей и вышла за адаптер: её типы стоят во всех пяти файлах контроллера и во всех шести его проверках, панель опубликована в интернет вместе с необойдённым /%5f/, а пространство /api/ нельзя закрыть на прокси, потому что за файлами туда ходит браузер пользователя.
Сервис работает с SQLite напрямую, файлы записей лежат в своём каталоге и
отдаются своим обработчиком, маршруты и слои живут на `net/http`, схему двигают
свои шаги. Панель администратора исчезает вместе с адресами `/_/` и `/api/`.
Решение — [ADR-2026-08-22-storage-without-pocketbase](../../docs/adr/ADR-2026-08-22-storage-without-pocketbase.md),
разведка — [storage-without-pocketbase](../../docs/research/storage-without-pocketbase.md).
## Затрагивает
- каталог `internal/adapter/repo/pocketbase` целиком и его шаги схемы;
- каталог `internal/controller/http` целиком вместе с проверками;
- подъём сервера и цепочку слоёв в `cmd/transcriber/main.go`;
- раскладку каталога данных: файл базы и каталог файлов записей — **необратимое**;
- публичный контракт HTTP: пространство `/api/` и адрес `/_/` исчезают, корень
`/app/` остаётся;
- отдачу файла записи браузеру: короткий токен файла и защищённое поле коллекции;
- панель администратора как инструмент владельца сервиса;
- ключ конфига `[storage] data_dir` и перечень зависимостей `go.mod`;
- модель угроз: адрес `/_/`, дефект `/%5f/` и правило прокси на пространство
хранилища.
## Критерии приёмки
- Библиотеки нет в сборке. **Оракул:** `go list -deps ./... | grep -c pocketbase`
даёт `0`, а `go mod tidy` не возвращает её модулей в `go.mod`.
- Чужой файл недостижим по прямой ссылке. **Оракул:** тест контроллера — запрос
к файлу чужой записи и к несуществующей отвечает одинаково.
- Захват записи остаётся неделимым. **Оракул:** тест с параллельными воркерами
под `-race` — запись достаётся ровно одному, второй получает отказ по значению
признака захвата.
- Пространства хранилища не существует. **Оракул:** тест маршрутов — `/api/…`,
`/_/` и `/%5f/` отвечают тем же, чем всякий неизвестный путь.
- Схема накатывается на пустом каталоге до старта воркеров. **Оракул:** запуск на
чистом каталоге данных — ни одного отказа в журнале до первой строки о готовности.
## Рамки
Данные не переносятся: стройка, на сервере пусто. Панель не заменяется ничем —
остановленную запись возвращает в работу запрос к базе, пока этого не сделают
экраны владельца. Очередь остаётся своей таблицей с захватом одним запросом
`RETURNING`. Боевой каталог данных не трогать.