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

28 KiB
Raw Blame History

Context

Сегодня встроенное хранилище держит в этом коде шесть ролей сразу, и только две из них про хранение. Что именно оно держит, чем за это плачено и чем платит уход — измерено разведкой docs/research/storage-without-pocketbase.md; варианты («оставить как есть», «уйти целиком», «уйти в два шага», «локализовать протечку») названы там же таблицей, а выбор и причины отказа от остальных записаны решением ADR-2026-08-22-storage-without-pocketbase. Второй раз здесь они не переписываются.

Обстоятельство, которое делает работу дешёвой именно сейчас: стадия проекта — стройка, на сервере данных нет, сервис остановлен. Раскладка каталога данных названа необратимой, и цена её смены сегодня нулевая.

Ниже — только те решения, которых в ADR и разведке нет: они выбирают форму, а не направление.

Goals / Non-Goals

Goals:

  • Человек, открывший приложение, не замечает смены вовсе: адреса приложения, формы запросов и ответов остаются прежними.
  • Второго периметра на порту сервиса не остаётся: всё, что отвечает, написано нами и судится нашими правилами.
  • Файл записи достаётся только его владельцу, и право пройти по ссылке даёт узнавание, а не значение, выданное на предъявителя.
  • Захват записи остаётся неделимым, и результат по-прежнему пишет только держатель захвата.
  • Сервис поднимается на пустом каталоге данных сам, без ручного шага.

Non-Goals:

  • Замены панели владельца это изменение не приносит: экраны правки записи и прослушивания несут отдельные задачи. До них у владельца сервиса остаётся одно действие — возврат остановленной записи в работу, — и делает его подкоманда оснастки, а не экран.
  • Переноса прежних данных нет: переносить нечего.
  • Загрузка частями, узнавание записи по хеш-сумме, удаление записи и вторая копия рядом становятся выполнимыми, но этим изменением не делаются.
  • Состав полей карточки и списка не меняется ни одним полем.

Decisions

Файлы записей лежат каталогом на запись

Каталог данных получает раздел записей, и каждой записи в нём принадлежит свой подкаталог, названный её идентификатором. Внутри лежат копии — принятая и приведённая, — под именами, которые задаёт сервис.

Что человек увидит иначе: ничего. Что станет возможным — вторая копия рядом с первой, дозапись частями и уборка записи целиком одним движением; сегодня всё это упирается в чужую раскладку.

Рассмотрено и отвергнуто:

  • плоский каталог, имя файла — идентификатор с расширением. Отвергнут: две копии одной записи в плоском каталоге различаются только приставкой в имени, и уборка записи превращается в перебор по маске;
  • раскладка по первым знакам идентификатора (как у git). Отвергнута: она лечит переполнение каталога тысячами имён, а расчётная нагрузка — единицы записей в день; платить за это нечитаемыми путями сегодня не за что.

Файл отдаётся адресом приложения, а не адресом хранилища

Файл записи уходит адресом под корнем приложения, и вид копии называет спрашивающий — тем же способом, каким называется вид текста. Обработчик судит владельца записи и отвечает на чужую и на несуществующую одинаково.

Что человек увидит иначе: короткого токена файла больше нет, и порядок из трёх шагов («узнавание → токен → ссылка») становится одним шагом. Отзыв доступа доходит до файла сразу, а не через срок жизни токена.

Рассмотрено и отвергнуто:

  • своё значение на предъявителя, выдаваемое узнанному — то, что было. Отвергнуто: узнавание идёт на каждом запросе, второе значение ничего не добавляет и создаёт окно, в котором отозванный доступ ещё работает;
  • ссылка со сроком и подписью (как у объектных хранилищ). Отвергнута: та же цена, что и у токена, плюс свой секрет подписи, которого у сервиса нет.

Шаги схемы двигает goose, а не свой раннер

Шаги схемы накатывает github.com/pressly/goose/v3библиотекой, а не командной строкой: каталог шагов вшит в бинарник, провайдер заводится в точке входа и получает пишущий пул базы, накат идёт до подъёма входов и до старта воркеров, отказ шага роняет старт. Ни отдельного исполняемого файла, ни своего шага выкладки не заводится.

