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