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