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

23 KiB
Raw Blame History

Критерии приёмки

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