Инструмент выбран решением владельца 2026-08-22. Он уже был в этом проекте и ушёл вместе с PocketBase: возврат не приносит нового знания, а снимает часть работы.

Из трёх норм, которые накат обязан выполнять, goose даёт две — и даёт по факту, а не по обещанию:

  • шаг и отметка о нём идут одной транзакцией. Накат одного шага открывает транзакцию на том же соединении и внутри неё выполняет и сам шаг, и вставку версии в таблицу учёта (provider_run.go: beginTx вокруг runMigration и maybeInsertOrDelete). Отменяет это только сам шаг — пометкой -- +goose NO TRANSACTION, — и мы её не ставим;
  • порядок шагов детерминирован. Версия шага читается числом из имени файла до первого знака подчёркивания (NumericComponent в migration.go), а собранные шаги сортируются по этому числу (sort.Slice в provider_collect.go). Порядок обхода каталога на исход не влияет: его результат пересортировывается. Две одинаковых версии дают отказ сбора с обоими путями, а не молчаливый выбор одной.

Третьей нормы — исключающей блокировки накатаgoose под SQLite не даёт вовсе. Пакет lock поставляет два запирателя, и оба для PostgreSQL: NewPostgresSessionLocker и NewPostgresTableLocker; интерфейсы SessionLocker и Locker объявлены вместе с отказом ErrLockNotImplemented, а провайдер, которому запиратель не задан, накатывает без всякой блокировки.

Блокировка остаётся нашей заботой и закрывается замком на файле в каталоге данных: syscall.Flock с LOCK_EX берётся до наката и снимается после, а с умершим процессом его снимает ядро — просроченного замка, который надо чистить руками, не остаётся. Второй процесс, поднятый на том же каталоге, ждёт замка либо отказывает. Норму держит требование capability storage «Сервис поднимается на чистом каталоге данных», и написана она про поведение, а не про инструмент.

Что человек увидит иначе: ничего, пока всё цело. Отказ схемы становится отказом старта, а не отказом каждого запроса потом.

Рассмотрено и отвергнуто:

  • свой раннер — чтение каталога шагов, своя таблица учёта, своя сортировка. Отвергнут: три нормы выше ему пришлось бы выполнять самому, две из них goose уже выполняет, а третья остаётся нашей при любом выборе. Плата за свой раннер — код, который надо писать, отлаживать и держать, — вносится ради экономии одной зависимости, уже бывшей в проекте;
  • схема одним файлом, приводимым к желаемому виду при старте. Отвергнута: инвариант проекта требует версионированных шагов и запрещает переписывать применённый.

Прежние шаги схемы заменяются одним шагом начальной схемы

Применённые шаги PocketBase удаляются каталогом целиком, и на их месте встаёт один шаг, заводящий схему сразу такой, какая нужна. Это снятие инварианта проекта «Миграция, уехавшая на сервер, не переписывается» — разовое, решением владельца от 2026-08-22.

Причина: стадия проекта — стройка, на сервере данных нет, сервис остановлен, выкладка пойдёт с чистого листа. Переписывать нечего: ни один из прежних шагов не применён ни к одной живой базе, а новая база заводится другим инструментом и другой таблицей учёта — отметки прежнего каталога ей не годятся вовсе.

Граница: снятие разовое и кончается этим изменением. Шаг начальной схемы, уехав на сервер, подпадает под инвариант как всякий прежний — менять его можно будет только новым шагом. Шаг гейта migrations при этом обязан смотреть на новый каталог: оставленный на старом, он прочитает удаление прежних файлов как переписывание применённого и покраснеет.

База принимает одного писателя

Соединение для записи одно, чтение идёт своим пулом; журнал упреждающей записи включён, соблюдение внешних ключей включено, ожидание занятой базы задано числом. Все три задаются строкой подключения обоих пулов, а не запросом после открытия: две из трёх настроек в SQLite принадлежат соединению, а не базе, и пул заводит новые соединения по мере надобности. Числа переезжают в дом числовых настроек проекта.

Два числа заводятся ключами конфига сразу, этим же изменением:

  • [storage] busy_timeout_ms — сколько ждать занятую базу, миллисекунды;
  • [storage] read_connections — сколько соединений держит читающий пул.

Оба крутят при одном и том же отказе — «база занята» под несколькими воркерами, — и число воркеров у сервиса уже настраивается. Константами кода они означали бы, что подбор ответа на этот отказ требует пересборки образа.

Операция, которая читает и следом пишет, идёт целиком на пишущем соединении: читающую транзакцию SQLite до пишущей не повышает и отказывает по занятости сразу, не дожидаясь заданного числом срока. Так идут захват записи и накат шага схемы.

Узнавание устроено иначе, и это решение владельца от 2026-08-23. Учётная запись ищется читающим пулом, а пишущая транзакция открывается только когда поиск ничего не нашёл; окно между двумя соединениями закрывает повторный поиск внутри транзакции, а уникальность держит схема. Прежнее устройство брало писателя на каждом запросе приложения — включая опрос карточки и запрос куска записи, — и цена этого измерена: очередь к единственному пишущему соединению ожиданием занятой базы не ограничена и отказом не кончается, а ждёт столько, сколько занят писатель.

Что человек увидит иначе: ничего, пока настройка верна. Ошибка здесь видна отказами «база занята» под несколькими воркерами — тем самым, что чужая библиотека держала за нас двумя пулами, — а ошибка во внешних ключах не видна вовсе: ничья запись просто заводится.

Рассмотрено и отвергнуто: один пул на всё — отвергнут разведкой, измерившей, что драйвер пишет единственным соединением.

Пакет хранилища называется internal/adapter/repo/sqlite

Пакет internal/adapter/repo/pocketbase уходит целиком, и на его месте встаёт internal/adapter/repo/sqlite. Имя выбирается здесь, а не при написании кода: оно разойдётся по импортам, по правилам internal/archrules, которые указывают в пакет строкой, и по документам канона — переименование после этого стоит дороже самого выбора.

Названо по драйверу, а не по роли: соседи в internal/adapter названы тем же способом — converter, metaviewer, recognizer, — и «repo/sqlite» читается как «репозитории поверх SQLite» без знания кода.

Рассмотрено и отвергнуто: internal/adapter/repo/db — не называет ничего, а второй драйвер рядом с ним пришлось бы назвать по имени, и пара вышла бы разнородной.

Захват остаётся одним запросом с возвратом

Выбор пригодной записи и пометка её захваченной идут одним запросом, и он же возвращает идентификатор записи и признак этого захвата. Признак уникален для каждого захвата, и запись результата условна по нему.

Решение подтверждается, а не принимается заново: замер, которым выбрана очередь таблицей, снят на том же драйвере, который остаётся после ухода. Отменяется одно слово — таблица перестаёт быть коллекцией.

Ограничитель частоты становится своим

Бюджет считается по адресу спрашивающего под корнем приложения. Объявленная приложению частота опроса карточки по-прежнему выводится из доли этого бюджета, а не из своей константы.

Что человек увидит иначе: включение нашего правила перестаёт вводить в действие чужие правила на чужой поверхности — их больше нет.

Панель не заменяется ничем, а возврат в работу делает подкоманда оснастки

Владелец сервиса теряет панель, и заменяющего её экрана это изменение не приносит. Единственное действие, которое он делал панелью и которое нельзя отложить до экранов, — возврат остановленной записи в работу — переезжает в набор инструментов разработчика, cmd/devtools, отдельной подкомандой. Она открывает базу того же каталога данных, зовёт домен и пишет событие журнала записи с происхождением «человек».

Причины две, и каждая своя.

Первая: возврат в работу — не одно поле. Сбросить надо признак остановки, признак захвата и срок его протухания, число отказов, паузу и время входа в рубеж; норму держит capability pipeline. Рука, забывшая любое из них, оставляет запись либо невидимой для захвата, либо останавливаемой снова первым же захватом — молча, без единой строки. Подкоманда колонок не пишет: правило исполняет домен, а она назначает порядок шагов.

