хранилище, файлы записей и очередь переведены на встроенную PocketBase

- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
This commit is contained in:
av
2026-08-12 08:31:59 +03:00
parent 09cedc4e61
commit 01cc31d45f
55 changed files with 5238 additions and 1235 deletions
@@ -0,0 +1,63 @@
# Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
## Решение
Поле файла в хранилище **не помечается защищённым**: ссылка
`/api/files/<коллекция>/<запись>/<имя>` работает без токена, и право пройти по
ней даёт знание самой ссылки. Взамен имя файла в хранилище **не пишется в журнал
ни в каком виде** — ни на успешном пути, ни в тексте отказа.
## Почему
Очевидный подход к файлам, отдаваемым в интернет, — закрыть их: у хранилища для
этого есть пометка «защищённое поле», и тогда файл отдаётся только по отдельному
файловому токену. Мы от неё отказываемся, и цитата из источника называет причину:
> Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право
> прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с
> `GET /api/status/:id`, а не ниже.
Отказ дешёв ровно до тех пор, пока ссылку неоткуда взять. Перевод хранилища это
условие сломал, и вот чем:
> Изъятие выписано под путь на диске: `data/files/<uuid>.ogg` читателю журнала
> бесполезен. После перевода имя файла в хранилище — это последняя часть ссылки
> `/api/files/...`, по которой запись скачивает кто угодно; строка журнала стала
> бы бессрочным ключом к чужому аудио.
Путь был построен ревью и прогнан: отказ чтения из хранилища нёс ключ файла
целиком, строка уходила в журнал, а анонимный запрос по собранному адресу
отвечал `200` с телом записи. Второй путь шёл через отказ выгрузки в Object
Storage — тот несёт полный URL объекта.
Отсюда вторая половина решения, без которой первая недопустима: **отказы
обрываются**. Наружу идёт свой текст с идентификатором записи, а чужая цепочка
`%w` — нет. В журнал приёма вместо имени идёт расширение собственным полем;
прослеживаемость от этого не страдает.
Запись попадает в журнал как **намеренный отказ от очевидного подхода**: закрыть
файлы токеном предложат снова, и без записанной причины предложение выглядит
бесплатным.
## Последствия
- `+` ссылка работает без токена, и приёмка проверяется обычным запросом; будущее
приложение получает файл без отдельного механизма выдачи токенов.
- `+` изъятие из инварианта приватности не расширилось: в журнале по-прежнему
только расширение, а не имя.
- `` ссылка, единожды утёкшая, работает бессрочно: отзыва у неё нет, а файлы не
удаляются вовсе. Утечка возможна не только журналом — любой будущий экран,
показывающий ссылку, наследует это свойство.
- `` появилась норма, которую держит не построение, а внимание: всякий новый
отказ хранилища надо обрывать руками. Норму сторожат требование capability
`storage` и проверка журнала, но компилятор — нет.
- `` диагностируемость отказов упала: обрывая цепочку, мы теряем причину. У
выгрузки в Object Storage это смягчено — сохраняется класс отказа SDK
(`AccessDenied`, `NoSuchBucket`), в котором адреса не бывает.
- Решение действует до разграничения доступа: задачи `oidc-login` и
`record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
записью.
@@ -0,0 +1,50 @@
# Каталог данных задаётся одним ключом `[storage] data_dir`
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
раздел «Ключи конфигурации: два пути заменяются одним каталогом»
## Решение
Ключи `[database] path` и `[storage] path` уходят. Вместо них — один
`[storage] data_dir` со значением `data`: база и файлы записей лежат под одним
каталогом, и по-другому хранилище не умеет.
Выбор сделан человеком 2026-08-12 из трёх названных вариантов.
## Почему
Имя ключа конфигурации проект объявил необратимым
([../../CLAUDE.md](../../CLAUDE.md), «Работа»): переименование правится не в
одном файле, а в конфигурации на сервере и в выкладке, и молча ломает запуск.
Поэтому выбор ушёл человеку, а не был принят по ходу.
Цитата источника о цене каждого варианта:
> - `[storage] data_dir` — **выбрано**. Ключ назван по назначению, как названы и
> сегодняшние; смена библиотеки через год имени не тронет. Слово `storage` при
> этом уже занято capability, но в конфигурации оно значит ровно то же — где
> лежат данные;
> - `[pocketbase] data_dir` — прямее всего читается тем, кто знает библиотеку, и
> вписывает имя поставщика в необратимый ключ. Смена библиотеки потребует
> второго необратимого переименования;
> - `[data] dir` — короче и нейтральнее всех, но `data` в проекте уже значит
> каталог на диске, и секция с таким именем читается как «настройки каталога»,
> а не «настройки хранилища».
Запись попадает в журнал по **дорогому откату**: переименование ключа стоит
правки конфигурации на сервере и в выкладке, а ошибка проявляется отказом старта.
## Последствия
- `+` имя ключа не называет поставщика, и смена библиотеки хранилища второго
необратимого переименования не потребует.
- `+` каталог данных один, и запрет «боевой каталог не трогать» покрывает и базу,
и записи одной строкой.
- `` слово `storage` в проекте теперь значит три вещи: capability, само
хранилище и секцию конфигурации. Поле записи о файле от этого переименовано в
`location` — чтобы смыслов было три, а не четыре.
- `` прежние конфигурации несовместимы: сервис на старом `config.toml`
поднимется на умолчании `data`, а не на прежних путях. Данные при этом не
переносятся по решению задачи, так что цена нулевая ровно сейчас и была бы не
нулевой при переносе.
+2
View File
@@ -32,6 +32,8 @@
| Дата | Запись | Статус |
| --- | --- | --- |
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | |
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
| 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | |
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |