Files
transcriber/openspec/changes/archive/2026-08-23-storage-without-pocketbase/tasks.md
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

231 lines
23 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.
## Критерии приёмки
Скопированы из записи задачи `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`: сегодня он
описывает отдачу файла ссылкой по токену, собственную поверхность хранилища
и панель владельца — то есть ушедшее