Вторая: entity.EventOriginHuman теряет иначе единственного писателя — его писала панель. Происхождение события в журнале записи стало бы константой, и различие «это сделал конвейер» против «это сделал человек» перестало бы значить что-либо.

Что человек увидит иначе: вместо таблицы с полями у владельца одна команда с одним предметом. Экраны приносят отдельные задачи, и подкоманда живёт до них.

Рассмотрено и отвергнуто: правка строки в базе руками — то, чем возврат описывался до чекпоинта. Отвергнута: перечисленные выше поля человек за клавиатурой сбрасывает по памяти, а событие журнала записи не пишется вовсе.

Колонок location и source в новой схеме нет

Обе колонки заводятся сегодня, пишутся одним значением и не читаются никем.

location у сущности файла: туда пишется local, и второго значения (s3) не пишет ни один шаг. Ветвления по нему в коде нет.

source у аудиозаписи: всякий приём пишет туда api. Второе значение, telegram, держалось не потребителем, а ссылкой из применённого шага схемы — вход Telegram убран 2026-08-14, и константа осталась только потому, что применённый шаг не переписывается. Шаги уходят, и держать её больше нечем.

Ни одной колонки в новой схеме не заводится, и поля уходят из сущностей домена и из их отображения в строки базы. Цена возврата названа прямо: поле, у которого появится читатель — вторая копия в объектном хранилище либо второй вход, — вернётся одним новым шагом схемы, и платится это тогда, а не сейчас.

Колонки записи пишутся и читаются по имени

Отображение сущности в строку базы работает именованными параметрами запроса и сканированием по имени колонки, а не позиционными списками.

Причина в самой сущности: у аудиозаписи поля одного типа — необязательной строки — идут длинным непрерывным рядом, и ссылки на файл, на структуру реплик, на два вида текста и на попытку распознавания стоят в нём подряд. Позиционный список даёт сдвиг на одно поле, который компилируется молча и кладёт идентификатор файла в колонку текста. По имени такого сдвига не существует вовсе: лишнее имя или недостающее — отказ запроса, а не тихая подмена значения.

Инвариант проекта о колонках записи, правящихся в двух местах сразу, эта форма не снимает: колонку по-прежнему можно забыть в отображении или в шаге схемы. Она снимает другую поломку — ту, где колонка не забыта, а перепутана местом. Сторожа инварианта в internal/archrules переписываются под эту форму: сегодня они построены на динамической записи по имени колонки в API уходящей библиотеки.

Risks / Trade-offs

  • Владелец остаётся без панели, а экранов ещё нет → возврат остановленной записи в работу делает подкоманда cmd/devtools, а прочая правка ждёт экранов; сервис на стройке, живых записей нет, и цена ограничена этим окном.
  • Раскладка каталога данных меняется необратимо → цена нулевая сегодня и перестаёт быть нулевой после первой боевой записи; смена сделана до выкладки.
  • Отдача файла написана нами и может отдать чужое → правило одно: обработчик судит владельца записи, а чужая запись отвечает тем же, чем несуществующая; проверка на это стоит критерием приёмки.
  • Единственный писатель настроен неверно → отказы «база занята» под несколькими воркерами; ожидание и размер читающего пула заданы ключами [storage] busy_timeout_ms и [storage] read_connections и стоят в доме числовых настроек проекта.
  • Шаг схемы не накатился, а сервис поднялся → накат идёт до подъёма входов, и его отказ роняет старт; критерий приёмки требует чистого журнала до строки о готовности.
  • Два процесса накатывают схему на одном каталоге → блокировки под SQLite goose не даёт, и замок на файле берём сами; забытый замок виден только на чистой базе, которую заводят один раз, поэтому проверка на два одновременных наката стоит критерием приёмки.
  • Прежние адреса продолжают отвечать чем-то посторонним → пути хранилища и панели перестают быть корнями сервиса и подпадают под общее правило неизвестного пути; критерий приёмки проверяет и подменённый знак в адресе панели.
  • Норма отказа «узнан, но учётной записи нет» теряет свой единственный случай → ветвь снимается вместе с ним; вернуть её придётся задаче о личных токенах, и там же она получит свой случай.