хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
@@ -0,0 +1,230 @@
## Критерии приёмки
Скопированы из записи задачи `storage-without-pocketbase` дословно: запись
закрытие удалит, критерии обязаны её пережить.
- Библиотеки нет в сборке. **Оракул:** `go list -deps ./... | grep -c pocketbase`
даёт `0`, а `go mod tidy` не возвращает её модулей в `go.mod`.
- Чужой файл недостижим по прямой ссылке. **Оракул:** тест контроллера — запрос
к файлу чужой записи и к несуществующей отвечает одинаково.
- Захват записи остаётся неделимым. **Оракул:** тест с параллельными воркерами
под `-race` — запись достаётся ровно одному, второй получает отказ по значению
признака захвата.
- Пространства хранилища не существует. **Оракул:** тест маршрутов — `/api/…`,
`/_/` и `/%5f/` отвечают тем же, чем всякий неизвестный путь.
- Схема накатывается на пустом каталоге до старта воркеров. **Оракул:** запуск на
чистом каталоге данных — ни одного отказа в журнале до первой строки о готовности.
Ниже — рубрика ревью дизайна. Приёмка судится по одному списку, поэтому она
стоит здесь же, а не отдельным документом.
- Ограничения схемы действуют при любом способе записи и на каждом соединении
пула. **Оракул:** соединение, взятое из читающего пула, отвечает `1` на `PRAGMA
foreign_keys`; вставка аудиозаписи с несуществующим владельцем отвергается базой
и на пишущем соединении, и на читающем.
- Захват неделим, а держатель узнаётся значением признака. **Оракул:** тест под
`-race` с несколькими воркерами — запись достаётся ровно одному; запись
результата с чужим значением признака не проходит и записи не меняет.
- Узел, читающий состояние, которое сам же меняет, называет неделимый шаг.
**Оракул:** захват записи, заведение учётной записи первым обращением и накат
шага схемы идут одним запросом либо одной транзакцией на пишущем соединении;
тест на два одновременных первых обращения даёт ровно одну строку пользователя.
- Границы транзакции названы, и отмена контекста оставляет запись либо прежней,
либо полной. **Оракул:** тест с отменой контекста посреди составной операции —
читающий следом видит либо всё прежнее, либо всё новое, и ни одной половины.
- Накат схемы идемпотентен и не оставляет полуприменённого состояния.
**Оракул:** второй запуск на заведённом каталоге не применяет ни одного шага;
запуск, оборванный между применением шага и отметкой о нём, при повторе даёт тот
же исход либо отказ с именем шага, но не удвоенное применение.
- Укладка файла атомарна, и неполная укладка не выдаёт себя за полную.
**Оракул:** источник, отдающий отказ на середине потока, не оставляет ни строки
о файле, ни файла под рабочим именем, ни временного имени в подкаталоге записи.
- Время, идентификаторы и их вид приходят из одного места. **Оракул:** все колонки
времени применённой схемы объявлены одним типом и без умолчания; строка,
заведённая приёмом, и строка, заведённая запросом к базе, попадают в отбор
захвата одинаково.
- Отдача файла одинаково отвечает на чужое и несуществующее, а размер ответа
ограничен запрошенным. **Оракул:** тест обработчика — чужая запись,
несуществующая запись и негодное значение параметра `copy` у чужой записи дают
один код и одно тело; запрос с диапазоном отдаёт длину запрошенного куска, а не
файла целиком; настоящий HTTP-запрос с `Range: bytes=99999999-` и запрос с двумя
диапазонами дают код `400` и тело с полями `error_code` и `message`, а не `416`
телом библиотеки.
- Отказы, рождающиеся не в обработчике, приходят той же формой, что и отказы
обработчика. **Оракул:** настоящий HTTP-запрос через поднятую цепочку слоёв —
предел тела, ограничитель частоты и неизвестный путь под корнем приложения дают
тело с полями `error_code` и `message` и код из закрытого перечня; вызовом
отображателя ошибки это не проверяется.
- Ключ ограничителя частоты называет того, кого надо ограничить. **Оракул:** два
клиентских адреса через один доверенный прокси расходуют разные бюджеты, а
заголовок пересылки, пришедший с недоверенного адреса, на ключ бюджета не
влияет.
- Горячие выборки опираются на индекс. **Оракул:** `EXPLAIN QUERY PLAN` отбора
захвата и `EXPLAIN QUERY PLAN` списка, сужаемого владельцем и страницей, не
показывают полного сканирования таблицы аудиозаписей.
- Подъём и остановка симметричны и громкие. **Оракул:** отказ шага подъёма даёт
ненулевой код выхода и ровно одну строку журнала о причине; мягкая остановка
закрывает то же, что открыл подъём, — оба пула базы, входы и воркеров, — и
повторная остановка не даёт паники.
## 1. База и её схема
- [x] 1.1 Завести подключение к базе на `modernc.org/sqlite`: одно соединение для
записи, отдельный пул для чтения, журнал упреждающей записи, ожидание
занятой базы числом из настроек
- [x] 1.2 Подключить `github.com/pressly/goose/v3` библиотекой: добавить модуль в
`go.mod`, завести каталог шагов, вшитый в бинарник, и поднимать провайдер в
точке входа. Командная строка `goose` не заводится: ни отдельного
исполняемого файла, ни своего шага выкладки
- [x] 1.3 Взять исключающую блокировку наката самим: `goose` под SQLite её не
поставляет — запиратели у него только для PostgreSQL. Замок на файле в
каталоге данных (`syscall.Flock`, `LOCK_EX`) берётся до наката и снимается
после; проверить тестом, что второй накат на том же каталоге ждёт либо
отказывает, а шаги параллельно не применяются
- [x] 1.4 Удалить каталог шагов PocketBase целиком —
`internal/adapter/repo/pocketbase/migrations`
- [x] 1.5 Завести один шаг начальной схемы в новом каталоге шагов: разовое снятие
инварианта «применённая миграция не переписывается» решением владельца
2026-08-22
- [x] 1.6 Перевести ключ `[docs] migrations` в `.av-dev.toml` на новый каталог
шагов. Без этого шаг гейта `migrations` покраснеет на удалении файлов
прежнего каталога — он читает их статусом `D` как переписанный применённый
шаг
- [x] 1.7 Написать шаги схемы под все таблицы — учётные записи, аудиозаписи,
файлы, тексты, структура реплик, попытки распознавания, журнал событий,
темы — с обязательными связями владельца и обязательными колонками
`original_filename`, `duration_ms`, `size_bytes`
- [x] 1.8 Завести тем же шагом два индекса аудиозаписей: под отбор захвата — по
рубежу, признаку остановки, паузе и сроку протухания захвата; под список —
по владельцу и колонке упорядочивания страницы вместе с ключом записи.
Индексы заводятся здесь, а не потом: применённый шаг схемы не
переписывается, и добавление индекса будет стоить отдельного шага, а замер
2026-08-15 уже показывал полное сканирование на выборке, сужаемой владельцем
- [x] 1.9 Накатывать схему до подъёма входов и до старта воркеров, отказ шага
ронять стартом с именем шага
- [x] 1.10 Записать числа настроек базы в `docs/database.md`, «Настройки с
числовым значением»
- [x] 1.11 Завести в `config.example.toml` два ключа секции `[storage]`
`busy_timeout_ms` (ожидание занятой базы, миллисекунды) и `read_connections`
(число соединений читающего пула) — с комментарием на каждый: зачем,
границы, единицы; проверить, что загрузчик их читает и старт на пустом
значении не молчит
- [x] 1.12 Снять `EXPLAIN QUERY PLAN` с отбора захвата и со списка, сужаемого
владельцем и страницей: полного сканирования таблицы аудиозаписей ни один
из планов не показывает
## 2. Репозитории на своей базе
- [x] 2.1 Переписать репозиторий аудиозаписи: чтение, сохранение своих полей,
условная запись результата по признаку захвата
- [x] 2.2 Переписать захват одним запросом с `RETURNING`, возвращающим
идентификатор записи и признак этого захвата
- [x] 2.3 Переписать репозитории приложений записи — тексты, структура реплик,
попытки распознавания, журнал событий, темы — с уникальностью по паре
«запись и вид» и «запись и версия разбора»
- [x] 2.4 Переписать репозиторий учётных записей: поиск по ключу, заведение при
первом обращении, разбор двух отказов уникальности
- [x] 2.5 Проверить тестом, что пустая замена не стирает сохранённый текст и
сохранённый ответ провайдера
- [x] 2.6 Отображать сущности в строки базы **по имени**: именованные параметры
запроса и сканирование по имени колонки, без позиционных списков. У
аудиозаписи ссылки на файлы, на структуру реплик, на два вида текста и на
попытку распознавания стоят подряд полями одного типа, и позиционный сдвиг
на одно поле скомпилировался бы молча, положив идентификатор файла в колонку
текста
- [x] 2.7 Убрать поля `location` у сущности файла и `source` у аудиозаписи — из
домена, из отображения в строки базы и из шага начальной схемы. Обе пишутся
сегодня одним значением и не читаются никем, а второе значение `source`
держалось ссылкой из применённого шага, который уходит. Вернуть поле, когда
у него появится читатель, будет стоить одного нового шага схемы
## 3. Файлы записей своим каталогом
- [x] 3.1 Завести раскладку: подкаталог на запись под её идентификатором, имя
файла задаёт сервис, копии `original` и `normalized` лежат вместе
- [x] 3.2 Переписать репозиторий файлов: укладка потоком без чтения в память,
чтение потоком, владелец колонкой, ссылки на две копии порознь
- [x] 3.3 Сохранить единый способ выдать рабочую копию шагу и единственный
способ её убрать
- [x] 3.4 Проверить тестом, что имени отправителя нет ни в имени файла, ни в
пути к нему, ни в журнале
## 4. Маршруты и слои на `net/http`
- [x] 4.1 Переписать подъём сервера и цепочку слоёв без чужого маршрутизатора,
сохранив порядок «ограничитель частоты → узнавание»
- [x] 4.2 Написать свой ограничитель частоты по адресу спрашивающего под корнем
приложения и вывести объявляемую частоту опроса из доли его бюджета
- [x] 4.3 Переписать узнавание по доверенному заголовку на своих типах, убрав
выдачу и приём всякого значения на предъявителя
- [x] 4.4 Переписать обработчики приёма, списка, карточки, текста, пределов и
«кто вошёл» на своих типах
- [x] 4.5 Завести обработчик отдачи файла `GET /app/audiorecords/{id}/file` с
проверкой владельца, параметром `copy` и закрытым перечнем его значений,
кодом `409` на отсутствующую копию и выдачей по диапазону. Негодный диапазон
— неудовлетворимый и множественный — приводить к обычному отказу `400` с
телом сервиса, а не отдавать `416` телом библиотеки
- [x] 4.6 Свести адресное пространство сервиса к одному корню `/app` плюс
`/health` и `/metrics`; проверить тестом ответы на `/api/…`, `/_/` и `/%5f/`
- [x] 4.7 Убрать ветвь отказа `403` и значение `forbidden` из перечня кодов
отказа вместе с её единственным случаем
## 5. Уборка библиотеки
- [x] 5.1 Удалить пакет адаптера хранилища вместе с панелью и правилами панели;
каталог его шагов схемы уходит пунктом 1.4
- [x] 5.2 Убрать из настроек и из образца конфига всё, что относилось к панели и
к её владельцу
- [x] 5.3 Убрать библиотеку и достижимые только через неё модули из `go.mod`,
прогнать `go mod tidy`
- [x] 5.4 Проверить оракулом, что `go list -deps ./... | grep -c pocketbase`
даёт `0`
- [x] 5.5 Снять изъятие правил `internal/archrules`, разрешавшее транспорту
знать адаптер хранилища, и добавить правило на это направление
- [x] 5.6 Переписать правила `internal/archrules` о колонках записи
(`TestКолонкиЗаписиПишутсяИЧитаются`,
`TestКолонкиЗаписиЗаведеныШагомСхемы`) и о едином дескрипторе рубежа
(`TestОтборСпискаБерётРубежиУДескриптора`,
`TestУКаждогоРабочегоРубежаЕстьШаг`, `TestШагиОбъявленыРубежамиДескриптора`)
под новую форму хранилища: сегодня они построены на динамической записи по
имени колонки в API уходящей библиотеки и указывают в удаляемый пакет.
Форма выбрана — именованные параметры и сканирование по имени (пункт 2.6), —
и сторожа инварианта о колонках записи переписываются под неё: они сверяют
**имена** колонок в отображении, в чтении и в шаге начальной схемы, а не
порядок полей. Указывают правила в новый пакет `internal/adapter/repo/sqlite`
и в новый каталог шагов
## 6. Оснастка владельца
- [x] 6.1 Завести в `cmd/devtools` подкоманду возврата остановленной записи в
работу: принимает идентификатор записи, открывает базу каталога данных,
зовёт домен и пишет событие журнала записи с происхождением
`entity.EventOriginHuman`. Колонок подкоманда не пишет: перечень полей,
которые возврат обязан сбросить, исполняет домен — норму держит capability
`pipeline`
- [x] 6.2 Проверить тестом, что возврат в работу сбрасывает всё названное
требованием — признак остановки, признак захвата и срок его протухания,
число отказов, паузу и время входа в рубеж, — и что ближайший захват выдаёт
запись, не дожидаясь протухания прежнего срока
## 7. Проверки и документы
- [x] 7.1 Написать тест параллельного захвата под `-race`: запись достаётся
ровно одному, второй получает отказ по значению признака захвата
- [x] 7.2 Написать тест отдачи файла: чужая запись и несуществующая отвечают
одинаково
- [x] 7.3 Написать тест подъёма на чистом каталоге: до строки о готовности в
журнале нет ни одного отказа
- [x] 7.4 Прогнать `task gate` до зелёного
- [x] 7.5 Обновить документы канона — паспорт, архитектуру, модель угроз,
`docs/database.md` — под ушедшие панель, пространство хранилища и токен
файла; в перечне зависимостей архитектуры назвать `pressly/goose/v3`
пришедшим, а библиотеку хранилища — ушедшей
- [x] 7.6 Записать в `CLAUDE.md` решение владельца от 2026-08-22 о **разовом**
снятии инварианта «миграция, уехавшая на сервер, не переписывается»: причина
— стройка, на сервере данных нет, сервис остановлен; граница — снятие
кончается этим изменением, и шаг начальной схемы подпадает под инвариант как
всякий прежний. Пункт синка: документы канона правятся после задачи
- [x] 7.7 При архивации изменения поправить `Purpose` спеки `storage`: сегодня он
описывает отдачу файла ссылкой по токену, собственную поверхность хранилища
и панель владельца — то есть ушедшее