Files
transcriber/openspec/changes/archive/2026-08-12-pocketbase-storage/design.md
T
av 01cc31d45f хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
2026-08-12 08:31:59 +03:00

455 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
Способ решения выбран до этой задачи, и переписывать его здесь незачем:
хранилищем становится PocketBase вместе с файлами и панелью
([ADR-2026-08-11-pocketbase-storage-with-admin-panel](../../../docs/adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md),
замер — [research/pocketbase.md](../../../docs/research/pocketbase.md)), очередь
остаётся своей таблицей, но коллекцией той же базы
([ADR-2026-08-11-queue-as-pocketbase-collection](../../../docs/adr/ADR-2026-08-11-queue-as-pocketbase-collection.md),
сравнение кандидатов — [research/job-queue.md](../../../docs/research/job-queue.md)).
Ниже — только то, что этими решениями не закрыто.
Сегодня состояние лежит в SQLite через `mattn/go-sqlite3`, запросы строит goqu,
схему двигает goose, файлы лежат плоским каталогом с именами по идентификатору,
а HTTP отдаёт gin. Версия PocketBase — та же, что мерила разведка: 0.39.10.
## Goals / Non-Goals
**Goals:**
- Запись, её метаданные и её файл лежат в одном хранилище и видны владельцу
панелью.
- Захват задачи неделим, у задачи есть предел попыток и состояние «мертва».
- Сборка перестаёт требовать CGO.
- Сервис поднимается на чистом каталоге сам.
**Non-Goals:**
- **Перенос прежних данных.** Не переносим и не пытаемся прочитать: решение
задачи, а не следствие отказа.
- **Вход пользователей.** Провайдер OIDC у коллекции пользователей — задача
`oidc-login`.
- **Закрытие панели снаружи.** Это работа выкладки: адрес панели закрывает
Authelia на обратном прокси.
- **Отказ от холостого опроса.** Три воркера по-прежнему опрашивают базу раз в
секунду; открытый вопрос архитектуры остаётся открытым.
- **Удаление файлов и объектов.** Хранение бессрочно, задача `delete-record`
своя.
- **Своё резервное копирование.** Берём мы встроенное или нет — вопрос не
решён и здесь не решается.
## Decisions
### Приложение поднимает PocketBase само, а не отдаёт ему командную строку
Библиотека умеет запускаться двумя способами: `Start()` отдаёт процесс её
собственному набору команд, а `Bootstrap()` плюс `apis.Serve()` оставляет
управление нам.
Берём второй. Первый забирает разбор флагов себе, и ключ `-c config.toml`,
объявленный в командах проекта, пришлось бы либо ломать, либо подпирать. Мягкая
остановка по сигналу с двумя таймаутами из конфигурации — тоже наша, и отдавать
её чужой команде не за что.
Цена названа: набора команд PocketBase у нас не появляется, а вместе с ним нет и
команды заведения владельца панели. Чем это закрыто — ниже.
*Отвергнуто:* `Start()` с подстройкой флагов — экономит десяток строк и ломает
объявленный контракт запуска.
### Наши два обработчика переезжают на роутер PocketBase, gin уходит
Панель отдаётся тем же портом, что и приложение, — так решено ADR. Значит порт
слушает сервер PocketBase, и второму серверу на том же порту взяться неоткуда.
Наши маршруты — `POST /api/audio`, `GET /api/status/:id`, `GET /health`,
`GET /metrics` — переезжают в его роутер обработчиком на `OnServe`. Столкновения
имён нет: PocketBase занимает `/api/collections`, `/api/files`, `/api/settings`,
`/api/logs`, `/api/backups`, `/api/crons`, `/api/realtime`, `/api/batch` и
`/api/health`, а `/api/audio` и `/api/status/:id` свободны. Публичный контракт
API от этого не меняется — меняется то, кто его обслуживает.
Вместе с gin уходит `samber/slog-gin`, и запросы начинает писать журнал
PocketBase. Проверки приёма по HTTP переписываются под новый обработчик; их
предмет — коды, поля ответа и запрет имени отправителя в журнале — сохраняется
дословно.
*Отвергнуто:* **два сервера на разных портах** — наружу опубликован один порт, и
панель осталась бы недоступной либо потребовала бы второй маршрут на прокси;
ADR решил иначе. **gin общим обработчиком под роутером PocketBase** — маршруты
разбирались бы дважды, а совпадение с чужим путём проявилось бы как молчаливый
перехват.
### Схему заводят миграции PocketBase, каталог `migrations/` уходит
Коллекции `files` и `transcribe_jobs` заводит зарегистрированная миграция на Go;
`apis.Serve` применяет непринятые перед стартом сервера. Инвариант проекта
«миграция, уехавшая на сервер, не переписывается» переносится дословно: файл
шага не правится, изменение — только новым файлом.
Goose, goqu и `mattn/go-sqlite3` уходят из зависимостей вместе с каталогом
`migrations/*.sql` и вшиванием его в бинарник.
### Состав коллекций
`files` — по записи на физическую копию, как и сегодня:
| Поле | Что |
| --- | --- |
| `file` | сам файл; пусто у копии в Object Storage |
| `location` | `local` или `s3` |
| `object_key` | ключ объекта; пусто у местной копии |
| `size` | размер в байтах |
Поле названо `location`, а не `storage`, как сегодня, потому что после перевода
слово `storage` занято дважды: так зовётся capability и так зовут само хранилище.
Третий смысл в поле записи развёл бы `storage.FileRepository` и `entity.StorageS3`
по разным вещам под одним словом, и увидеть это в коде было бы нечем.
`transcribe_jobs` — задача и она же очередь. Поля сегодняшней таблицы переезжают
один в один, кроме трёх мест:
- `is_error` **уходит**. Задача выбывает из выборки состоянием, и способ этот
один: два способа разошлись бы, и молчаливо потерялся бы тот, который забыли
проверить;
- `state` получает значение `dead`;
- прибавляется `attempts` — число попыток.
Идентификаторы записей выдаёт PocketBase — 15 знаков собственного алфавита. Наши
UUID уходят: два источника идентификатора в одной таблице дают два формата
ссылки на одну сущность. Ответ `POST /api/audio` при этом продолжает нести
`job_id` строкой — контракт говорит о поле, а не о длине значения.
**Инвариант «новая колонка правится в четырёх местах» остаётся, но переезжает.**
Мест по-прежнему четыре — отображение задачи в запись и обратно, перечень колонок
захвата и структура, в которую он читает, — и все четыре лежат в одном пакете, а
не в четырёх запросах разных слоёв. Условие, ради которого инвариант писался, при
этом не снято: компилятор видит два места из четырёх, и колонка, забытая в паре
«перечень — структура», приезжает из захвата нулевой, а первый же `Save` пишет
этот ноль поверх сохранённого. Теряется поле **только у задачи, попавшей к
воркеру**, — то есть тише, чем прежде.
### Захват — один запрос с `RETURNING` мимо записей коллекции
```
UPDATE transcribe_jobs
SET acquisition_id = ?, acquire_time = ?, attempts = attempts + 1, updated = ?
WHERE id = (SELECT id FROM transcribe_jobs
WHERE state = ? AND (delay_time IS NULL OR delay_time < ?)
AND (acquisition_id IS NULL OR acquire_time < ?)
ORDER BY created LIMIT 1)
RETURNING <колонки>
```
`RETURNING` в движке за `modernc.org/sqlite` есть, и замер разведки показал: на
трёх горутинах разом запись получает ровно одна. Запрос идёт через `app.DB()`,
который всё, кроме выборок, направляет в пул с единственным соединением, — то
есть захваты выстраиваются в очередь, а не соревнуются за файл.
`ORDER BY` идёт по времени заведения **и по ключу записи**: время неуникально, и
без ключа порядок обработки невоспроизводим, а проверка, опирающаяся на
«следующую» задачу, зелена через раз.
**Время во всех колонках очереди — то же, каким хранилище пишет свои
`created`/`updated`:** строка `2006-01-02 15:04:05.000Z` в UTC (`types.DateTime`
библиотеки). Наш запрос кладёт и сравнивает `acquire_time`, `delay_time` и
`updated` только через это же значение. Причина не в аккуратности: сравнение
строк в SQLite побайтовое, и вид, разошедшийся на разделителе или на дробной
части, обращает `acquire_time < ?` в постоянную истину — тогда любая захваченная
задача немедленно достаётся второму воркеру — или в постоянную ложь — тогда
брошенная задача не возвращается никогда. Оба исхода тихие, и тест, который сам
же кладёт время своим кодом, зелен в обоих.
Хуки коллекции на сыром запросе не срабатывают — цена названа в ADR; поле
времени изменения проставляет тот же запрос.
*Отвергнуто:* **захват записями коллекции** — это снова два шага без транзакции,
ровно то, от чего уходим. **Захват в транзакции PocketBase** — даёт то же
свойство дороже: транзакция на каждый холостой опрос, которых 259 200 в сутки.
### Результат пишет только держатель захвата
Неделимость захвата не закрывает всего: захват протухает не только у мёртвого
воркера, но и у живого. Конвертация шестичасовой записи идёт дольше часа по
построению, а срок захвата на конвертацию сегодня — час.
Отсюда две правки. Первая: **сроки захвата привязываются к потолку своего шага**,
и в таблице настроек стоят рядом с ним. Вторая, и она важнее: **запись результата
условна по признаку захвата** — шаг, чей захват за время работы достался другому,
завершается без записи и без ответа отправителю.
Без второго два воркера пишут в одну задачу по очереди: результат первого
затирает результат второго, файл второго остаётся сиротой, распознавание уходит в
Yandex дважды за наши деньги, а отправитель получает два ответа на одну запись.
### Мёртвая задача, попытки и пауза
Три вещи, которые легко свести в одну и нельзя: **счётчик попыток**, **пауза
повтора** и **задержка опроса чужой операции**.
**Счётчик** растёт при каждом захвате и обнуляется, когда шаг завершился без
отказа. Рост при захвате, а не при отказе, — единственное, что засчитывает
попытку задаче, уносящей с собой процесс: до объявления отказа такая задача не
доходит никогда, и по счётчику отказов крутилась бы вечно. Обнуление при успехе
делает то же самое с другой стороны: задача, прошедшая конвейер, попыток не
копит и до предела не добирается.
**Кто переводит в «мертва».** Тот, кто захватил задачу с превышенным счётчиком:
захват её выдаёт, вызывающий видит перебор, ставит `dead`, сообщает отправителю и
возвращает «работы нет». Условие `attempts < предел` прямо в отборе не годится —
задача исчезла бы из выборки, не получив состояния, то есть выбыла бы молча.
**Отправителю сообщается.** Переход в «мертва» идёт тем же путём, что отказ шага:
инвариант «Принятая запись не теряется молча» допускает два исхода — либо задача
пригодна к повтору, либо о неудаче сказано, — и молчаливая смерть не подходит ни
под один.
**`dead` и `failed` — разные приговоры, а не два имени одного.** В `failed`
задачу переводит шаг, рассудивший об этой записи окончательно: файл не
конвертируется, распознавание вернуло ошибку. В `dead` задача уходит без такого
суждения: мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит
сам, и выбирать между двумя ему не приходится.
**Пауза повтора** — функция счётчика, ставится в момент отказа. **Задержка опроса
чужой операции** — число, ставится шагом проверки, и попытку он не тратит, потому
что отработал без отказа. Свести их было бы ошибкой ровно потому, что счётчик на
ожидании обнулён: пауза выродилась бы в своё наименьшее значение, и опрос
SpeechKit участился бы с пяти секунд до одной — вчетверо больше обращений к
платному сервису, а отказ по его лимиту тратит попытки уже по-настоящему.
Числа — в таблицу настроек `docs/database.md`:
| Настройка | Значение | Откуда |
| --- | --- | --- |
| Предел попыток | 5 | — |
| Пауза перед повтором | `2^(попытка−1)` секунд, потолок 5 минут | — |
| Срок захвата, конвертация | 8 часов | потолок записи 6 часов плюс запас |
| Срок захвата, проверка операции | 1 час | опрос идёт секунды |
| Задержка перед первой проверкой операции | 10 секунд | как сегодня |
| Задержка между проверками операции | 5 секунд | как сегодня |
Из вариантов эти числа не выбирались, и это честнее назвать, чем оправдать:
предел 5 и удвоение паузы — обычное умолчание, а не вывод из замера. Позволительно
потому, что числа обратимы — они живут в одной таблице и правятся строкой, в
отличие от имени ключа конфигурации и раскладки файлов.
### Имя файла в хранилище задаём мы, а не хранилище
Умолчание PocketBase строит имя из имени, данного отправителем: `sample.ogg`
превращается в `sample_uztrv6wvz3.ogg` — так это замерила разведка, и так это
предсказали ADR и модель угроз.
**Умолчание не берём.** Спека `intake` уже нормирует обратное: имя отправителя в
хранилище не попадает, потому что имя файла кончается в журнале, а имя
отправителя в журнал не пишется по инварианту приватности. Имя файла у библиотеки
— обычное поле, и мы ставим в него своё: идентификатор с расширением, как
сегодня.
**Суффикса при этом не появляется**, и это выяснилось прогоном: десять случайных
знаков дописывает не укладка, а тот самый конструктор имени, который мы обходим.
Значит имя в хранилище равно заданному, и защищает ссылку не суффикс, а то, что
имени в журнале нет вовсе.
Изъятие из инварианта — расширение, хвост после последней точки — остаётся ровно
таким, каким объявлено, и не расширяется до полного имени.
### Файл кладётся потоком, а рабочая копия заводится одним способом
Расчётный потолок записи — шесть часов, и в память такая запись не помещается.
Библиотека умеет строить файл из пути на диске и читает его потоком. Значит приём
пишет тело во временный файл, отдаёт его хранилищу и убирает за собой; каталог
временных файлов — общесистемный, не `data/`.
Обратная сторона — та же и упускается легче. **Конвертер и чтение метаданных
принимают путь**, потому что отдают файл внешней программе: `ffmpeg` и `ffprobe`
получают имя аргументом. Хранилище пути наружу не даёт, значит между ними нужна
рабочая копия — и вот её-то и надо завести **одним местом**, а не по месту в
каждом шаге.
Место это — сам репозиторий файлов: он выдаёт рабочую копию и единственный
способ её убрать, а зовёт уборку шаг. Полностью замкнуть уборку на репозиторий —
вызовом шага изнутри — мешает конвертация: ей нужны две копии разом, исходник и
результат, и вложенные вызовы читались бы хуже, чем два `defer` подряд. Цена
названа: норма держится проверкой, а не построением, и проверки на уборку есть у
приёма и у шага конвертации.
*Отвергнуто:* **построение файла из байтов в памяти** — проще на строку и роняет
процесс на первой же длинной записи. **Путь внутрь раскладки хранилища, отданный
`ffmpeg` напрямую** — раскладка библиотеки становится нашим контрактом, а
требование «файл адресуется записью» не выполняется с первого дня и молча.
**Перевод конвертера и `ffprobe` на потоки** — дороже всего и упирается в то, что
длительность из потока `ffprobe` отдаёт не всегда.
### Панель — вход в задачу, а не окно просмотра
Ради правки задачи панель и покупалась: мёртвая задача оживляется сменой
состояния, а не запросом в консоли сервера. Но правка полем в панели идёт мимо
кода, который сегодня чистит служебные поля прошлого состояния, — и владелец,
«вернувший задачу в работу», получил бы задачу с прежним признаком захвата
(захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт от
первого отказа). Он бы об этом не узнал.
Поэтому переход, сделанный в панели, проходит те же правила, что переход из кода:
на правку записи задачи вешается хук, который при смене состояния чистит признак
захвата, время захвата, паузу и число попыток. Единая точка перехода остаётся
одна, и панель ходит через неё.
Схема при этом держит то, что сегодня держит компилятор: ссылка на файл
обязательна, перечень состояний закрыт, число попыток неотрицательно. Задача,
заведённая в панели руками, не должна ронять процесс на разыменовании пустой
ссылки — а сегодня уронила бы, и вместе с воркером ушли бы бот и приём по HTTP.
*Отвергнуто:* **панель только для чтения по этой коллекции** — отнимает ровно то,
ради чего перевод затевался. **Оставить как есть** — перекладывает на владельца
знание о четырёх служебных полях, и первая же ошибка тихо ломает задачу.
### Потолок размера назван числом, потому что чужие умолчания малы
Прогон показал то, что чтением не видно: нулевой потолок у поля файла библиотека
читает не как «без предела», а как своё умолчание в **5 МиБ**, а роутер
хранилища отсекает тело запроса на **32 МиБ** раньше нашего обработчика. Оба
умолчания на два-три порядка меньше расчётной записи в шесть часов: приём
отказывал бы на всём длиннее примерно пяти минут, а уже принятая запись
исчерпывала бы попытки на шаге конвертации — результат в ogg переваливает 5 МиБ
примерно на пятой минуте.
Поэтому потолок задан числом и одним: `entity.MaxRecordSize`, 8 ГиБ, выведено из
шести часов с запасом на видео. Тем же числом ограничено тело запроса приёма.
Заодно снят таймаут чтения — умолчание в пять минут не переживает заливку
шестичасовой записи по медленному каналу, а стойкость к целенаправленной
нагрузке объявлена вне модели угроз.
### Правила панели стоят на правке запросом, а не на всяком сохранении
Первая редакция вешала их модельным событием, и это оказалось дефектом: событие
не различает, кто пишет, и срабатывало на каждом переходе конвейера. Задержка,
поставленная шагом вместе со сменой состояния, стиралась тем же сохранением —
опрос платного распознавания уходил через секунду вместо десяти, — а число
попыток мёртвой задачи, которое переход хранит намеренно, приходило владельцу
нулём.
Событие правки **запросом** различает источник по построению: конвейер пишет
мимо HTTP-слоя и под него не попадает.
### Поле файла не помечаем защищённым, но ссылка не уезжает в журнал
Защищённое поле требует отдельного файлового токена. Не помечаем: сегодня право
прочитать задачу даёт знание её идентификатора, и файл встаёт вровень с
`GET /api/status/:id`, а не ниже. Правила доступа коллекций при этом остаются
пустыми — то есть перечислить записи может только владелец панели, и подобрать
идентификатор снаружи неоткуда.
**Отсюда следствие, которого не было при плоском каталоге, и оно меняет смысл
изъятия из инварианта приватности.** Изъятие выписано под путь на диске:
`data/files/<uuid>.ogg` читателю журнала бесполезен. После перевода имя файла в
хранилище — это последняя часть ссылки `/api/files/...`, по которой запись
скачивает кто угодно; строка журнала стала бы бессрочным ключом к чужому аудио.
Поэтому **в журнал идёт расширение собственным полем**, а имя файла — ни в каком
виде. Прослеживаемость от этого не страдает: требование `intake` просит
идентификатор, расширение и размер, и все три остаются. Изъятие остаётся ровно
таким, каким объявлено: расширение, и только оно.
**Отказы обрываются там же.** Отказ чтения из хранилища несёт ключ файла целиком,
отказ выгрузки в Object Storage — полный адрес объекта; обе цепочки `%w` уехали
бы в журнал и собрали бы ссылку не хуже успешного пути. Поэтому наружу идёт свой
текст с идентификатором записи, а чужой не оборачивается.
Цена названа: ссылка на файл, единожды утёкшая, работает без ограничения по
времени. Разграничение доступа целиком — задачи `oidc-login` и
`record-ownership`, и до них периметр таков, каким его описывает модель угроз.
### Ссылку на файл строит панель, а не наш контракт
Потребителя у ссылки внутри сервиса нет: ответ опроса готовности её не несёт,
распознавание берёт содержимое, а панель строит ссылку сама. Поэтому метода
«построй ссылку» в договоре ядра с хранилищем **не заводим** — иначе форма
HTTP-пути протекла бы в доменный контракт, а знать о протоколе хранилищу незачем.
Требование «файл отдаётся ссылкой» при этом остаётся: оно нормирует свойство
хранилища, а проверяется прогоном — запросом за файлом и сверкой длины. Первым
потребителем ссылки станет приложение, и заведёт её себе оно.
### Владелец панели заводится ссылкой при первом запуске
Команды заведения владельца у нас нет — её забрал отказ от чужой командной
строки. Библиотека закрывает это сама: пока владелец не заведён, при старте
сервера она печатает ссылку установки, по которой владелец задаёт себе почту и
пароль. Ссылка идёт в журнал контейнера, а журнал читает владелец сервиса.
Ссылка равносильна паролю от панели, поэтому у неё два ограничения, и оба у
библиотеки уже есть: **тридцать минут жизни** (`NewStaticAuthToken(30*time.Minute)`)
и печать **только пока владельца нет**. Проверено чтением её кода;
подтвердить прогоном — шаг приёмки. Бессрочная ссылка в журнале отдала бы панель
всякому читателю логов навсегда — при инварианте «строки уже уехали в журнал
контейнера» это необратимо.
Ключа конфигурации под пароль не появляется, и это осознанно: секрет, которого в
конфигурации нет, не утекает вместе с ней. Хранилище держит только отпечаток.
*Отвергнуто:* **пароль ключом конфигурации** — заводит в конфигурации самый
чувствительный секрет проекта и ставит его в один ряд с токеном бота, тогда как
хранилище умеет обойтись отпечатком.
### Ключи конфигурации: два пути заменяются одним каталогом
`[database] path` и `[storage] path` уходят: база и файлы съезжаются под один
каталог, и по-другому хранилище не умеет.
**Имя ключа конфигурации проект объявил необратимым**, поэтому решение принял
человек 2026-08-11: **`[storage] data_dir` со значением `data`**. Варианты и цена
каждого:
- `[storage] data_dir`**выбрано**. Ключ назван по назначению, как названы и
сегодняшние; смена библиотеки через год имени не тронет. Слово `storage` при
этом уже занято capability, но в конфигурации оно значит ровно то же — где
лежат данные;
- `[pocketbase] data_dir` — прямее всего читается тем, кто знает библиотеку, и
вписывает имя поставщика в необратимый ключ. Смена библиотеки потребует второго
необратимого переименования;
- `[data] dir` — короче и нейтральнее всех, но `data` в проекте уже значит
каталог на диске, и секция с таким именем читается как «настройки каталога», а
не «настройки хранилища».
## Risks / Trade-offs
- **Правила доступа коллекций оставлены пустыми, а сама база публикует
`/api/collections/...` и служебные разделы наружу** → пустое правило значит
«только владелец панели», то есть анонимный запрос к записям получает отказ.
Проверяется прогоном на живом сервисе, а не рассуждением, и прогон этот —
отдельный шаг приёмки.
- **Панель висит на публичном порту** → закрывает её Authelia на обратном прокси;
это работа выкладки, и до неё панель открыта всякому, кто знает адрес. Записано
моделью угроз, задачи в беклоге нет намеренно.
- **Захват идёт сырым запросом мимо записей коллекции** → правка состава колонок
очереди перестаёт быть видной компилятору в этом одном месте. Держится тестом
захвата, который читает захваченную задачу целиком.
- **Число попыток растёт при захвате** → задача, которую бросают по независящей от
неё причине (перезапуск сервиса), тратит попытки. Смягчение: счётчик обнуляется
на каждом шаге, завершившемся без отказа, поэтому пять перезапусков подряд
должны прийтись на одну и ту же задачу, чтобы её убить.
- **Задача умирает молча, если сообщение отправителю не дошло** → переход в
«мертва» отвечает тем же путём, что и отказ, и отказ отправки логируется так же.
Гарантии доставки у нас нет ни там, ни там, и этой задачей она не заводится.
- **Проверки приёма по HTTP переписываются целиком** → предмет проверок при этом
не меняется, и расхождение поймает сравнение с прежним списком сценариев спеки
`intake`.
- **Идентификаторы задач меняют формат** → внешняя программа, хранящая прежние
идентификаторы, их не найдёт. Прежних данных нет по решению задачи, поэтому
цена нулевая — но названа, потому что при переносе данных была бы не нулевой.
## Migration Plan
Переноса нет. Сервис поднимается на чистом каталоге данных; момент перехода на
сервере назначает человек, и до него прежний каталог остаётся нетронутым.
Откат — возврат прежнего образа и прежнего каталога `data/`: новый каталог
данных заводится рядом, старого не трогает.
## Open Questions
- **Своё резервное копирование PocketBase** — берём или оставляем серверу;
открытый вопрос архитектуры, этой задачей не закрывается.
- **Отказ от холостого опроса** — 259 200 запросов в сутки посчитаны, цена не
измерена; вопрос остаётся открытым.