Compare commits

...
83 Commits
Author SHA1 Message Date
av c1dab24de1 учёт: закрыта задача drop-dead-dotenv-loader, заведена drop-dead-entrypoint-script 2026-08-23 21:14:30 +03:00
av b01bdabc37 снят мёртвый godotenv.Load() вместе с зависимостью
- переменных окружения наш рабочий код больше не читает нигде: ушли вызов, импорт и предупреждение на старте
- у оставленной строки .env в .gitignore и у запрета forbidigo переписаны причины: прежние называли живым снесённого читателя
- в документы записано, что барьеры против .env стоят без читателя, и правило парности .gitignore с .dockerignore
2026-08-23 21:06:30 +03:00
av 1385dd3f5d раскладка av-dev поднята до версии 5: метка прогона ушла из процесса
- docs/review.md: подраздел «Триггеры метки» заменён на «Когда звать глубокое
  ревью» — областями кода и перечнем необратимых мест проекта
- дубли инвариантов и перечня необратимого сведены к ссылкам на CLAUDE.md, а в
  database.md убрано разошедшееся число мест правки колонки
- шапка раздела перестала называть самый поздний артефакт ревью: строка
  протухала каждым прогоном
2026-08-23 20:11:48 +03:00
av 404be2bdd5 storage: заголовок требования о выдаче файла говорит то же, что тело
- «Файл отдаётся ссылкой» осталось от снятого решения: тело требования прямо
  отрицает выдачу значения на предъявителя, право даёт узнавание при каждом
  обращении
- слово «ссылка» перестало нести в одном требовании три смысла: указатель в
  базе, значение доступа и путь к файлу в журнале
2026-08-23 16:58:30 +03:00
av 31ce520c5b документы: сведены расхождения, найденные сверкой канона
- один факт — один дом: рецепт локального входа, правило чтения
  X-Forwarded-For, уровень строки журнала и опись опор изъятия сведены к
  своим домам, копии заменены ссылками
- форма [auth.test_headers] выровнена по образцу конфига в семи местах;
  сценарии intake и archive перестали ссылаться на сессию, которой сервис
  не выдаёт
- поправлены протухшие факты: ключ объекта строит ULID, а не UUID; сверку
  адреса пира зовут трое, а не двое; обзор capability access знает о
  задаче 2026-08-23
2026-08-23 16:41:06 +03:00
av 11269c1567 учёт: заведён урожай ревью входа по конфигу
- пять задач партии review-2026-08-23: многозначность Remote-Name и Remote-Email,
  мелочи входа, механизация правила о зависимостях конфига, снятие мёртвого
  читателя .env, управляющие знаки в расширении
- две существующие разведки дополнены находками того же прогона: адрес объекта в
  тексте отказа SpeechKit и выводимость имени копии из журнала
2026-08-23 14:03:52 +03:00
av 0aa9b3a567 security.md: защита от многозначного заголовка описана точнее
Отбой двух значений закрывает только Remote-User; Remote-Email берётся первым
и закрепляется за чужой учётной записью навсегда. Путь построен ревью
2026-08-23, правило распространяется на всю тройку отдельной задачей.
2026-08-23 13:41:53 +03:00
av 52fe31319a локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
2026-08-23 13:12:47 +03:00
av 75c6f0168a учёт: закрыт переезд хранилища, заведён урожай его ревью
- storage-without-pocketbase закрыта как реализованная: приёмка сошлась по всем
  пяти критериям записи и по двенадцати приёмочным свойствам рубрики ревью
  дизайна, работа лежит коммитом c9b7765
- урожай триажа ревью развёрнут в одиннадцать записей с тегом партии
  review-2026-08-23 и расставлен по зависимости, а не в конец списка
- находка про признак живости воркера слита в stalled-pipeline-metric: у неё та
  же причина — вставший конвейер неотличим от простоя
2026-08-23 08:41:49 +03:00
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00
av 1edf8cb225 записан подход к проектированию: чистая архитектура и тактические шаблоны DDD
- architecture.md: раздел «Слои и модель домена» — таблица слоёв с колонкой «чего не знает», шаблоны со своим сегодняшним предметом в коде, запрет анемичной модели
- CLAUDE.md: раздел «Проектирование» с подходом и ссылкой на раздел архитектуры
- названо, что чистоту домена не держит ни одно правило archrules
2026-08-22 21:05:13 +03:00
av ad5b5e377f записано решение уйти с PocketBase на SQLite со своим каталогом файлов
- разведка storage-without-pocketbase: шесть ролей библиотеки в этом коде, отпавший довод перевода, шесть модулей достижимы только через неё
- ADR-2026-08-22-storage-without-pocketbase заменяет три записи: перевод в PocketBase, очередь коллекцией, файл за защищённым полем
- задача storage-without-pocketbase встала первой строкой плана стройки
2026-08-22 21:00:14 +03:00
av a8fb4793be закрыта задача trusted-header-login
- две записи, ссылавшиеся на убранный oidc-login, переписаны
- dev-run-task переведена с отдельного cmd/devadmin на подкоманду cmd/devtools
2026-08-22 20:25:13 +03:00
av 7f33c957e5 вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
2026-08-22 20:24:22 +03:00
av e4441f3c49 tasks: вход переезжает на заголовки прокси, пять задач про OIDC закрыты
- заведена `trusted-header-login` и поставлена в голову очереди
- пять задач про механику OIDC закрыты как отменённые ею
- `dev-run-task` и `api-tokens` переписаны под новый вход
2026-08-22 17:43:18 +03:00
av 2bb252e018 tasks: владельца панели заводит отдельная команда, а не ключ конфига
- admin-owner-from-config закрыта: решение владельца 2026-08-15, ключ конфига
  не заводится, требование спеки storage остаётся в силе
- dev-run-task вобрала cmd/devadmin и стоит первой строкой: инструмент и шаг
  порознь не нужны, поэтому едут одной задачей
2026-08-15 20:49:50 +03:00
av 97c6c7c440 tasks: заведены задачи про владельца панели из конфига и команду локального запуска
- admin-owner-from-config первой строкой: сервис заводит владельца панели
  по ключу конфига; задача отменяет требование спеки storage, и это названо
  в рамках
- dev-run-task следом: task dev поднимает сервис и заглушку разом, гасит
  оба по Ctrl+C; зависит от ключа конфига
2026-08-15 20:39:45 +03:00
av daa4c3b6e4 oidcstub: записано решение не заводить заглушке своих тестов
- решение владельца 2026-08-15: пакет в образ не едет, отказ громкий,
  а путь входа проверен подставным провайдером в тестах транспорта
2026-08-15 20:18:58 +03:00
av 91138dd39a точки входа переехали в cmd/, заведена заглушка OIDC для локального входа
- main.go и journal_route_test.go переехали в cmd/transcriber без правок
  содержимого; образ собирает ./cmd/transcriber поимённо
- cmd/oidcstub отвечает на /authorize, /token и /userinfo, проверок не делает
  и слушает петлевой адрес: войти без Authelia стало чем
- ступень сборки приложения переехала на node:24 с alpine — musl ждёт ответа
  на AAAA, которого нет, и npm ci висел вместо отказа
2026-08-15 20:15:57 +03:00
av 63a13404df заведена задача про сканер уязвимостей в зависимостях приложения 2026-08-15 19:02:36 +03:00
av 8c18abc24e закрыта задача spa-skeleton 2026-08-15 18:53:24 +03:00
av 663021f712 приложение собрано каркасом и вшито в бинарник
- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное
  вшивается через go:embed и раздаётся корневым маршрутом: разметка на
  неизвестном пути вне корней сервиса, отказ контракта внутри корня
- перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов,
  а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь
- шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка
  входят в гейт, а в Dockerfile появилась ступень приложения
2026-08-15 18:51:05 +03:00
av c6ffda9aac web-ui.md: ссылка на закрытую задачу заменена ссылкой на спеку archive 2026-08-15 13:54:12 +03:00
av a5bc322814 закрыта задача json-api-for-spa 2026-08-15 13:52:06 +03:00
av 3a2da3004b приём и чтение записей сведены к одному контракту приложения
- адреса приложения переехали в своё пространство `/app/`, опрос готовности
  убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи,
  текст — отдельным адресом названного вида
- заведена единая точка отображения доменной ошибки и слой, приводящий к той же
  форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением
- у записи появились имя файла отправителя, длительность и размер своими
  колонками, а у ленты владельца — свой индекс: без него страница сканировала
  весь архив сервиса
2026-08-15 13:51:23 +03:00
av 79ff12548f tasks: спроектирован контракт приложения и переставлена голова очереди
- json-api-for-spa: адреса приложения уехали в своё пространство /app/,
  приём стал POST /app/audiorecords, опрос /api/status/:id убран, заведены
  список, карточка, текст, /app/me и /app/config; имя файла отправителя
  легло своей колонкой рядом с заголовком
- три действия над записью — правка заголовка, возврат в работу и журнал
  событий — собраны задачей audiorecord-actions
- голова очереди: контракт, каркас, экран загрузки, список, действия
- в прежних задачах поправлены адреса, рубежи конвейера и остатки Telegram
2026-08-15 09:06:11 +03:00
av d88e56efcb claude.md: объявлена стадия стройки и выкладка с чистого листа
- на сервере данных нет и сервис остановлен, совместимость с ним не требуется
- запрет на боевой каталог и на переписанный шаг схемы этим не снимается
2026-08-15 08:12:18 +03:00
av b4b19db6e4 tasks: заведён урожай ревью remove-telegram-intake
- шесть записей по кластерам причин: журнал под внешним значением, нулевой
  код ответа в журнале, затирание вложения бедным ответом, открытый анониму
  адрес подтверждения почты, рубеж расшифровки без работы, пределы длительности
- находка про код 500 у отказа приёма дописана в json-api-for-spa: там живёт
  единая точка отображения доменной ошибки
- telegram-account-link и bot-api-only-through-bot-client оставлены с оговоркой,
  что предмета у них нет до возвращения входа
2026-08-15 07:46:16 +03:00
av 8f7c3a057a удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка
  входа при старте, секция настроек и зависимость go-telegram-bot-api; из
  конвейера ушла доставка ответа отправителю — исход виден опросом готовности.
  Колонки адресата и значение источника остались в схеме: применённые шаги не
  переписываются
- шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла;
  существующие строки он не проверяет, и это принято сознательно — искать их
  надо запросом до выкладки
- ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ
  распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала
  быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image
  до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
2026-08-15 07:24:35 +03:00
av 97ceb7bb69 заведена задача failure-verdict-vs-retry 2026-08-14 20:38:12 +03:00
av 9a964f2efc закрыта задача record-centric-model 2026-08-14 20:20:58 +03:00
av 1576d06735 внутренняя модель перестроена вокруг аудиозаписи
- audiorecords вместо transcribe_jobs: приложения (texts, structures,
  recognitions, record_events, topics) живут своими коллекциями, ссылки на
  исходник и на приведённую копию перестали переставляться
- рубеж называет достигнутое, отказ стал признаком остановки с причиной, а
  сторожей стало двое: число отказов и время в рубеже
- воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг
  выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
2026-08-14 20:20:33 +03:00
av d079f03350 заведена задача на перестройку модели вокруг аудиозаписи
- центральная сущность — аудиозапись: файлы, тексты, структура реплик и темы
  живут отдельными строками, поля очереди перестают соседствовать с содержимым,
  а провайдерское уезжает в свою таблицу
- конвейер становится цепочкой рубежей с остановкой признаком: рубеж не
  стирается, и запись перезапускается с места остановки
- воркеры теряют специализацию, их число задаётся конфигом
2026-08-14 16:46:40 +03:00
av a67cdee382 закрыта задача record-ownership 2026-08-14 12:19:19 +03:00
av 8af8ec2e54 у записи появился владелец: чужую больше не отдают
- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы
  `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и
  несуществующая дают один ответ; правило просмотра файлов сужено им же
- приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи
  пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный
  файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается
- удаление учётной записи с записями отвергается стражем, и вешает его сама
  сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
2026-08-14 12:18:11 +03:00
av b7d4660aef канон: раскладка повышена до версии 4
- слово «провенанс» снято из словаря проектных текстов: у числа теперь
  «происхождение», у вопроса и находки — «откуда»
- правлены форма вопроса в docs/review.md, шапка раздела об OIDC в
  docs/research/pocketbase.md и две записи задач; формулировки прошли
  вычитку агентами doc-wording и task-wording
- архив openspec/changes/archive/ не тронут: слово, верное на день записи,
  остаётся свидетельством
2026-08-14 09:51:01 +03:00
av 62b2829cba go: тулчейн поднят до 1.26.6
- директива `go` в go.mod — единственное место, где назван патч: шаг гейта
  сверяет мажор и минор, поэтому `golang:1.26-alpine` в Dockerfile и «Go 1.26»
  в CLAUDE.md и README.md остались верными
- директива toolchain не заведена: она стала бы пятым местом с версией и
  уронила бы сверку
- достижимых из кода уязвимостей у govulncheck больше нет; недостижимая
  GO-2026-5932 в golang.org/x/crypto/openpgp остаётся объявленной
2026-08-14 09:50:50 +03:00
av e660617ba0 закрыта задача config-example-toml 2026-08-14 09:32:24 +03:00
av ce0ae76977 config: образец переименован в config.example.toml
- имя приведено к конвенции, которая сама называла его расхождением;
  ссылки поправлены в README, CLAUDE.md, конвенциях, docs/review.md и двух
  записях задач
- в шапке docs/conventions/config.md заодно поправлен второй пункт перечня:
  проверки на старте у `[auth]` и `[telegram]` уже есть
- архив openspec/changes/archive/ не тронут: это запись о прошлом
2026-08-14 09:32:03 +03:00
av 312caf0fa3 tasks: тип login-url-from-collection-settings сменён на feature
- задача оказалась шире chore: у нормированного адреса входа появляется
  новый исход отказа, а обязательность проверочного кода PKCE переезжает
  в настройки провайдера в хранилище
- дальше она идёт сценарием решения, следующим прогоном
2026-08-14 08:45:44 +03:00
av cd57b68215 config: включение Telegram разведено с ключом доступа
- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет,
  файл без него негоден; bot_token стал только ключом доступа и при
  enabled = false не читается вовсе, а пустой при enabled = true роняет старт
- выключенный вход даёт подъём одним входом без единого обращения к Telegram и
  записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение
- отказ разбора файла настроек больше не пересказывает toml — её ParseError
  несёт в тексте разбираемое значение, и оборванная строка секретного ключа
  уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
2026-08-13 21:46:14 +03:00
av 903941f587 docs: точного числа накопленного в документах больше нет
- CLAUDE.md, «Язык»: ссылаться можно на конкретную запись или на весь корпус
  разом, но не на их количество — число протухает молча, машина его не считает.
  Изъятие названо: неизменное число и историческое в записи о прошлом остаются.
- Сняты счёты capability, прогонов ревью, типизированных ошибок, воркеров,
  сверок документов и правил линтера в docs/, спеке pipeline и CLAUDE.md.
- Заодно исправлено то, что этот же счёт и скрывал: типизированных ошибок три,
  а не две — LostAcquisitionError был потерян из перечня.
2026-08-13 19:23:00 +03:00
av 4c87220d90 docs: поправлен маркер канона и записано правило о живом прогоне
- architecture.md: маркер над таблицей компонентов ссылался на
  несуществующую capability delivery; теперь на intake, pipeline и storage,
  и названо непереехавшее — приём из Telegram и деление текста по словам.
- review.md: проход, поднявший сервис, обязан его остановить, а меряющий —
  убедиться, что отвечает его сборка. Цена правила уже заплачена: оставленный
  процесс держал порт, и замеры ушли к прежней сборке.
2026-08-13 19:15:57 +03:00
av edcf8ede70 закрыта задача local-run-without-telegram-token 2026-08-13 19:10:28 +03:00
av b733a84d6a telegram: сервис поднимается без бота и работает одним входом
- Клиент бота собирается один раз и достаётся отправителю и транспорту;
  разрез прошёл по «ответил ли Telegram»: ответ «такого бота нет» роняет
  старт, недоступность даёт подъём без Telegram (ADR-2026-08-13). Ожидание
  при сборке ограничено сроком — иначе молчащий Telegram вешал подъём.
- Недоставленный ответ не роняет шаг: пишется с job_id и считается метрикой,
  уровень по причине — WARN для неподнятого входа, ERROR для неназванного
  адресата. Заведены transcriber_intake_up и transcriber_undelivered_reply_count.
- Закрыта утечка токена в журнал: отказ разбора адреса рождается раньше
  обращения к клиенту, то есть мимо чистки на его границе.
2026-08-13 19:10:08 +03:00
av 863ba3b42e tasks: local-run-without-telegram-token переведена в feature
- Тип сменён с chore: подъём без токена бота меняет наблюдаемое поведение и
  требует нормы, которой в openspec/specs нет.
- В тело записан второй предмет работы — судьба задачи из Telegram, дошедшей до
  ответа при отсутствующем боте; он же стал четвёртым критерием приёмки.
2026-08-13 16:59:44 +03:00
av 220a4374b1 docs: раздел линтеров назван «Код проверок и подавления»
- Прежнее имя «Проверки о самих проверках» читалось против запрета в CLAUDE.md,
  хотя правила в нём — линтеры над кодом проверок, то есть тот же один уровень.
2026-08-13 16:52:52 +03:00
av ec136b50fb scripts: снесены проверки шага сверки версий Go
- Двадцать сценариев шага были единственной проверкой над проверкой в проекте;
  запрет CLAUDE.md остался без исключений.
- Ссылки на файл сняты в памятке, конвенции линтеров, журнале ревью и статусе
  ADR о спеке toolchain; норма шага живёт комментариями в самом скрипте.
2026-08-13 16:51:15 +03:00
av 54268b5933 CLAUDE.md: запрет на проверки над проверками
- Уровень проверки один: линтеры и тесты судят код сервиса, судить их самих
  проект не берётся; названы попавшие под запрет виды работ.
- Пересказ решения в go-linters.md и review.md заменён ссылкой на дом.
2026-08-13 16:47:54 +03:00
av 539ed926cb docs: сняты проверки над проверками
- Из «Любой узел» в review.md убраны три свойства о годности самих проверок:
  мутация теста, мутация оракула критерия, требование без сценария.
- Из go-linters.md снята «Лестница механизации», ссылки на неё переписаны
  в конвенциях, их индексе и журнале дефектов.
2026-08-13 16:45:12 +03:00
av cb65967389 tasks: закрыта задача rollback-does-not-undo-schema-step
- Отменена владельцем: работа целиком документационная, факт об откате
  и применённом шаге схемы остаётся неназванным.
2026-08-13 16:40:09 +03:00
av 26256cdb06 openspec: упразднена спека toolchain
- Инструментарий проекта спеками не нормируется: capability toolchain удалена,
  норму шага сверки версий Go держат его проверки в scripts.
- Перечень capability в архитектуре и ревью сокращён до четырёх, решение
  ADR-2026-08-12-spec-norms-build-toolchain помечено устаревшим.
2026-08-13 16:36:22 +03:00
av 6994feec55 tasks: закрыты три задачи о проверках над проверками
- Отменены migrations-step-norm-and-tests, gate-steps-subject-guard и
  review-config-from-go-upgrade: много механики, мало пользы.
- go-linters.md больше не числит отсутствие проверок шага migrations долгом —
  это решение, а не незакрытая работа.
2026-08-13 16:31:39 +03:00
av 3ffb5109a7 tasks: закрыта задача gate-changed-lines-coverage
- Владелец отменил механизацию покрытия изменённых функций 2026-08-13.
- Причина и дата уехали в REJECTED.md; в коде и документах ничего не менялось.
2026-08-13 16:29:00 +03:00
av f7a8a1df9d tasks: восемь записей интейка получили место в плане стройки
- migrations-step-norm-and-tests, gate-steps-subject-guard и
  review-config-from-go-upgrade уехали в голову: слой проверок, которым верят
  все задачи ниже;
- rollback-restores-wrong-session-duration встал рядом с соседом про откат,
  bot-api-only-through-bot-client — рядом с чисткой транспорта,
  pin-runtime-image-base — перед spa-skeleton, откуда начинаются пересборки;
- pipeline-spec-purpose-drift закрыта реализованной, а
  docs-consistency-2026-08-13 переписана под пять оставшихся находок: шестую
  свело повышение раскладки.
2026-08-13 16:11:58 +03:00
av 2c12376262 docs: ссылки на упразднённый роадмап переадресованы, у восьми фактов назван дом
- ссылки на tasks/ROADMAP.md переведены на BACKLOG.md и на openspec/specs,
  упоминания целей — на задачи, которые эту работу делают;
- судьи документации нашли восемь расхождений: Purpose спеки pipeline объявлял
  неописанным то, что уже нормирован пятью требованиями, вид времени в
  конвенции спорил со схемой, а квоты, шесть часов и отказ от Web Push жили
  сразу в двух документах без ссылки друг на друга;
- два числа получили провенанс: 259 200 запросов в сутки и потолок в шесть
  часов теперь ведут к записке разведки, а не читаются как замер.
2026-08-13 16:00:31 +03:00
av 5501384cdc tasks: упразднены цели и роадмап, объявлена стадия build
- 11 записей типа goal закрыты с причиной, называющей задачи-наследники;
  ROADMAP.md удалён, индекс остался один — BACKLOG.md;
- 33 записи переписаны: ссылка «Двигает пункты N «Завершения» цели» уступила
  место прямому утверждению — без целей номера пунктов вели в никуда;
- шапка BACKLOG.md размечена парой <!-- стадия -->, порядок строк теперь
  объявлен зависимостью, а не важностью.
2026-08-13 16:00:14 +03:00
av 00148bcfb5 Taskfile.yml: поправлен путь к docs.py после переименования скилла
Каталог скилла зовётся skills/canon, а переменная вела в skills/doc-canon:
шаг гейта скрипт не находил и краснел кодом 3, что читалось как отсутствие
плагина, — раскладку документов при этом не проверял никто.
2026-08-13 15:59:57 +03:00
av 32949e7b01 chore: список включённых плагинов Claude Code уехал в репозиторий
- .claude/settings.json объявляет av-dev и av-dev-git включёнными,
  так что скиллы канона и коммитов поднимаются вместе с проектом
2026-08-13 12:40:24 +03:00
av b76f2d7c7e docs: раскладка переехала в .av-dev.toml, а расхождения документов сведены
- Перевод на канон 1 доделан: адреса служебного файла и имена скиллов
  переставлены в девяти местах прозы и кода, гейт зовёт три скрипта по новым
  путям, прежние docs/.docs.json и tasks/.tasks.json удалены.
- Сверка двумя агентами нашла четырнадцать расхождений, тринадцать сведены
  строками: число прогонов ревью и преамбула журнала дефектов, счёт capability,
  маршруты README, дубли инварианта захвата и кодов прогона, протухшие указатели
  записок разведки, маркер долга на переехавшем абзаце. Срок жизни сессии
  нормирует спека access, database.md на неё ссылается.
- Purpose спеки pipeline объявляет неописанным то, что в ней же и стоит; правка
  идёт изменением openspec, поэтому заведена задача pipeline-spec-purpose-drift.
2026-08-13 12:36:36 +03:00
av eacaf76d5f tasks: заведён остаток работы о гейте, контексте и токене
- четыре новые записи: проверить шаг migrations так же, как шаг сверки версий Go;
  свести шесть расхождений между документами канона; запретить обращаться к Bot
  API мимо клиента бота; разведка о шагах гейта, теряющих предмет
- context-cancel-in-pipeline приведена к правде: дописан перечень сделанного
  попутно, критерий с оракулом «тест на трёх прерываниях подряд» разбит надвое —
  проверена была только его узкая половина
2026-08-13 10:55:38 +03:00
av f4d8c7ed50 docs: мутация, не собравшаяся, — не мутация
- порядок заведения правила пополнен строкой: правка, снявшая последнее
  употребление импорта, роняет сборку, а не проверку, и её вывод легко принять
  за сработавшую мутацию. Случай был в этом же сеансе — проба приёма по HTTP
2026-08-13 10:29:17 +03:00
av bb9a67929c docs: правила о контексте и о токене записаны, две находки — в журнал
- в go-linters.md заведён раздел «Отмена и внешний собеседник», перечень
  механизированного пополнен девятью правилами и двумя шагами гейта, названы
  остатки: contextcheck не видит сигнатуру без контекста вовсе, шаг migrations
  судит только шаги, бывшие в базе диффа, rowserrcheck и sqlclosecheck
  профилактические — предмета в коде нет
- в logging.md и security.md чистка отказа Telegram описана по факту: точка одна
  и лежит на границе клиента, закрыты все пять путей вместе с логгером самой
  библиотеки. Прежнее «*Расхождение:* вычистки нет... она не логируется» было
  неверным дважды
- в журнал дефектов записаны две находки: отказ скачивания уносил токен бота
  (проскочил, жил с самого начала) и остановка сервиса хоронила конвертируемую
  запись в failed (поймано ревью до коммита)
- вопрос темы operations про отмену переформулирован: спрашивать надо не
  «доходит ли контекст», а «что шаг делает с задачей, деньгами и ответом
  отправителю»; вопрос про таймаут оставлен с оговоркой, что проброс контекста
  на него не отвечает
- в памятке: словарь кодов новых шагов, требование компилятора C у детектора
  гонок и оговорка, что «CGO не нужен» относится к сборке, а не к гейту
2026-08-13 10:28:31 +03:00
av bc5c35790e Гейт ищет гонки, переписанные шаги схемы и девять новых классов дефектов
- включены noctx, contextcheck и bodyclose (отмена доходит до внешнего вызова,
  контекст приезжает сверху, тело ответа закрывается), nilerr, rowserrcheck и
  sqlclosecheck (отказ не теряется молча), testifylint и nolintlint (форма
  утверждения и форма подавления), плюс errcheck check-type-assertions:
  непроверенное приведение типа паникует, и check-blank его не видит
- заведён шаг tests: go test -race, потому что «результат пишет только держатель
  захвата» — утверждение об одновременности. Без компилятора C шаг гоняет тесты
  без детектора и краснеет кодом 3 после них: гонки не повод отнимать у гейта
  сами тесты
- заведён шаг migrations: у файла шага схемы допустим один статус — A. Баз диффа
  две, BASE и HEAD: первая отвечает на «уже уехал» настолько, насколько свежа
  origin/master, вторая ловит правку закоммиченного шага независимо от неё.
  Каталог берётся из docs/.docs.json, пустой каталог роняет шаг
- единственное подавление — noctx на httptest.NewRequest в проверках: за
  фикстурой запроса внешнего собеседника нет. Граница проверена мутацией —
  http.Get и exec.Command из проверки правилу по-прежнему подсудны
2026-08-13 10:28:12 +03:00
av f494dcb83e Отмена доходит до внешнего собеседника, а токен не покидает единой точки
- контекст проложен от воркера и обоих входов до внешних вызовов: ffmpeg и
  ffprobe заводятся через exec.CommandContext, SpeechKit и Object Storage
  принимают ctx вместо context.Background, скачивание записи идёт запросом с
  контекстом. Прежде остановка сервиса не доходила до чужой работы вовсе
- прерванный шаг приговора не выносит: убитый по контексту ffmpeg отдаёт
  «signal: killed», от настоящего отказа неотличимо ни типом, ни errors.Is, и
  различает их только ctx.Err(). Задача остаётся на повтор, попытку не тратит и
  отправителю о несуществующем сбое не сообщает; воркер не считает остановку
  отказом, а задача не забирается вовсе, если нас уже остановили
- клиента Bot API заводит единая точка internal/adapter/telegram: токен стоит в
  пути каждого обращения, а http.Client кладёт адрес в *url.Error целиком.
  Чистка на месте употребления закрывала один вызов из пяти — теперь свой Do
  чистит отказ, подменённый логгер вычищает токен из строк самой библиотеки, а
  транспорт бота токена не получает вовсе
- принятие операции распознавания защищено от отмены своим пределом: SpeechKit
  мог её принять и начать считать деньги, а потерянный идентификатор заставил
  бы повтор оплатить ту же запись второй раз
- приём по HTTP доводит запись до задачи независимо от отправителя: на
  контексте запроса один обрыв соединения терял полностью загруженную запись
- ответ Telegram с не-2xx кодом больше не становится записью: прежде тело
  отказа доезжало до хранилища и умирало на ffprobe, уводя диагностику
2026-08-13 10:27:54 +03:00
av d8d6bcc193 docs: линтеры и механизация переехали записью конвенций go-linters.md
- документ docs/autotests.md снят: перечень правил, подавлений и лестница
  механизации — это конвенция о том, чем машина читает код, и место ей среди
  прочих записей
- в шапке названо, чего в записи нет: как писать тесты. Свойства, которые обязан
  проверять тест, остаются в review.md, «Типовые узлы»
- ссылки переставлены в памятке, индексе конвенций, четырёх записях, review.md,
  .golangci.yml, lefthook.yml и пакете сканеров
2026-08-13 09:11:49 +03:00
av 8bcd2c0059 tasks: закрыта задача gate-extra-linters
- shellcheck, hadolint и мутационный тест скрипта сверки версий заведены, набор
  предкоммитных проверок назван в памятке
2026-08-13 09:02:31 +03:00
av 37ccda3677 docs: autotests.md стал переносимым документом об автопроверках
- лестница механизации из пяти ступеней, два круга проверок, перечень правил
  таблицами по родам, перечень подавлений с причинами, порядок заведения
  правила; названы остатки правил и отклонённые подъёмы
- утверждения «Механизировано» в четырёх записях конвенций и единые точки в
  architecture.md приведены к сегодняшнему состоянию; изъятие «транспорт знает
  адаптер хранилища» названо строкой
- в журнал дефектов записаны две находки: узнавание конца потока по тексту и
  правило гейта, обходимое одной лишней строкой
2026-08-13 09:02:19 +03:00
av e1dfe662ea Гейт видит скрипты и Dockerfile, а pre-commit — затронутые файлы
- шаги shell (shellcheck) и dockerfile (hadolint) заведены; два правила hadolint
  подавлены поимённо с причиной — DL3007 до задачи pin-runtime-image-base и
  DL3018 по существу
- lefthook гоняет на затронутых файлах gofmt, golangci-lint по их каталогам,
  shellcheck, hadolint и gitleaks — около секунды; полный набор в pre-commit не
  переносится намеренно
- заведён мутационный тест скрипта сверки версий: 20 сценариев спеки toolchain
  плюс требование сообщения называть все четыре места, независимость исхода от
  установленного go и запрет звать go, docker и сеть
2026-08-13 09:02:05 +03:00
av 0353517ec4 Линтеры и сканеры взяли на себя то, что было прозой конвенций
- включены sloglint, misspell, depguard; forbidigo получил запреты на чтение
  времени, окружения и вывод в stdout — каждый по свойству, а не по одному
  имени: запрет на os.Getenv без соседей обходится os.LookupEnv
- заведён internal/archrules — тесты-сканеры: направление зависимостей между
  ядром, транспортами и адаптерами, узнавание ошибки по тексту в шести формах,
  и согласованность колонок очереди во всех четырёх местах плюс шаг схемы
- каждое правило проверено мутацией, каждое исключение объявлено с причиной
2026-08-13 09:01:52 +03:00
av 1d243ad2f6 Время читается единой точкой, а конец потока узнаётся не по тексту
- заведён internal/clock: Now даёт метку в UTC, Start — начало измерения
  длительности с монотонными часами; девять мест рабочего кода и запрос захвата
  переведены на него, долг «время time.Now() по месту» закрыт
- клиент SpeechKit узнавал конец потока сравнением err.Error() == "EOF": отказ
  с тем же текстом вернул бы усечённую расшифровку как готовую, теперь
  errors.Is(err, io.EOF)
- починены находки новых линтеров: две опечатки, два slog.DiscardHandler,
  четыре неэкранированные подстановки в docker/entrypoint.sh
2026-08-13 09:01:36 +03:00
av 6c7f006e75 docs: инструменты гейта переехали в свой дом — docs/autotests.md
- перечень «Механизировано» и то, что осталось прозой, снято из конвенций: они
  про то, как писать код, а не про инструменты, которые его читают
- новый документ — дом темы ревью autotests, с границами: семантика гейта
  остаётся в CLAUDE.md, журнал дефектов и вопросы по темам — в review.md
- вопрос ревью о суждении по готовому ответу сужен до того, что машина не
  проверяет: до ответа мимо recorder
2026-08-13 07:35:58 +03:00
av 8bffd30955 tasks: закрыты четыре задачи о гейте, заведён урожай ревью
- сняты fix-migrations-path-in-docs-config, gate-step-exit-codes,
  response-assertions-judge-result, gate-dependency-vulnerabilities
- заведена rollback-restores-wrong-session-duration: откат шага входа ставит
  14 суток, называя их умолчанием библиотеки в пять
2026-08-12 22:06:11 +03:00
av 3925c637f3 Гейт ловит достижимые уязвимости в зависимостях
- шаг vulns гоняет govulncheck последним: ему одному нужна сеть, и он самый
  долгий; отсутствие инструмента даёт код окружения, а не пропуск
- закрыты обе достижимые находки — grpc до 1.82.1, aws-sdk-go-v2/service/s3 до
  1.97.3 с eventstream 1.7.8; прогон на реальных ключах Yandex не делался
- место шага названо в семантике гейта: он судит достижимость из кода, и
  недостижимая GO-2026-5932 в golang.org/x/crypto/openpgp его не роняет
2026-08-12 22:04:53 +03:00
av 35bde75b1f Гейт роняет проверку, судящую ответ по живой карте заголовков
- forbidigo с analyze-types запрещает в файлах проверок обращение к
  httptest.ResponseRecorder.Header и .HeaderMap: правило судит по типу
  приёмника, поэтому ловит и цепочку, и переменную, и индекс, и обход
- класс стоил трёх зелёных гейтов при неработающем коде; правило записано
  строкой в перечне механизированного, прозой не дублируется
- поправлены два утверждения «механизировано: ничего», разошедшиеся с
  включённым линтером и со сверкой шага схемы
2026-08-12 22:04:35 +03:00
av 870b6bc829 Обёртки шагов гейта отдают код окружения, а не код дрейфа
- недостающий скрипт у всех четырёх обёрток в Taskfile.yml даёт 3, как обещает
  словарь: прежний 1 значил «дрейф» и отправлял искать разъехавшееся там, где
  просто неполно дерево
- в словарь кодов CLAUDE.md добавлены сами обёртки и оговорка про 201: этим
  кодом task отдаёт наружу любой отказ шага, а код шага печатает строкой
2026-08-12 22:04:06 +03:00
av 4a052ec99b Шаги схемы вынесены в свой каталог, и сверка их снова видит
- шаги PocketBase переехали из файла в пакет
  internal/adapter/repo/pocketbase/migrations, файл на шаг с именем
  зарегистрированного шага; туда же имена коллекций, срок сессии — в provider.go
- ключ migrations в docs/.docs.json наведён на этот каталог: прежнее значение
  указывало на несуществующий migrations/, и шаг гейта проходил зелёным при
  всякой правке схемы
- app.go подключает пакет шагов явным пустым импортом: пропавшая ссылка на
  константы унесла бы регистрацию, и хранилище поднялось бы без коллекций
2026-08-12 22:03:48 +03:00
av b46be019fc docs: канон приведён к сегодняшнему состоянию после сверки
- периметр: passport.md и security.md больше не утверждают, что HTTP API
  открыт без аутентификации, а review.md не числит эту находку типовой
  ложноположительной — приём, опрос и файл закрыты сессией с 2026-08-12;
- logging.md писал, что расширение попадает в журнал полем пути: описано
  изъятие инварианта приватности — собственное поле, имени и пути нет;
- узел ревью переименован в repo/pocketbase, поведение конвейера из обзора
  уехало ссылкой в спеку pipeline, Purpose спеки storage написан вместо
  заглушки, в ADR о переезде дописано уточнение о действующей раскладке.
2026-08-12 21:13:06 +03:00
av 8739b18a9f tasks: очередь расставлена от базы к деталям
- порядок беклога и роадмапа назначен слоями: проверки, которым можно
  верить → долги входа → владелец записи и контракт API → конвейер под
  тестами → приложение и возможности поверх; у каждого движения записана
  причина;
- заведены восемь задач под пункты «Завершения», которых не закрывала ни
  одна запись, — цель any-audio-source была без задач вовсе;
- у четырёх задач сняты критерии, требовавшие того, что делает задача ниже
  по очереди; исправлены ссылки на несуществующий repo/sqlite и на
  отменённую разведку об очереди.
2026-08-12 20:48:53 +03:00
av ddc34b3182 tasks: закрыта задача oidc-login, заведён урожай ревью 2026-08-12 18:10:59 +03:00
av c44f0e7582 HTTP API закрыт за вход через OIDC у Authelia
- шаг схемы закрывает поверхность, которую хранилище приносит открытой:
  собственную регистрацию, вход по паролю и одноразовый код — без этого
  закрытие приёма обходилось двумя запросами
- продление сессии выключено, срок семь суток: иначе отзыв доступа у
  провайдера до сервиса не доходит никогда
- файл записи отдаётся вошедшему по токену файла — пересмотр
  ADR-2026-08-12-file-link-open-but-not-logged
2026-08-12 17:44:22 +03:00
av d676df8a27 tasks: закрыта задача go-1-26-upgrade, заведён урожай ревью
- четыре задачи из находок ревью и заметки владельца: проверки shellcheck и
  hadolint с тестом скрипта сверки версий, закрепление рантайм-базы образа,
  коды выхода шагов гейта, настройка конвейера ревью
- INBOX разобран целиком и очищен
2026-08-12 14:26:07 +03:00
av 09228f23d8 Go обновлён до 1.26, а расхождение версий теперь роняет гейт
- шаг go-version в task gate сверяет объявленную версию в go.mod, Dockerfile,
  CLAUDE.md и README.md; судит по репозиторию, go не зовёт, docker и сети не
  требует
- заведена capability toolchain: до сих пор спеки нормировали только поведение
  сервиса, теперь и инструмент сборки. Причина и цена — в двух ADR
- закрыт дефект 2026-08-12: образ на golang:1.24-alpine разошёлся с go.mod и
  перестал собираться, а восемь шагов гейта и шесть проходов ревью были зелёными
2026-08-12 10:57:50 +03:00
378 changed files with 48905 additions and 5684 deletions
+13
View File
@@ -0,0 +1,13 @@
# Раскладка av-dev в этом проекте: версия и настройки проверок.
# Файл ведут скиллы плагина, править руками можно — комментарии свои.
version = 5 # версия раскладки; обратной совместимости нет, есть «приведён» и «нет»
[docs]
# каталог миграций: по нему docs.py сверяет схему с database.md
migrations = "internal/adapter/repo/sqlite/migrations"
[tasks]
# каталог задач от корня репозитория; имена частей — умолчания скрипта
dir = "tasks"
stage = "build"
+6
View File
@@ -0,0 +1,6 @@
{
"enabledPlugins": {
"av-dev@av-dev-skills": true,
"av-dev-git@av-dev-skills": true
}
}
+30
View File
@@ -0,0 +1,30 @@
# Что не уезжает в контекст сборки образа.
#
# Файл заведён не ради веса: без него `COPY web/ ./` кладёт каталог зависимостей
# с машины собирающего **поверх** дерева, поставленного `npm ci` в контейнере, и
# ступень собирает приложение из того, что лежит у него, а не из файла замка.
# Сборка при этом зелёная — расхождение молчаливое.
#
# `.gitignore` этого не закрывает: docker его не читает.
# Зависимости и собранное приложение — ставит и собирает сама ступень образа.
web/node_modules/
web/embed/dist/
web/*.tsbuildinfo
# Боевые и локальные данные: каталог записей и база того, кто запускал сервис
# у себя. В образе им делать нечего.
data/
# Настройки с секретами. Образ берёт конфиг на сервере, а не из дерева.
config.toml
.env
# История репозитория: в слой сборки не нужна.
.git/
.gitignore
# Каталоги процесса, а не сборки.
openspec/
tasks/
docs/
+16 -9
View File
@@ -20,14 +20,9 @@ transcriber
# Go workspace file # Go workspace file
go.work go.work
# Database files # Каталог данных: файл базы, её журнал упреждающей записи, замок наката схемы и
data/transcriber.db # подкаталоги с файлами записей. Раскладку задаёт сервис.
data/transcriber.db-shm data/
data/transcriber.db-wal
# Uploaded files
data/files/*
!data/files/.gitkeep
# IDE files # IDE files
.vscode/ .vscode/
@@ -51,7 +46,19 @@ Thumbs.db
# Config files # Config files
config.toml config.toml
# Переменные окружения: сервис их не читает, настройки приезжают из TOML.
# Строка стоит против того, чтобы секрет завёлся здесь руками: из этого файла
# он попадает в git тем же способом, каким попал бы из конфига.
.env
# Sample and test audio files # Sample and test audio files
*.m4a *.m4a
*.mp3 *.mp3
*.ogg *.ogg
# Приложение: зависимости и собранное. Метка `web/embed/.gitkeep` остаётся в
# git — без неё `go build ./...` отказывает у того, кто приложение не собирал.
web/node_modules/
web/embed/dist/
# Слепок проверки типов: его пишет сборка, и в git он значил бы «собрано у меня».
web/*.tsbuildinfo
+190 -2
View File
@@ -1,15 +1,152 @@
# Линтеры проекта. Перечень правил и их дома — docs/conventions/go-linters.md,
# «Механизировано»; здесь только настройка и «почему именно так».
#
# Базовый набор v2 (`default: standard`) — errcheck, govet, ineffassign,
# staticcheck, unused. Сверх него включено то, что механизирует конвенции: то,
# что проверяет правило, прозой в конвенциях не остаётся.
version: "2" version: "2"
linters: linters:
default: standard default: standard
enable: enable:
# docs/conventions/errors.md: сравнение ошибок через errors.Is и errors.As.
- errorlint - errorlint
# docs/conventions/errors.md: ошибки — только stdlib.
- depguard
# docs/conventions/logging.md: форма вызова slog.
- sloglint
# Опечатка в комментарии и в тексте ошибки читается как термин проекта.
- misspell
# Запреты по месту: чем судят ответ в проверках, чем читают время, откуда
# берут конфигурацию, куда пишут вывод. Подробности у каждого правила ниже.
- forbidigo
# Отмена доходит до внешнего вызова: запрос и внешний процесс заводятся с
# контекстом. Инвариант «принятая запись не теряется молча» держится
# остановкой на середине, а не только записью в лог: `ffmpeg`, заведённый
# без контекста, переживает остановку воркера и дожёвывает чужую запись.
- noctx
# Контекст приезжает сверху, а не заводится по месту. `context.Background()`
# внутри адаптера обрывает цепочку отмены ровно на границе с платным
# внешним сервисом — там, где отмена и нужна.
- contextcheck
# Тело ответа закрывается. `errcheck` его не видит: `(io.ReadCloser).Close`
# объявлен в `exclude-functions` ниже, и незакрытое тело от невыясненного
# `Close` этим списком не отличается.
- bodyclose
# `return nil` после проверенной ошибки — это молчаливая потеря отказа,
# прямо запрещённая инвариантом об очереди (CLAUDE.md, major).
- nilerr
# Отказ выборки не теряется: неспрошенный `rows.Err()` превращает оборванное
# чтение в пустой результат.
- rowserrcheck
# `Rows` и `Stmt` закрываются: незакрытая выборка держит соединение.
- sqlclosecheck
# Форма утверждений в проверках: перепутанные местами «ожидалось/получено»,
# `assert` там, где после провала продолжать нельзя, `require` из горутины.
- testifylint
# Подавление — это решение: строчное `//nolint` обязано называть линтер и
# причину, а протухшее подавление обязано краснеть. Тот же порядок, что у
# подавлений в этом файле, но применённый к комментариям в коде.
- nolintlint
settings: settings:
forbidigo:
# `analyze-types` включает суждение по типу приёмника, а не по печатному
# тексту вызова. Правилу о заголовках это необходимо (см. ниже), прочим
# правилам не мешает: имена пакетов в шаблонах те же.
analyze-types: true
forbid:
# Вывод идёт в журнал: строка в stdout мимо slog не имеет ни уровня, ни
# полей, и в разборе постфактум её не найти. Встроенные `print`/`println`
# названы тем же правилом: запрет на одно имя обходится соседним.
- pattern: '^fmt\.Print.*$'
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
- pattern: '^print(ln)?$'
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
# Конфигурация приезжает из TOML. Перечислены все способы прочитать
# окружение, а не один: `os.Getenv` без соседей обходится `os.LookupEnv`
# одной правкой. Наш рабочий код окружение не читает вовсе — это
# правило и держит.
#
# Чего правило не ловит: `fmt.Fprintln(os.Stdout, …)` и
# `os.Stdout.WriteString` — первый аргумент по имени функции не судится.
# Этот остаток назван прозой в docs/conventions/logging.md.
- pattern: '^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$'
msg: 'конфигурация только из TOML (docs/conventions/config.md)'
# Единая точка чтения времени — internal/clock: метка времени в UTC
# (`clock.Now`), измерение длительности с монотонными часами
# (`clock.Start`). Прежде время брали по месту, и хранилище сравнивало
# строками времена из разных зон.
- pattern: '^time\.Now$'
msg: 'время читают clock.Now (метка) и clock.Start (длительность) — docs/conventions/database.md'
# Проверка ответа судит по **готовому ответу**, а не по изменяемому
# состоянию обработчика. `httptest` устроен зеркально настоящему серверу:
# `Header()` отдаёт живую карту, доступную и после записи ответа, а
# снимок, который получит клиент, лежит отдельно и читается через
# `Result()`. Проверка, читающая живую карту, зелена при неработающем
# коде — класс всплывал трижды (docs/review.md, записи 2026-08-10,
# 2026-08-11 и 2026-08-12) и трижды стоил зелёного гейта.
#
# Правило судит по типу приёмника, и в этом весь смысл: запрет на
# цепочку `w.Header().Get` обходится одной лишней строкой —
# `h := w.Header()`, — а также чтением по индексу карты и обходом
# `range`. По типу под правило попадают все эти формы разом. Текстом его
# записать нельзя ещё и потому, что `.Header` носят и запрос
# (`req.Header.Set` в проверках законен), и снимок ответа
# (`w.Result().Header` — как раз то, к чему правило ведёт).
#
# Приёмник назван поимённо: подставной сервер в проверках отдаёт
# заголовок через `w.Header().Set`, но у него приёмник —
# `http.ResponseWriter`, и под правило он не попадает.
- pattern: '^httptest\.ResponseRecorder\.Header$'
msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header'
# `HeaderMap` — тот же живой снимок прежним именем поля. Правило второе,
# потому что об устарелости поля говорит `staticcheck` (SA1019), а о том,
# почему по нему не судят ответ, — только это сообщение.
- pattern: '^httptest\.ResponseRecorder\.HeaderMap$'
msg: 'проверка судит ответ по живой карте заголовков: читай w.Result().Header'
sloglint:
# Стиль вызова один — пары «ключ-значение». `kv-only` запрещает атрибуты
# (`slog.String` и прочие) **целиком**, а не только смешение с парами:
# смешение и так запрещено умолчанием `no-mixed-args`. Решение осознанное —
# один стиль на весь код, — и записано строкой в
# docs/conventions/logging.md, «Сообщение».
no-mixed-args: true
kv-only: true
# `msg` — константа: сообщение с подставленным значением не сгруппировать
# отбором, а данные для этого и кладут в поля.
static-msg: true
# `key-naming-case` не включаем: словарь полей намеренно смешанный —
# доменные поля `snake_case`, системные домены с точкой (`http.method`,
# `ext.service`). См. docs/conventions/logging.md, «Поля: словарь имён».
depguard:
rules:
main:
deny:
- pkg: github.com/pkg/errors
desc: 'ошибки — только stdlib errors и fmt.Errorf (docs/conventions/errors.md)'
- pkg: github.com/cockroachdb/errors
desc: 'стек-трейс избыточен, контекст несёт цепочка %w (docs/conventions/errors.md)'
nolintlint:
# Подавление без причины снимают при первом же неудобстве: снимающий не
# знает, что оно ловило. Те же два требования, что у подавлений в этом
# файле, — имя линтера и причина строкой.
require-explanation: true
require-specific: true
# Подавление, которому нечего подавлять, — след починенного места, и
# краснеть оно обязано: иначе перечень подавлений врёт.
allow-unused: false
errcheck: errcheck:
# Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не # Без этого `_ = x.Close()` снимает замечание, и критерий «отказ не
# теряется молча» принимается реализацией, которая его теряет. Отказ, # теряется молча» принимается реализацией, которая его теряет. Отказ,
# который решено не проверять, теперь объявляют ниже поимённо — заметно. # который решено не проверять, теперь объявляют ниже поимённо — заметно.
check-blank: true check-blank: true
# Непроверенное приведение типа паникует, а не отдаёт ошибку, поэтому
# `check-blank` его не ловит: `v := x.(T)` вовсе не про присваивание в `_`.
check-type-assertions: true
exclude-functions: exclude-functions:
# Закрытие через defer и лучшая-попытка уборки файла — осознанно без проверки # Закрытие через defer и лучшая-попытка уборки файла — осознанно без проверки
- (io.Closer).Close - (io.Closer).Close
@@ -17,8 +154,59 @@ linters:
- (*os.File).Close - (*os.File).Close
- (io.ReadCloser).Close - (io.ReadCloser).Close
- os.Remove - os.Remove
# Метод сам логирует ошибку отправки, вызывающему она не нужна # Закрытие выборки отложенным вызовом: строки к этому моменту прочитаны,
- (*git.vakhrushev.me/av/transcriber/internal/controller/tg.TelegramController).send # а их отказ уже спрошен у `rows.Err()` — отдельного смысла у отказа
# закрытия нет.
- (*database/sql.Rows).Close
# Откат транзакции отложенным вызовом. Успешно завершённая транзакция
# отвечает на него «уже закончена», и проверка этого отказа означала бы
# разбор штатного исхода.
- (*database/sql.Tx).Rollback
# Запись тела ответа. Отказ здесь значит оборванное соединение, и
# сказать о нём некому: код ответа уже ушёл, а строка о каждом закрытом
# браузере наполняла бы журнал ничем.
- (*encoding/json.Encoder).Encode
- (net/http.ResponseWriter).Write
exclusions:
rules:
# Правило о заголовках живёт только в файлах проверок: в рабочем коде
# `Header()` и есть способ отдать заголовок.
- linters:
- forbidigo
path-except: '_test\.go$'
text: 'живой карте заголовков'
# Единая точка чтения времени сама читает время — иначе ей нечем.
- linters:
- forbidigo
path: 'internal/clock/'
text: 'time.Now'
# Проверка читает окружение **своего прогона** — `PATH`, чтобы убрать из
# него каталог с `go`, и `os.Environ()`, чтобы передать окружение дочернему
# процессу. Настройками приложения это не является. Исключение объявлено по
# тексту сообщения, а не по имени функции: правило называет четыре имени, и
# исключение обязано покрывать те же четыре.
- linters:
- forbidigo
path: '_test\.go$'
text: 'конфигурация только из TOML'
# Проверки строят время фикстур, а не метку домена: `time.Now` в них не
# обходит единую точку, а задаёт вход. Запрет здесь стоил бы обязательного
# обряда на каждый срок захвата в фикстуре и не поймал бы ничего.
- linters:
- forbidigo
path: '_test\.go$'
text: 'time.Now'
# `httptest.NewRequest` строит фикстуру для обработчика в том же процессе:
# внешнего собеседника за ней нет, и отменять у неё нечего — правило здесь
# говорит не о том, что мы имели в виду. Изъятие названо по имени этой
# функции, а не выключением `noctx` на проверках целиком: настоящий внешний
# вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему
# подсуден.
- linters:
- noctx
path: '_test\.go$'
text: 'httptest\.NewRequest'
formatters: formatters:
enable: enable:
+228 -53
View File
@@ -9,11 +9,13 @@
## Что это ## Что это
Сервис расшифровки аудио в текст. Принимает запись двумя входами — Telegram-бот и Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, —
HTTP API, — конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex
Yandex SpeechKit и возвращает текст туда, откуда пришла запись. Состояние задач, SpeechKit и отдаёт текст тому, кто запись загрузил, карточкой записи.
метаданные и сами файлы лежат во встроенной PocketBase, и она же даёт владельцу Состояние записей и метаданные лежат в SQLite, файлы записей — своим каталогом
панель администратора. рядом с базой. Панели администратора у сервиса нет: встроенное хранилище,
дававшее её, убрано 2026-08-22. Вход Telegram убран 2026-08-14 — временно, до
задачи, которая свяжет чат с учётной записью.
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
руками не правит и в форматы документов не экспортирует, учётных записей не руками не правит и в форматы документов не экспортирует, учётных записей не
@@ -24,19 +26,45 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
## Стек ## Стек
Go 1.25 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), SQLite через
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object `modernc.org/sqlite` — база, — шаги схемы библиотекой `pressly/goose/v3`,
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка — маршруты и слои на `net/http`, файлы записей своим каталогом,
`aws-sdk-go-v2` для Object
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Приложение — Vue 3
с роутером пятой версии и сборкой Vite; собранное вшито в бинарник, проверяют
его Biome и юнит-тесты Vue. Сборка —
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`. Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
## Проектирование
**Чистая архитектура.** Зависимости направлены внутрь, к домену: домен не знает
ни хранилища, ни транспорта, знание о внешнем мире приходит интерфейсом порта, а
реализацию подставляет точка входа. Направления держат тесты-сканеры
`internal/archrules`, а не договорённость.
**Модель домена ведётся тактическими шаблонами DDD** — сущность и корень
агрегата, объект-значение, доменное событие, репозиторий, служба домена,
фабрика. Анемичной модели не заводим: поведение записи живёт в домене, а
прикладной слой назначает порядок шагов, а не правила.
Слои, их дома, что каждому знать нельзя и чем шаблон занят сегодня —
[docs/architecture.md](docs/architecture.md), «Слои и модель домена». Здесь это
не повторяется: перечень растёт вместе с моделью, и вторая копия разошлась бы с
ним молча.
## Инварианты ## Инварианты
Что нарушать нельзя. Что нарушать нельзя.
- **Секрет не покидает конфиг.** Токен бота, ключ SpeechKit и пара ключей Object - **Секрет не покидает конфиг.** Ключ SpeechKit и пара ключей Object
Storage не попадают в git, в лог, в ответ пользователю и в колонку Storage не попадают в git, в лог, в ответ пользователю и
`error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют вручную во в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
всех местах выкладки. **critical** вручную во всех местах выкладки. **critical**
Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в
настройках коллекции пользователей хранилища, — и снято 2026-08-22 вместе с
самим секретом: вход переехал на доверенный заголовок, обменивать код стало не
на что. Чтение файла базы больше не равносильно чтению секрета. Секретов в
базе не осталось вовсе: пароль владельца от панели ушёл вместе с панелью.
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла - **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера. идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
@@ -48,82 +76,183 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
приведённым к перечню известных форматов. Границу держит спека `intake`, приведённым к перечню известных форматов. Границу держит спека `intake`,
цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md), цена — [adr/ADR-2026-08-11-known-format-label.md](docs/adr/ADR-2026-08-11-known-format-label.md),
остаток — [docs/security.md](docs/security.md). остаток — [docs/security.md](docs/security.md).
- **Бот отвечает только тем, кто в белом списке.** Бот проверяет отправителя до
любой работы, включая скачивание файла. Нарушение обратимо правкой конфига, но
чужие записи к тому моменту уже обработаны за наши деньги. **critical**
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет - **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
задачу пригодной к повтору, либо переводит её в `failed` и сообщает запись пригодной к повтору, либо ставит на неё признак остановки с причиной —
пользователю. Молчаливый выход из шага без записи в лог и без смены состояния и тогда причина видна её владельцу **карточкой записи**, а владельцу сервиса
запрещён. Обратимо повторной отправкой, но пользователь об этом не узнает. журналом. Молчаливый выход из шага без записи в лог и без смены состояния
**major** запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом
Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и
отправитель, который не спрашивает, о нём не узнаёт. Адрес, которым он
спрашивает, сменился 2026-08-15: опрос готовности убран, и обязанность целиком
переехала на карточку. **major**
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не - **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
запись раз в секунду на каждый воркер. **major** запись раз в секунду на каждый воркер. **major**
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым - **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
файлом шага. Необратимо: хранилище считает применённое по имени файла. файлом шага. Необратимо: учёт применённого ведёт сама база.
**critical** **critical**
- **Имя файла в хранилище задаёт сервис, а в журнал не идёт.** Умолчание *Снятие было, разовое:* 2026-08-22 решением владельца весь каталог шагов
PocketBase строит имя из имени, данного отправителем, — оно не применяется. встроенного хранилища удалён и заменён одним шагом начальной схемы. Причина —
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется стройка: на сервере данных нет, сервис остановлен, выкладка идёт с чистого
листа, а новая база ведёт учёт применённого своей таблицей, которой отметки
прежнего каталога не годятся вовсе. Граница названа: снятие кончилось этим
изменением, и шаг начальной схемы подпадает под инвариант как всякий прежний.
- **Имя файла на диске задаёт сервис, а в журнал не идёт.** Ни имя файла, ни имя
подкаталога записи не строятся из имени, данного отправителем: подкаталог зовётся
идентификатором записи, файл — идентификатором с расширением. В журнал пишется
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
записи. **critical** записи. **critical**
- **Колонки очереди правятся в четырёх местах** пакета хранилища — - **Колонки записи правятся в трёх местах** пакета хранилища —
`applyToRecord`, `recordToJob`, константа `acquireColumns` и структура `writeOwnedByPipeline` вместе с `writeRecord`, `readRecordColumns` и
`acquiredRow` с её `toJob`, — плюс шаг схемы. Компилятор видит два из них. `rowToAudioRecord`, — плюс шаг схемы. Компилятор не видит ни одного: колонка,
Колонка, забытая в паре `acquireColumns`/`acquiredRow`, приезжает из захвата забытая в одном из них, теряется молча — запись сохранится без поля, приедет с
нулевой, и первый же `Save` пишет этот ноль поверх сохранённого значения: нулевым либо доедет до сущности пустой, и ближайшее сохранение запишет этот
поле теряется **только у задачи, попавшей к воркеру**. **major** ноль поверх сохранённого.
- **Результат пишет только держатель захвата.** Шаг, чей захват за время работы Мест было четыре, пока захват перечислял колонки поимённо; теперь он
достался другому, завершается без записи и без ответа отправителю. Иначе два возвращает идентификатор и признак своего захвата, и перечень перестал расти
воркера пишут в одну задачу по очереди, а отправитель получает два ответа. с моделью. Отображение при этом идёт **по имени колонки**: именованные
параметры запроса и место назначения, найденное по имени, — позиционный список
дал бы сдвиг на одно поле, который компилируется молча. Сверку держат правила
`internal/archrules`. **major**
- **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него
выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя;
перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в
отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
ниже не пишется в журнал и не считается в метрику: запись встанет без единого
следа. Сверку держат правила `internal/archrules`. **major**
- **Результат пишет только держатель захвата, и держатель узнаётся значением.**
Признак захвата уникален для каждого захвата, и запись результата условна по
нему, а не по занятости записи. Шаг, чей захват за время работы достался
другому — по протуханию срока или после того, как человек вернул запись в
работу подкомандой оснастки, — завершается без записи результата. Условие
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
по очереди, портя её результат. **major**
- **У записи есть владелец, и колонка пустого значения не принимает.** Ничья
запись не заводится ничем — ни приёмом, ни конвейером, ни запросом к базе, — и
держит это схема, а не договорённость: колонка объявлена внешним ключом на
учётную запись и обязательна. Пока обязательность жила в одном приёме, ничью
запись заводили руками мимо него, она уходила в конвейер, стоила денег на
распознавание и не доставалась потом никому. Правило со стороны
спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной
записью, потому что схема запрещает **заводить** ничью, а это правило —
**спрашивать** ничьим именем. **major**
- **Остановленная запись несёт причину, какой бы та ни была.** Причин три —
приговор шага, исчерпанные отказы, застревание, — и каждая записывается в саму
запись и в её журнал событий. Остановленная запись захвату не выдаётся, значит
исход «пригодна к повтору» исключён, и другого следа у неё не будет.
Обязанность, записанная у одной причины, у остальных читалась бы как снятая.
**major** **major**
## Команды ## Команды
```bash ```bash
go build ./... # CGO не нужен go build ./... # CGO не нужен
go test ./... go test ./... # в гейте идёт с -race, и там нужен компилятор C
go vet ./... go vet ./...
gofmt -l . gofmt -l .
golangci-lint run golangci-lint run
go run . -c config.toml # флаг -c или --config, по умолчанию config.toml go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml
go run ./cmd/devtools resume -c config.toml <id> # вернуть остановленную запись в работу
task front # приложение: зависимости, Biome, юнит-тесты, сборка
task image # docker-образ; тег и раскладка — docs/architecture.md task image # docker-образ; тег и раскладка — docs/architecture.md
task gate # весь набор проверок разом task gate # весь набор проверок разом
``` ```
Node на машину **не ставится**: шаг сборки приложения зовёт его контейнером, а
образ берёт из ступени `Dockerfile`. Требованием к машине разработчика поэтому
становится docker — тот же, которым собирается образ.
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml` Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml`
скопируй `config.dist.toml` и заполни; известные прорехи образца перечислены в скопируй `config.example.toml` и заполни; известные прорехи образца перечислены
[docs/conventions/config.md](docs/conventions/config.md) строками в [docs/conventions/config.md](docs/conventions/config.md) строками
«*Расхождение:*». «*Расхождение:*».
## Гейт ## Гейт
- **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию - **Команда целиком:** `task gate`. База диффа — переменная `BASE`, по умолчанию
`origin/master`; переопределяется `task gate BASE=<rev>`. `origin/master`; переопределяется `task gate BASE=<rev>`.
- **Какое правило чем проверяется** — конвенция
[docs/conventions/go-linters.md](docs/conventions/go-linters.md). Здесь
семантика гейта, там перечень правил, подавлений и место настройки каждого;
перечень здесь не повторяется.
- **Где логи шагов:** вывод команды, отдельного файла нет. - **Где логи шагов:** вывод команды, отдельного файла нет.
- **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У - **Что означает каждый исход:** ненулевой код любого шага роняет гейт. У
`docs.py check`, `tasks.py check` и `openspec.py check` словарь кодов общий: `docs.py check`, `tasks.py check`, `openspec.py check` и
0 сошлось, 1 дрейф, 2 ошибка употребления, 3 окружение (не корень проекта, `scripts/check-go-version.sh` словарь кодов общий: 0 сошлось, 1 дрейф,
каталог не найден), 4 внутренний сбой. 2 ошибка употребления, 3 окружение (не корень проекта, каталог или файл не
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`, найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно:
неотформатированный файл, находка `golangci-lint`, дрейф раскладки документов, четвёртый шаг с собственной семантикой сделал бы это утверждение неверным.
дрейф каталога задач, форма `openspec/config.yaml`. Машина проверяет всё Тому же словарю следуют **обёртки шагов** в `Taskfile.yml` — все, включая
`tests`, `migrations`, `shell`, `dockerfile` и `vulns`: недостающий инструмент
— отказ окружения, код 3. У `tests` это отсутствие CGO или компилятора C, без
которых не работает детектор гонок — тесты он в этом случае всё равно гоняет,
без `-race`, и краснеет уже после них. У `migrations` код 3 — неразрешимая
база диффа, отсутствующий каталог шагов и каталог без единого шага; код 1 —
переписанный шаг схемы. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`,
`golangci-lint`) держат свои коды, и гейту от них нужно только «ненулевой».
Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на
любой отказ шага выходит с 201, а код шага печатает строкой
(«exit status 3»), поэтому словарь читается по коду скрипта.
- **Что красит безусловно и почему:** красная сборка приложения, находка Biome,
красный юнит-тест приложения, отказ сборки, тестов, `go vet`,
гонка, найденная детектором (`go test -race`), переписанный применённый шаг
схемы,
неотформатированный файл, находка `golangci-lint`, расхождение объявленных
версий Go, дрейф раскладки документов,
дрейф каталога задач, форма `openspec/config.yaml`, достижимая из кода
уязвимость в зависимостях (`govulncheck`), находка `shellcheck` в скриптах
оболочки и `hadolint` в `Dockerfile`. Машина проверяет всё
перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с
именем недостающего плагина, а не пропускается молча. именем недостающего плагина, а не пропускается молча.
- **Что ловит pre-commit, а что только гейт.** `lefthook.yml` гоняет на
**затронутых файлах** дешёвую часть: `gofmt` (правит на месте и добавляет в
коммит), `golangci-lint` по пакетам тронутых файлов, `shellcheck`, `hadolint`,
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
сверка версий Go, сверки документов и `govulncheck`: они смотрят всё
дерево либо требуют сети, а pre-commit обязан быть быстрым.
- **Сетезависимые шаги названы здесь поимённо, и перечень этот закрыт.** Один из
них — `front`: он ставит зависимости приложения из реестра пакетов, а docker до
того тянет образ сборочного окружения. Отказ сети и реестра там — отказ окружения, код 3,
отдельно от красной сборки, у которой код 1; полный кэш установщика снимает
поход в сеть вовсе. Без короткого обращения-пробы шаг **висел** бы вместо
отказа: установщик уходит в повторы с нарастающей паузой на каждом пакете, а
гейт, который висит, хуже красного.
- **Шагу `vulns` нужна сеть** тоже: база уязвимостей живёт на
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Судит он
достижимость из кода: находка в модуле, чей уязвимый символ мы не вызываем,
шаг не роняет. Такая сегодня одна — `GO-2026-5932` в
`golang.org/x/crypto/openpgp`, исправления у неё нет вовсе.
- **Чего в гейте намеренно нет и кто тогда обязан это гонять:** - **Чего в гейте намеренно нет и кто тогда обязан это гонять:**
- **сборка образа** — дорога, и отказ от неё сознательный. Дешёвая замена
стоит шагом сверки версий: он сравнивает строки и ловит расхождение, из-за
которого образ перестаёт собираться, но собираемости не проверяет. Собрать
образ по-прежнему может только человек — `task image`, и на подъёме версии
это обязательно;
- собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и часть его
правил подавлена поимённо — `DL3007` до задачи `pin-runtime-image-base` и
`DL3018` по существу (alpine не держит старые версии пакетов, закрепление
ломает сборку через недели). Причины стоят строками в `Taskfile.yml`;
- `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс - `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс
коммита. Полную историю никто не проверяет; коммита. Полную историю никто не проверяет;
- согласованность документов между собой и с кодом — её судят агенты, зовёт - согласованность документов между собой и с кодом — её судят агенты, зовёт
их скилл `av-dev-docs:healthcheck`, и звать его надо руками; их скилл `av-dev:doc-healthcheck`, и звать его надо руками;
- покрытие изменённых строк не считается ничем. - покрытие изменённых строк не считается ничем.
**Гейт на `master` сегодня зелёный целиком, и объявленных долгов у него нет.** **Гейт на `master` сегодня зелёный целиком, и объявленных долгов у него нет.**
Красный шаг означает поломку — свою или чужую, но поломку, а не наследство. Красный шаг означает поломку — свою или чужую, но поломку, а не наследство.
Списывать отказ на долг больше нельзя: списывать не на что. Списывать отказ на долг больше нельзя: списывать не на что.
Два прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как Три прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
новый: новый:
- `hadolint` давал `DL3066` на строке `USER transcriber` — «Non-numeric user-id
may not be resolvable by host system». Отказ пришёл с обновлением `hadolint`,
а не с правкой репозитория, и жил на `master` незамеченным. Закрыто решением
владельца 2026-08-22: пользователь называется числом — `USER 1000:1000`.
Владельца файлов в смонтированном каталоге это не двигает, потому что те же
числа уже стояли при заведении пользователя (`-u 1000`, `-g 1000`);
- `golangci-lint run` давал 4 замечания — два непроверенных `Close` и два - `golangci-lint run` давал 4 замечания — два непроверенных `Close` и два
сравнения ошибок приведением типа. Закрыто задачей сравнения ошибок приведением типа. Закрыто задачей
`errors-as-instead-of-typecast` 2026-08-11; тогда же у `errcheck` включена `errors-as-instead-of-typecast` 2026-08-11; тогда же у `errcheck` включена
@@ -134,16 +263,39 @@ task gate # весь набор проверок разом
## Запреты ## Запреты
- **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и - **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
база (`data/data.db`), и записи живых людей база (`data/transcriber.db`), и записи живых людей
(`data/storage/<коллекция>/<запись>/`). Локальный каталог данных — свой, его (`data/records/<запись>/`). На стройке под ним пусто и сервис
ронять и пересоздавать можно свободно. остановлен — запрет от этого не снимается: каталог принадлежит серверу, и
- **Боевым токеном бота не запускаться.** Второй процесс с тем же токеном выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой,
перехватывает обновления у работающего, и пользователь теряет ответы. его ронять и пересоздавать можно свободно.
- **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]`
проверяются на старте, но наружу при этом не обращаются. У `[auth]` остался
один ключ — перечень доверенных адресов, — и он проверяется на читаемость, а не
на достижимость. Расшифровка при выдуманных ключах не работает: её подменяют
`internal/adapter/recognizer/memory.go`. Подробности строками в
`config.example.toml`.
**На машине без прокси представиться нечем**: сервис узнаёт
пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков
не ставит. Заголовок подставляет сам сервис — настройками, а не вторым
процессом: рецепт из трёх правок записан связным блоком в
`config.example.toml`, под перечнем доверенных адресов. Приложение при этом
открывают по адресу сервиса, второго порта нет. Заполненная имитация при
выключенном предохранителе роняет старт с именем ключа. Ключей боевого
провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге.
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage - **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён — оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
подставляй `internal/adapter/recognizer/memory.go`. подставляй `internal/adapter/recognizer/memory.go`.
- **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server` - **Выкладку не запускать.** `inv pl -- transcriber` из `pet-project-server`
запускает человек. запускает человек.
- **Проверок над проверками не заводить.** Уровень проверки один: линтеры и
тесты судят код сервиса, а судить их самих незачем. Под запрет попадают тесты
на шаги гейта и на свои скрипты проверок, стражи предмета у правил,
механизация покрытия изменённого кода, мутационная сверка оракулов и
требование мутировать тест, чтобы убедиться в его способности упасть. Решение
владельца 2026-08-13; им закрыты четыре задачи — причины и даты в
[tasks/REJECTED.md](tasks/REJECTED.md), — и тем же решением снесены двадцать
сценариев шага сверки версий Go, единственный такой файл в проекте.
Исключений у запрета нет.
- **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во - **`testdata` в проекте нет.** Тесты, которым нужен файл, создают его во
временном каталоге и убирают за собой. временном каталоге и убирают за собой.
- **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не - **Временное** — `t.TempDir()` в тестах, `/tmp` вне их. В `data/` временное не
@@ -151,6 +303,14 @@ task gate # весь набор проверок разом
## Работа ## Работа
- **Стадия проекта — стройка** (`[tasks] stage = "build"`, объявлена в
[tasks/BACKLOG.md](tasks/BACKLOG.md)). Приложение строим заново: на сервере
данных нет, сервис остановлен, выкладка пойдёт с чистого листа. Совместимость
с тем, что уже лежит на сервере, поэтому не требуется — переносить нечего: ни
базы, ни файлов записей, ни истории. Что это **не** отменяет: гейт краснеет на
переписанном шаге схемы, как и краснел, и снятие этого запрета — отдельное
решение человека; боевой каталог данных остаётся под запретом; выкладку
по-прежнему запускает человек.
- **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет. - **Основная ветка:** `master`. Коммиты идут в неё напрямую, веток и PR нет.
- **Сообщение коммита** без трейлера `Co-Authored-By`. - **Сообщение коммита** без трейлера `Co-Authored-By`.
- **Необратимое** (спрашивается у человека всегда): применённая миграция, формат - **Необратимое** (спрашивается у человека всегда): применённая миграция, формат
@@ -158,9 +318,9 @@ task gate # весь набор проверок разом
ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация ключа конфига, любое действие с боевыми данными и с Yandex Cloud, ротация
секрета. секрета.
- **Что считается сломанным** — новый красный шаг гейта, которого не было до - **Что считается сломанным** — новый красный шаг гейта, которого не было до
твоей правки. Такое чинится прежде любой другой работы. Два объявленных долга твоей правки. Такое чинится прежде любой другой работы. Исключений из этого
из раздела «Гейт» сломанным состоянием **не** считаются, пока их не закрыли правила нет: раздел «Гейт» называет оба прежних долга закрытыми, и списывать
задачами. красный шаг больше не на что.
- **Ориентир по размеру порции:** не замерялся. - **Ориентир по размеру порции:** не замерялся.
- **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены - **Что такое «сделана»:** `task gate` зелёный и критерии приёмки проверены
поимённо. поимённо.
@@ -169,4 +329,19 @@ task gate # весь набор проверок разом
- Документация, комментарии, сообщения коммитов — русский. - Документация, комментарии, сообщения коммитов — русский.
- Код и идентификаторы — английский. - Код и идентификаторы — английский.
- Текст, который видит пользователь Telegram, — русский. - Текст, который видит пользователь сервиса, — русский.
- **Точного числа накопленного в документах нет.** «Три capability», «пять
прогонов ревью», «две типизированные ошибки» расходятся с действительностью на
первой же задаче, которая прибавит четвёртую, — и расходятся молча: машина
такое не считает, а читатель верит написанному. Ссылаться можно только на
**конкретную запись** (по имени, со ссылкой) либо на **весь корпус разом**
(«заведённые capability», «записи журнала ниже»). Само перечисление при этом
законно: перечень обновляют вместе с предметом, а число живёт отдельно от него
и потому протухает в одиночку.
*Изъятие:* число, которое не растёт с работой, остаётся числом — количество
уровней журнала в библиотеке, ступеней сборки образа, состояний списка на
экране. Так же законно **историческое** число в записи о прошлом: «решением от
2026-08-13 закрыты четыре задачи» описывает событие, а не сегодняшний счёт.
Настройки с числовым значением — свой случай, их дом
[docs/database.md](docs/database.md).
+39 -3
View File
@@ -1,5 +1,30 @@
# Front build stage
#
# Приложение собирается до бинарника: вшивание требует готового каталога.
# Имя этого образа — единственное; шаг набора проверок берёт его отсюда же,
# чтобы версия сборочного окружения не жила вторым числом в Taskfile.yml.
#
# Образ на glibc, а не на alpine, и разница здесь не в весе: musl шлёт запросы
# `A` и `AAAA` разом и ждёт **оба** ответа, а DNS-сервер, который на `AAAA`
# молчит, оставляет его без адреса вовсе — при живом `A`. `npm` на такой отказ
# уходит в повторы с нарастающей паузой на каждом пакете, и сборка не краснеет,
# а **висит**. Библиотека glibc довольствуется полученным `A` и собирает.
# Ступень сборочная: в готовый образ её слои не едут, и лишний вес остаётся
# ценой одной сборки, а не размером выкладки.
FROM docker.io/library/node:24 AS front-build
WORKDIR /web
# Зависимости ставятся из файла замка командой, которая его не правит:
# иначе собранное в образе перестаёт совпадать с собранным в наборе проверок.
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# Build stage # Build stage
FROM docker.io/library/golang:1.25-alpine AS build-env FROM docker.io/library/golang:1.26-alpine AS build-env
# Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite, # Сборочных зависимостей нет: хранилище ходит в SQLite через modernc.org/sqlite,
# и CGO больше не требуется. # и CGO больше не требуется.
@@ -16,8 +41,14 @@ RUN go mod download
# Copy source code # Copy source code
COPY . . COPY . .
# Собранное приложение приезжает ступенью выше: в дереве сборки его нет,
# а вшивание без него отдаёт бинарник, который отвечает «приложение не собрано».
COPY --from=front-build /web/embed/dist ./web/embed/dist
# Build the application # Build the application
RUN CGO_ENABLED=0 go build -o transcriber . # Собирается одна точка входа из cmd/, а не весь пакет: соседний cmd/devtools —
# оснастка разработчика (подставной прокси), и в образе ей делать нечего.
RUN CGO_ENABLED=0 go build -o transcriber ./cmd/transcriber
# ---------------- # ----------------
# Production stage # Production stage
@@ -60,7 +91,12 @@ COPY docker/entrypoint.sh /usr/bin/entrypoint
RUN chmod 755 /usr/bin/entrypoint RUN chmod 755 /usr/bin/entrypoint
# Set user # Set user
USER transcriber #
# Числом, а не именем: имя разрешает в идентификатор сам образ, и хост, которому
# нужно понять владельца файлов в смонтированном каталоге, разрешить его не
# может. Числа те же, что заданы выше при заведении пользователя (`-u 1000`,
# `-g 1000`), поэтому владелец файлов не меняется — меняется только запись.
USER 1000:1000
EXPOSE 8080 EXPOSE 8080
+58 -32
View File
@@ -1,24 +1,24 @@
# Transcriber Service # Transcriber Service
Сервис расшифровки аудиозаписей. Два входа — Telegram-бот и HTTP API. Сервис расшифровки аудиозаписей. Вход один — HTTP API.
## Возможности ## Возможности
- Приём аудио из Telegram: голосовые сообщения, аудиофайлы и документы с аудио
- Приём аудиофайлов через HTTP API - Приём аудиофайлов через HTTP API
- Конвертация в ogg через ffmpeg - Конвертация в ogg через ffmpeg
- Распознавание речи через Yandex SpeechKit - Распознавание речи через Yandex SpeechKit
- Отслеживание статуса задач расшифровки - Отслеживание статуса задач расшифровки
- Встроенная PocketBase для метаданных, файлов и панели владельца; метрики Prometheus - Своё хранилище: SQLite для метаданных и каталог файлов записей рядом с ним; метрики Prometheus
## Технологии ## Технологии
- **Веб-фреймворк**: gin-gonic/gin - **Язык**: Go 1.26, CGO не нужен
- **Telegram**: go-telegram-bot-api - **HTTP**: стандартная библиотека, `net/http`
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3) - **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
- **Конвертация**: ffmpeg - **Конвертация**: ffmpeg
- **Хранилище, файлы и панель**: встроенная PocketBase - **База данных**: SQLite через modernc.org/sqlite, CGO не нужен
- **База данных**: SQLite внутри PocketBase (через modernc.org/sqlite, CGO не нужен) - **Шаги схемы**: pressly/goose/v3, библиотекой — накат при старте
- **Файлы записей**: свой каталог, подкаталог на запись
- **Метрики**: prometheus/client_golang - **Метрики**: prometheus/client_golang
## Установка и запуск ## Установка и запуск
@@ -30,22 +30,34 @@
``` ```
3. Скопируйте образец конфига и заполните его: 3. Скопируйте образец конфига и заполните его:
```bash ```bash
cp config.dist.toml config.toml cp config.example.toml config.toml
``` ```
4. Запустите приложение: 4. Запустите приложение:
```bash ```bash
go run . -c config.toml go run ./cmd/transcriber -c config.toml
``` ```
Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен
установленный `ffmpeg`. установленный `ffmpeg`.
### Белый список Telegram ### Кого пускают
Бот отвечает только тем, кто перечислен в конфиге. Кого и по какому признаку он Кто пришёл, сервис узнаёт из заголовка `Remote-User`, который ставит обратный
пускает — [docs/security.md](docs/security.md), «Что разграничивает доступ»; прокси, сходив к Authelia; своего входа у сервиса нет. Кому верить, задаёт
известные прорехи образца конфига, включая недостающий ключ белого списка, перечень доверенных адресов в секции `[auth]`. Подробности
[docs/conventions/config.md](docs/conventions/config.md). [docs/security.md](docs/security.md), «Что разграничивает доступ»; известные
прорехи образца конфига — [docs/conventions/config.md](docs/conventions/config.md).
Локально прокси нет, а браузер заголовков не ставит — заголовок подставляет сам
сервис по своим настройкам. Второго процесса для этого не нужно: приложение
открывают по адресу сервиса.
Рецепт целиком — связным блоком в `config.example.toml`, под перечнем доверенных
адресов: там названы три правки, принимаемые имена заголовков и цена включения.
Заполненная секция имитации при выключенном предохранителе роняет
старт с именем ключа: сервис с включённым предохранителем называет пришедшего
сам, никого не спросив, и в бою этот ключ стоит `false`.
## Деплой ## Деплой
@@ -61,9 +73,16 @@ inv pl -- transcriber
## HTTP API ## HTTP API
Четыре маршрута: `POST /api/audio` — приём записи, `GET /api/status/:id` — Адреса приложения живут под корнем `/app`: `POST /app/audiorecords` — приём
готовность задачи, `GET /metrics` — метрики Prometheus с префиксом записи, `GET /app/audiorecords` — страница своих записей,
`transcriber_`, `GET /health` — проверка живости. `GET /app/audiorecords/{id}` — карточка, `GET /app/audiorecords/{id}/text` —
текст названного вида, `GET /app/audiorecords/{id}/file` — файл записи названной
копии, `GET /app/me` — кто пришёл, `GET /app/config` — пределы, которые сервис
объявляет приложению. Отдельными адресами стоят `GET /metrics` — метрики
Prometheus с префиксом `transcriber_` — и `GET /health` — проверка живости.
Своего входа у сервиса нет: кто пришёл, называет заголовок обратного прокси
([access](openspec/specs/access/spec.md)). Больше на этом порту не отвечает
ничего: всякий прочий путь получает разметку приложения.
Контракт приёма и опроса нормативен и живёт в Контракт приёма и опроса нормативен и живёт в
[openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и [openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и
@@ -80,41 +99,48 @@ inv pl -- transcriber
``` ```
transcriber/ transcriber/
├── main.go # Точка входа: конфиг, миграции, сборка зависимостей, запуск ├── cmd/
│ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск
│ └── devtools/ # Оснастка разработчика: возврат остановленной записи в работу
├── internal/ ├── internal/
│ ├── entity/ # Модели: задача, файл, результат распознавания │ ├── entity/ # Модели: запись, файл, результат распознавания
│ ├── ident/ # Выдача и разбор идентификаторов строк (ULID)
│ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок │ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок
│ ├── config/ # Разбор config.toml │ ├── config/ # Разбор config.toml
│ ├── metrics/ # Метрики Prometheus │ ├── metrics/ # Метрики Prometheus
│ ├── service/ # Конвейер расшифровки │ ├── service/ # Конвейер расшифровки
│ ├── controller/ │ ├── controller/
│ │ ├── http/ # HTTP-обработчики │ │ ├── http/ # HTTP-обработчики
│ │ ├── tg/ # Telegram-бот
│ │ └── worker/ # Фоновые воркеры │ │ └── worker/ # Фоновые воркеры
│ └── adapter/ │ └── adapter/
│ ├── converter/ffmpeg/ # Конвертация аудио │ ├── converter/ffmpeg/ # Конвертация аудио
│ ├── metaviewer/ffmpeg/ # Длительность аудио │ ├── metaviewer/ffmpeg/ # Длительность аудио
│ ├── recognizer/yandex/ # SpeechKit + Object Storage │ ├── recognizer/yandex/ # SpeechKit + Object Storage
── telegram/ # Отправка сообщений ── repo/sqlite/ # Репозитории, подключение к базе, шаги схемы, каталог файлов
│ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели
└── data/ # Каталог данных: база и файлы записей вместе └── data/ # Каталог данных: база и файлы записей вместе
├── data.db # База хранилища (создаётся автоматически) ├── transcriber.db # База (создаётся автоматически)
── storage/ # Файлы записей в раскладке хранилища ── migrate.lock # Замок наката схемы
└── records/ # Файлы записей: подкаталог на запись
``` ```
## Хранилище ## Хранилище
Две коллекции, `files` и `transcribe_jobs`. Поля, ключи, правило времени и Таблицы базы — аудиозапись и её приложения. Поля, ключи, правило времени и
идентификаторов, а также механика захвата задачи воркером — идентификаторов, раскладка файлов записи и механика захвата задачи воркером —
[docs/database.md](docs/database.md). Панель владельца — по адресу `/_/` того же [docs/database.md](docs/database.md). Панели владельца у сервиса нет: единственное
порта; пароль от неё задаёт сам владелец по приглашению, которое сервис печатает его действие вне экранов — возврат остановленной записи в работу подкомандой
в журнал при первом запуске. оснастки.
```bash
go run ./cmd/devtools resume -c config.toml <идентификатор записи>
```
## Разработка ## Разработка
Схему двигают шаги миграций PocketBase на Go Схему двигают шаги `pressly/goose/v3`
`internal/adapter/repo/pocketbase`. Непринятые шаги накатываются при подъёме `internal/adapter/repo/sqlite/migrations`, файл на шаг, версия шага — число в
хранилища, прежде чем стартуют воркеры и сервер. Применённый шаг не начале имени файла. Непринятые шаги накатываются при старте, прежде чем поднимутся
входы и стартуют воркеры; отказ шага роняет старт. Применённый шаг не
переписывается: изменение — только новым файлом шага. переписывается: изменение — только новым файлом шага.
Проверки перед коммитом — одной командой: Проверки перед коммитом — одной командой:
+240 -10
View File
@@ -9,15 +9,24 @@ vars:
# Пути скриптов проверки. Умолчания — канонические пути маркетплейса, чтобы # Пути скриптов проверки. Умолчания — канонические пути маркетплейса, чтобы
# переустановка плагина не меняла Taskfile. Шаги независимы: каждый ловит # переустановка плагина не меняла Taskfile. Шаги независимы: каждый ловит
# дрейф своего каталога, и выпадение одного не подменяется другим. # дрейф своего каталога, и выпадение одного не подменяется другим.
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-docs/skills/canon/scripts/docs.py"}}' #
TASKS_PY: '{{.TASKS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-tasks/skills/tasks/scripts/tasks.py"}}' # Недостающий скрипт — отказ окружения у всех обёрток ниже, и код у него 3 по
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev-code/skills/openspec/scripts/openspec.py"}}' # общему словарю (CLAUDE.md, раздел «Гейт»). Прежний код 1 значил «дрейф» и
# отправлял читателя искать разъехавшееся там, где просто неполно дерево. Сам
# `task` отдаёт наружу свой 201 на любой отказ шага, поэтому словарь читается
# по коду скрипта, а не по коду `task`.
DOCS_PY: '{{.DOCS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/canon/scripts/docs.py"}}'
TASKS_PY: '{{.TASKS_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/task-track/scripts/tasks.py"}}'
OPENSPEC_PY: '{{.OPENSPEC_PY | default "~/.claude/plugins/marketplaces/av-dev-skills/av-dev/skills/code-openspec/scripts/openspec.py"}}'
tasks: tasks:
gate: gate:
desc: 'Все проверки разом. База диффа: task gate BASE=<rev>' desc: 'Все проверки разом. База диффа: task gate BASE=<rev>'
cmds: cmds:
# Приложение собирается первым: вшивание требует готового каталога, и
# `go build` без него соберёт бинарник со вчерашней сборкой.
- task: front
- go build ./... - go build ./...
- go vet ./... - go vet ./...
- | - |
@@ -27,11 +36,213 @@ tasks:
echo "gofmt: файлы выше не отформатированы" echo "gofmt: файлы выше не отформатированы"
exit 1 exit 1
fi fi
- go test ./... - task: tests
- golangci-lint run - golangci-lint run
- task: shell
- task: dockerfile
- task: go-version
- task: migrations
- task: docs - task: docs
- task: tasks - task: tasks
- task: openspec - task: openspec
# Последним: единственный шаг, которому нужна сеть, и самый долгий.
- task: vulns
tests:
desc: 'Тесты с детектором гонок'
cmds:
# Гонки ищет детектор, а не чтение кода: у сервиса три воркера ходят в одну
# очередь, и «результат пишет только держатель захвата» — утверждение о
# одновременном доступе. Детектору нужен CGO и компилятор C; сборка
# приложения по-прежнему обходится без них (CLAUDE.md, «Стек»), поэтому их
# отсутствие — отказ окружения, код 3, а не отказ проверки.
# Окружение проверяется **после** обычного прогона, а не вместо него:
# отсутствие компилятора отнимает у гейта поиск гонок, но не должно
# отнимать сами тесты. Порядок проверок — сперва компилятор: без него
# совет «включи CGO_ENABLED=1» бесполезен.
- |
if ! command -v gcc >/dev/null 2>&1 && ! command -v clang >/dev/null 2>&1; then
go test ./... || exit 1
echo "тесты прошли, но гонки не искали: детектору нужен компилятор C"
echo "ни gcc, ни clang не найдены в PATH; поставь: apt install gcc"
exit 3
fi
if [ "$(go env CGO_ENABLED)" != "1" ]; then
go test ./... || exit 1
echo "тесты прошли, но гонки не искали: детектору нужен CGO"
echo "CGO_ENABLED=$(go env CGO_ENABLED); включи: CGO_ENABLED=1 task gate"
exit 3
fi
go test -race ./...
migrations:
desc: 'Применённый шаг схемы не переписывается'
cmds:
# Инвариант CLAUDE.md (critical): хранилище считает применённое по имени
# файла шага, поэтому изменить уехавший шаг нельзя — только добавить новый.
# Компилятор этого не держит, и до этого шага не держало ничто.
#
# Судится каталог шагов против базы диффа: у файла шага допустим один
# статус — `A`. Правка (`M`), удаление (`D`) и переименование (`R`) красят.
# `migrations.go` под правило не подпадает: строка `Register` у нового шага
# прибавляется именно там, и запрет на него запретил бы заведение шага.
- |
if ! git rev-parse --verify --quiet "{{.BASE}}" >/dev/null 2>&1; then
echo "база диффа не найдена: {{.BASE}}"
echo "задай свою: task migrations BASE=<rev>"
exit 3
fi
# Каталог шагов берётся из .av-dev.toml — там он уже записан ключом
# `migrations` секции `[docs]` для сверки документов. Свой литерал завёл
# бы факту второй дом: каталог переехал бы, а один из двух стражей молча
# позеленел. До слияния плагинов файл звался docs/.docs.json.
dir=$(python3 -c 'import tomllib; print(tomllib.load(open(".av-dev.toml","rb"))["docs"]["migrations"])' 2>/dev/null) || dir=""
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
echo "каталог шагов схемы не найден: ключ [docs] migrations в .av-dev.toml → '$dir'"
exit 3
fi
# Страж предмета: правило, потерявшее файлы, стало бы вечно зелёным от
# одного переименования — тот же приём, что у правил `internal/archrules`.
if [ -z "$(ls "$dir" | grep -E '^[0-9]{12}_.*\.go$')" ]; then
echo "в $dir нет ни одного файла шага: правило потеряло предмет"
echo "поправь шаблон имени в этом шаге либо ключ [docs] migrations в .av-dev.toml"
exit 3
fi
# Баз две, и вторая обязательна. `{{.BASE}}` отвечает на «шаг уже уехал»
# ровно настолько, насколько свеж `origin/master`: отставшая ссылка
# читает весь каталог как добавленный, и правило молчит. `HEAD` ловит
# правку закоммиченного шага в рабочем дереве независимо от ссылки.
for base in {{.BASE}} HEAD; do
touched=$(git diff --name-status "$base" -- "$dir" \
| grep -E '[0-9]{12}_[^/]*\.go$' \
| grep -vE '^A[[:space:]]' || true)
if [ -n "$touched" ]; then
echo "база $base:"
echo "$touched"
echo "применённый шаг схемы переписан: изменение схемы — только новым файлом шага"
echo "(CLAUDE.md, «Инварианты», critical: хранилище считает применённое по имени файла)"
exit 1
fi
done
front:
desc: 'Приложение: зависимости, проверки, сборка'
vars:
# Образ берётся из Dockerfile: там он объявлен ступенью сборки. Второй дом
# версии сборочного окружения разошёлся бы с первым молча, а своего шага
# сверки у него, в отличие от версий Go, нет.
NODE_IMAGE:
sh: grep -oP '^FROM \K\S*node:\S*' Dockerfile | head -1
# Кэш установщика лежит вне дерева проекта: внутри контейнера он не пережил
# бы прогон, и каждый набор проверок тянул бы зависимости заново.
NPM_CACHE: '{{.NPM_CACHE | default "/tmp/transcriber-npm-cache"}}'
cmds:
# Node на машину не ставится — он зовётся контейнером, тем же образом,
# каким собирается ступень образа. Требованием к машине остаётся docker.
- |
set -eu
if ! command -v docker >/dev/null 2>&1; then
echo "docker не найден в PATH"
echo "приложение собирается контейнером: https://docs.docker.com/engine/install/"
exit 3
fi
if [ -z "{{.NODE_IMAGE}}" ]; then
echo "в Dockerfile не нашлось ступени с образом node"
exit 3
fi
mkdir -p "{{.NPM_CACHE}}"
run() {
docker run --rm \
-u "$(id -u):$(id -g)" \
-e npm_config_cache=/npmcache \
-v "{{.NPM_CACHE}}:/npmcache" \
-v "$PWD/web:/web" \
-w /web \
"{{.NODE_IMAGE}}" \
sh -c "$1"
}
# Зависимости ставятся из файла замка командой, которая его не правит:
# иначе набор проверок пачкал бы рабочее дерево, а собранное им
# расходилось бы с собранным в образе.
#
# Сперва — установка из кэша, без единого обращения наружу: кэш лежит
# вне дерева проекта и переживает прогоны, поэтому обычный случай сети
# не требует вовсе.
if ! run 'npm ci --offline' >/dev/null 2>&1; then
# Кэша не хватило — значит нужна сеть, и её наличие проверяется одним
# коротким обращением. Без этой проверки установщик уходит в повторы с
# нарастающей паузой и **висит на каждом пакете**: гейт, который висит,
# хуже красного — он не даёт ни исхода, ни причины.
if ! run 'npm ping --fetch-timeout=15000 --fetch-retries=0' >/dev/null 2>&1; then
echo "реестр пакетов недоступен"
echo "шагу нужна сеть: он ставит зависимости приложения и тянет образ"
exit 3
fi
# Сеть здесь уже заведомо есть — проба реестра прошла. Значит всякий
# отказ установки это отказ проекта: замок разошёлся с package.json,
# пакет снят из реестра, сломался его postinstall. По словарю кодов
# это дрейф, а не окружение: код 3 отправил бы человека чинить docker
# и сеть вместо `git diff web/package-lock.json`.
if ! run 'npm ci'; then
echo "зависимости приложения не установились, а реестр доступен"
echo "смотри расхождение web/package-lock.json с web/package.json"
exit 1
fi
fi
run 'npm run check && npm run test && npm run build'
shell:
desc: 'shellcheck на скрипты оболочки'
cmds:
# Скриптов два и оба свои: шаг сверки версий и `docker/entrypoint.sh`.
# Второй в образ копируется, но не исполняется — `ENTRYPOINT` в
# `Dockerfile` закомментирован, — и проверяется он именно поэтому: код,
# который никто не гоняет, портится незаметно. Ни один из двух не виден ни
# `go vet`, ни `golangci-lint`.
- |
if ! command -v shellcheck >/dev/null 2>&1; then
echo "shellcheck не найден в PATH"
echo "поставь: apt install shellcheck (или https://github.com/koalaman/shellcheck)"
exit 3
fi
shellcheck scripts/check-go-version.sh docker/entrypoint.sh
dockerfile:
desc: 'hadolint на Dockerfile'
cmds:
# DL3007 (`alpine:latest` у рантайм-слоя) подавлен: это открытая задача
# `pin-runtime-image-base`, и до её решения шаг краснел бы на известном.
# DL3018 (закрепить версии пакетов `apk`) подавлен по существу: alpine не
# держит старые версии в репозитории, и закрепление ломает сборку через
# недели — то есть лечение хуже болезни.
- |
if ! command -v hadolint >/dev/null 2>&1; then
echo "hadolint не найден в PATH"
echo "поставь: https://github.com/hadolint/hadolint/releases"
exit 3
fi
hadolint --ignore DL3007 --ignore DL3018 Dockerfile
go-version:
desc: 'Одна версия Go в go.mod, Dockerfile, CLAUDE.md и README.md'
cmds:
# Скрипт лежит в самом репозитории, а не в плагине: его отсутствие значит
# сломанное дерево, а не непоставленный плагин, и переопределять путь
# нечем и незачем. Код отсутствия — 3, как у прочих обёрток.
- |
py=scripts/check-go-version.sh
if [ ! -f "$py" ]; then
echo "$py не найден: дерево репозитория неполно"
exit 3
fi
sh "$py"
docs: docs:
desc: 'Раскладка docs/ против канона' desc: 'Раскладка docs/ против канона'
@@ -41,8 +252,8 @@ tasks:
py=$(eval echo {{.DOCS_PY}}) py=$(eval echo {{.DOCS_PY}})
if [ ! -f "$py" ]; then if [ ! -f "$py" ]; then
echo "docs.py не найден: $py" echo "docs.py не найден: $py"
echo "поставь плагин av-dev-docs либо задай путь: task docs DOCS_PY=<путь>" echo "поставь плагин av-dev либо задай путь: task docs DOCS_PY=<путь>"
exit 1 exit 3
fi fi
python3 "$py" check --base {{.BASE}} python3 "$py" check --base {{.BASE}}
@@ -53,8 +264,8 @@ tasks:
py=$(eval echo {{.TASKS_PY}}) py=$(eval echo {{.TASKS_PY}})
if [ ! -f "$py" ]; then if [ ! -f "$py" ]; then
echo "tasks.py не найден: $py" echo "tasks.py не найден: $py"
echo "поставь плагин av-dev-tasks либо задай путь: task tasks TASKS_PY=<путь>" echo "поставь плагин av-dev либо задай путь: task tasks TASKS_PY=<путь>"
exit 1 exit 3
fi fi
python3 "$py" check --dir tasks python3 "$py" check --dir tasks
@@ -65,11 +276,30 @@ tasks:
py=$(eval echo {{.OPENSPEC_PY}}) py=$(eval echo {{.OPENSPEC_PY}})
if [ ! -f "$py" ]; then if [ ! -f "$py" ]; then
echo "openspec.py не найден: $py" echo "openspec.py не найден: $py"
echo "поставь плагин av-dev-code либо задай путь: task openspec OPENSPEC_PY=<путь>" echo "поставь плагин av-dev либо задай путь: task openspec OPENSPEC_PY=<путь>"
exit 1 exit 3
fi fi
python3 "$py" check --dir . python3 "$py" check --dir .
vulns:
desc: 'Достижимые из кода уязвимости в зависимостях'
cmds:
# `govulncheck` — внешний инструмент, а не плагин и не файл репозитория:
# ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Его
# отсутствие — отказ окружения, код 3, как у прочих обёрток.
#
# Свой код 3 у самого инструмента значит «уязвимость найдена» и с кодом
# обёртки совпадает; различает их сообщение — обёртка называет недостающий
# инструмент. Шагу нужна сеть: база уязвимостей живёт на vuln.go.dev, и без
# сети шаг краснеет, а не пропускается молча.
- |
if ! command -v govulncheck >/dev/null 2>&1; then
echo "govulncheck не найден в PATH"
echo "поставь: go install golang.org/x/vuln/cmd/govulncheck@latest"
exit 3
fi
govulncheck ./...
# Контракт роли app_image (pet-project-server): собрать полный образ и затегать # Контракт роли app_image (pet-project-server): собрать полный образ и затегать
# его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server. # его $BUILD_ID. Деплой целиком: `inv pl -- transcriber` в pet-project-server.
image: image:
+53
View File
@@ -0,0 +1,53 @@
// Command devtools — оснастка разработчика: то, что нужно для локального
// прогона и никогда не едет в боевой образ.
//
// Пакет один на все такие инструменты, а не по пакету на инструмент. Причина
// счётная: каждый отдельный пакет стоит четырёх мест — строка сборки образа,
// «Деплой» в устройстве, «Команды» в памятке, README, — и забытая строка сборки
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
//
// Подкоманда одна — `resume`, возврат остановленной записи в работу. Она встала
// на место панели владельца: панели у сервиса больше нет, а экраны правки
// записи приносят отдельные задачи. Подставной обратный прокси жил здесь второй
// подкомандой и убран 2026-08-23 задачей `config-test-headers-login`: заголовки
// входа локального прогона подставляет сам сервис по своим настройкам.
//
// Вывод идёт stdlib-логом в поток ошибок, а не `slog`: его читает человек в
// терминале, в сбор он не едет. Изъятие названо строкой в конвенции журнала.
//
// В образ пакет не едет: ступень сборки называет `./cmd/transcriber` поимённо.
package main
import (
"fmt"
"log"
"os"
)
func main() {
log.SetFlags(0)
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "resume":
runResume(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1])
usage()
os.Exit(2)
}
}
func usage() {
fmt.Fprint(os.Stderr, `Оснастка разработчика.
Подкоманды:
resume вернуть остановленную запись в работу
`)
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"flag"
"fmt"
"log"
"os"
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/config"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// stepResume — чем возврат в работу подписывается в журнале событий записи.
const stepResume = "resume"
// runResume возвращает остановленную запись в работу.
//
// Подкоманда встала на место панели владельца: панели у сервиса больше нет, а
// экраны правки записи приносят отдельные задачи. Из всего, что владелец делал
// панелью, отложить до экранов нельзя было одно — возврат остановленной записи.
//
// **Колонок подкоманда не пишет.** Перечень полей, которые возврат обязан
// сбросить — признак остановки, признак захвата и срок его протухания, число
// отказов, паузу и время входа в рубеж, — исполняет домен одним действием.
// Рука, забывшая любое из них, оставила бы запись либо невидимой для захвата,
// либо останавливаемой снова первым же захватом — молча, без единой строки.
//
// Событие журнала записи пишется с происхождением «человек»: иначе запись,
// побывавшая остановленной и вернувшаяся в работу, неотличима в журнале от
// записи, которую конвейер вёл без остановок, а происхождение события перестаёт
// различать что-либо.
func runResume(args []string) {
flags := flag.NewFlagSet("resume", flag.ExitOnError)
configPath := flags.String("c", "config.toml", "путь к файлу настроек")
if err := flags.Parse(args); err != nil {
os.Exit(2)
}
if flags.NArg() != 1 {
fmt.Fprint(os.Stderr, "укажи идентификатор записи: devtools resume [-c config.toml] <id>\n")
os.Exit(2)
}
recordID, ok := ident.Parse(flags.Arg(0))
if !ok {
log.Fatalf("идентификатор записи не читается: %q", flags.Arg(0))
}
cfg, err := config.LoadConfig(*configPath)
if err != nil {
log.Fatalf("настройки не читаются: %v", err)
}
if err := cfg.Storage.Validate(); err != nil {
log.Fatalf("настройки хранилища негодны: %v", err)
}
db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{
BusyTimeoutMs: cfg.Storage.BusyTimeoutMs,
ReadConnections: cfg.Storage.ReadConnections,
})
if err != nil {
log.Fatalf("база не открывается: %v", err)
}
defer func() {
if err := db.Close(); err != nil {
log.Printf("база закрылась с отказом: %v", err)
}
}()
records := sqliterepo.NewAudioRecordRepository(db)
events := sqliterepo.NewRecordEventRepository(db)
record, err := records.Get(recordID)
if err != nil {
log.Fatalf("запись не читается: %v", err)
}
if !record.IsHalted() {
log.Fatalf("запись %s не остановлена: возвращать в работу нечего", recordID)
}
// Захват снимает сам домен, поэтому сохранение идёт **безусловным**: держателя
// у остановленной записи нет, и сверять признак захвата не с чем.
record.Resume()
if err := records.Save(record, ""); err != nil {
log.Fatalf("запись не сохраняется: %v", err)
}
if err := events.Append(&entity.RecordEvent{
RecordID: recordID,
Origin: entity.EventOriginHuman,
Step: stepResume,
Outcome: entity.EventOutcomeResumed,
}); err != nil {
log.Fatalf("событие журнала записи не сохраняется: %v", err)
}
log.Printf("запись %s возвращена в работу с рубежа %s", recordID, record.State)
}
+285
View File
@@ -0,0 +1,285 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg"
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/config"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
"git.vakhrushev.me/av/transcriber/internal/controller/worker"
"git.vakhrushev.me/av/transcriber/internal/metrics"
"git.vakhrushev.me/av/transcriber/internal/service"
"git.vakhrushev.me/av/transcriber/web"
)
// main держит одну обязанность: отказ подъёма пишется **одной** строкой и
// кончается ненулевым кодом выхода.
//
// Работа вынесена в run, чтобы уборка шла отложенными вызовами: `os.Exit`
// посреди подъёма оставил бы за собой открытые пулы базы и незакрытого клиента
// распознавания.
func main() {
// Создаем структурированный логгер
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
if err := run(logger); err != nil {
logger.Error("Transcriber service failed to start", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
// Parse command line flags
configPath := flag.String("c", "config.toml", "Path to config file")
flag.StringVar(configPath, "config", "config.toml", "Path to config file (alias for -c)")
flag.Parse()
cfg, err := config.LoadConfig(*configPath)
if err != nil {
return fmt.Errorf("unable to load configuration from %s: %w", *configPath, err)
}
logger.Info("Configuration loaded successfully", "config_path", *configPath)
// Пустой перечень доверенных адресов роняет старт: он значит «не верить
// никому», то есть сервис, поднявшийся никого не узнающим, — и узнать об
// этом было бы неоткуда.
if err := cfg.Auth.Validate(); err != nil {
return err
}
// Перечень разобран один раз, при старте: разбирать строки на каждом запросе
// значило бы платить за настройку, которая не меняется.
trustedNetworks, err := cfg.Auth.TrustedNetworks()
if err != nil {
return err
}
// Перечень называется строкой журнала: сервис, никого не узнающий из-за
// неверного перечня, иначе неотличим от сервиса, до которого заголовок не
// доходит вовсе, — а это разные поломки в разных местах.
logger.Info("Trusted proxies configured", "trusted_proxies", cfg.Auth.TrustedProxies)
// Настройки отладочного входа: заполненная имитация без предохранителя, имя
// заголовка, которого сервис не читает, и имитация без годного логина роняют
// старт. Имена заголовков приходят проверке доводом — дом у них один,
// константы транспорта, — а пакет настроек транспорта не знает.
if err := cfg.ValidateTestHeaders(
httpcontroller.IdentityHeaderNames(), httpcontroller.LoginHeader,
); err != nil {
return err
}
// Представление предиката «подставляем ли» одно — непустота перечня, — и
// судят его одинаково строка журнала ниже, слой подстановки и проверка выше.
// Второе выражение того же предиката разошлось бы с первым молча.
substitution := cfg.HeaderSubstitution()
if len(substitution) > 0 {
// Уровень предупреждающий: сервис называет пришедшего сам, никого не
// спросив, — ровно то «может стать проблемой», ради которого заведён
// этот уровень. Идут имена заголовков; значений нет — логин это ключ к
// чужому архиву.
logger.Warn("Identity headers are substituted from configuration",
"headers", httpcontroller.SubstitutedHeaderNames(substitution),
"capability", "access")
}
// Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
// ней значит остановить всякую запись первым же захватом.
if err := cfg.Pipeline.Validate(); err != nil {
return err
}
if err := cfg.Storage.Validate(); err != nil {
return err
}
db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{
BusyTimeoutMs: cfg.Storage.BusyTimeoutMs,
ReadConnections: cfg.Storage.ReadConnections,
})
if err != nil {
return err
}
defer func() {
if err := db.Close(); err != nil {
logger.Error("Failed to close the database", "error", err)
}
}()
// Создаем контекст для graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Схема накатывается **до** подъёма входов и до старта воркеров, а её отказ
// роняет старт: сервис, поднявшийся на неприведённой схеме, отвечает отказом
// на каждый запрос и на каждый прогон воркера — вместо одной строки о
// причине их становятся сотни.
if err := sqliterepo.Migrate(ctx, db, cfg.Storage.DataDir, logger); err != nil {
return err
}
store := sqliterepo.NewStore(cfg.Storage.DataDir)
recordRepo := sqliterepo.NewAudioRecordRepository(db)
fileRepo := sqliterepo.NewFileRepository(db, store)
repos := service.Repositories{
Records: recordRepo,
Files: fileRepo,
Texts: sqliterepo.NewTextRepository(db),
Structures: sqliterepo.NewStructureRepository(db),
Recognitions: sqliterepo.NewRecognitionRepository(db, store),
Events: sqliterepo.NewRecordEventRepository(db),
}
users := sqliterepo.NewUserRepository(db)
// Создаем адаптеры
metaviewer := ffmpegmv.NewFfmpegMetaViewer()
converter := ffmpegconv.NewFfmpegConverter()
recognizer, err := yandex.NewYandexAudioRecognizerService(yandex.YandexAudioRecognizerConfig{
Region: cfg.Yandex.ObjStorageRegion,
AccessKey: cfg.Yandex.ObjStorageAccessKey,
SecretKey: cfg.Yandex.ObjStorageSecretKey,
BucketName: cfg.Yandex.ObjStorageBucketName,
Endpoint: cfg.Yandex.ObjStorageEndpoint,
ApiKey: cfg.Yandex.SpeechKitAPIKey,
FolderID: cfg.Yandex.FolderID,
})
if err != nil {
return fmt.Errorf("failed to create audio recognizer: %w", err)
}
// Отдавать отказ закрытия некому — процесс заканчивается, — поэтому он идёт
// в журнал владельца. Что он означает: gRPC-клиент отдаёт здесь отказ лишь
// при повторном закрытии, то есть запись говорит о нашей ошибке, а не о
// недоступности Yandex.
defer func() {
if err := recognizer.Close(); err != nil {
logger.Error("failed to close audio recognizer", "error", err)
}
}()
transcribeService := service.NewTranscribeService(
repos,
metaviewer,
converter,
recognizer,
cfg.Pipeline.StuckLimits(),
logger,
)
// Создаем WaitGroup для ожидания завершения всех воркеров
var wg sync.WaitGroup
// Пул одинаковых воркеров: специализации у них нет, шаг выбирается по рубежу
// самой записи. Число приходит настройкой, ноль — законное значение.
pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger)
wg.Add(1)
go func() {
defer wg.Done()
pool.Start(ctx)
}()
// Вход у сервиса один — приём по HTTP, — и метка ставится только ему.
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
appHandler := httpcontroller.NewAppHandler(
recordRepo, repos.Texts, repos.Structures, fileRepo, transcribeService, logger,
)
// Адресное пространство сервиса объявлено одним перечнем, и он порождает
// регистрацию, а не описывает её: корень, заведённый мимо перечня, не
// получит обработчика вовсе. Отсюда же уровень журнала для адресов
// наблюдения, правило неизвестного пути у раздачи приложения и область
// действия узнавания.
mounts := httpcontroller.ServiceMounts(
httpcontroller.AppChain(appHandler.Routes(), users, trustedNetworks, substitution, logger),
promhttp.Handler(),
)
dist, appBuilt := web.Dist()
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, logger)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
Handler: httpcontroller.BuildHandler(mounts, webappHandler, logger),
// Шесть часов записи по медленному каналу переживают любой фиксированный
// таймаут чтения. Стойкость к целенаправленной нагрузке объявлена вне
// модели угроз проекта.
ReadTimeout: 0,
}
serveErr := make(chan error, 1)
wg.Add(1)
go func() {
defer wg.Done()
logger.Info("Starting HTTP server", "port", cfg.Server.Port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serveErr <- err
}
}()
// Настраиваем обработку сигналов для graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
logger.Info("Transcriber service started", "pipeline_workers", pool.Size())
logger.Info("Press Ctrl+C to stop...")
// Ждем сигнал завершения либо отказ сервера
var startupErr error
select {
case <-sigChan:
logger.Info("Received shutdown signal, initiating graceful shutdown...")
case err := <-serveErr:
logger.Error("HTTP server stopped unexpectedly, shutting down", "error", err)
startupErr = err
}
// Останавливаем HTTP сервер
shutdownCtx, shutdownCancel := context.WithTimeout(
context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
defer shutdownCancel()
logger.Info("Shutting down HTTP server...")
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("HTTP server forced to shutdown", "error", err)
} else {
logger.Info("HTTP server stopped gracefully")
}
// Отменяем контекст для остановки воркеров
cancel()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
logger.Info("All workers stopped gracefully")
case <-time.After(time.Duration(cfg.Server.ForceShutdownTimeout) * time.Second):
logger.Warn("Timeout reached, forcing shutdown")
}
logger.Info("Transcriber service stopped")
return startupErr
}
-42
View File
@@ -1,42 +0,0 @@
# Server configuration
[server]
port = 8080
shutdown_timeout = 5
force_shutdown_timeout = 20
# Storage configuration
# Единственный каталог данных: под ним лежат и база, и файлы записей.
[storage]
data_dir = "data"
# Yandex Cloud Configuration
[yandex]
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
folder_id = "your_folder_id_here"
# API ключ для доступа к Yandex SpeechKit (получить в консоли Yandex Cloud)
speech_kit_api_key = "your_speech_kit_api_key_here"
# Object Storage (S3) configuration
# Access Key ID для доступа к Object Storage (получить в консоли Yandex Cloud)
object_storage_access_key_id = "your_access_key_id"
# Secret Access Key для доступа к Object Storage (получить в консоли Yandex Cloud)
object_storage_secret_access_key = "your_secret_access_key"
# Имя бакета в Object Storage
object_storage_bucket_name = "your_bucket_name"
# Регион Object Storage
object_storage_region = "ru-central1"
# Endpoint Object Storage
object_storage_endpoint = "https://storage.yandexcloud.net/"
# Telegram Bot Configuration
[telegram]
# Токен Telegram бота (получить у @BotFather в Telegram)
bot_token = "your_telegram_bot_token_here"
# Таймаут обновлений Telegram бота (в секундах)
update_timeout = 10
+154
View File
@@ -0,0 +1,154 @@
# Server configuration
[server]
port = 8080
shutdown_timeout = 5
force_shutdown_timeout = 20
# Предохранитель отладочного запуска. Значения: false (по умолчанию) и true.
#
# Означает он одно: прогон идёт на машине разработчика, и сервису позволено
# подставить то, что в бою даёт обратный прокси, — заголовки входа из секции
# [auth.test_headers] ниже. Перечня следствий сверх этого у него нет: уровня
# журнала, текстов внутренних отказов, ограничителя частоты и подмены
# распознавателя признак не касается.
#
# Цена включения названа прямо: сервис с true и заполненной имитацией называет
# пришедшего сам, никого не спросив, и отдаёт архив всякому, чей запрос пришёл с
# доверенного адреса. В бою доверенный адрес — это адрес обратного прокси, то
# есть всякий, кто пришёл обычным путём. В боевом файле ключ стоит false.
debug = false
# Хранилище: каталог данных и числа его базы.
#
# Каталог единственный: под ним лежат и файл базы, и подкаталог с файлами
# записей. Двух путей у хранилища не бывает.
[storage]
data_dir = "data"
# Сколько ждать занятую базу, миллисекунды. Положительное число.
#
# База принимает **одного** писателя: драйвер пишет единственным соединением, и
# несколько воркеров, пришедших писать разом, встают в очередь. Это число —
# сколько ждущий готов простоять, прежде чем получить отказ «база занята».
# Крутят его при таком отказе под несколькими воркерами; ноль означает «отказать
# сразу» и потому не принимается.
busy_timeout_ms = 5000
# Сколько соединений держит читающий пул. Положительное число.
#
# Чтение идёт отдельно от записи: в журнале упреждающей записи читатели не
# мешают писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
# Число выводят из числа воркеров плюс запас под запросы приложения.
#
# Пишущее соединение при этом всегда одно и настройкой не делается: второе
# означало бы отказы по занятости на записи результата шага, то есть после
# оплаченной работы.
read_connections = 4
# Конвейер расшифровки.
[pipeline]
# Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к
# работе запись и выбирает шаг по её рубежу.
#
# Ноль — законное значение, а не поломка: сервис поднимается, записи
# принимаются и не двигаются. Годится местному запуску и выкладке, где конвейер
# надо остановить, не роняя приём.
workers = 3
# Предел простоя записи там, где работу делаем мы сами, в минутах.
#
# Сторож ловит **зависание**, а не долгую работу: пока шаг идёт, запись занята
# захватом, и живой процесс наблюдается сам по себе. Час меньше времени, которое
# многочасовая запись занимает на приведении, и это принято сознательно
# (решение владельца 2026-08-14): цена ложной остановки — одно движение
# владельца, потому что остановка обратима и рубежа не стирает.
own_work_limit_minutes = 60
# Предел простоя там, где ждём операцию внешнего сервиса, в минутах.
#
# Сколько идёт распознавание долгой записи, никто не мерил, поэтому ошибаемся в
# сторону долгого: ложная остановка хуже поздней. Откладывание опроса этот
# отсчёт не двигает — иначе зависшая у провайдера операция опрашивалась бы
# вечно.
foreign_work_limit_minutes = 1440
# Yandex Cloud Configuration
[yandex]
# ID папки в Yandex Cloud (получить в консоли Yandex Cloud)
folder_id = "your_folder_id_here"
# API ключ для доступа к Yandex SpeechKit (получить в консоли Yandex Cloud)
speech_kit_api_key = "your_speech_kit_api_key_here"
# Object Storage (S3) configuration
# Access Key ID для доступа к Object Storage (получить в консоли Yandex Cloud)
object_storage_access_key_id = "your_access_key_id"
# Secret Access Key для доступа к Object Storage (получить в консоли Yandex Cloud)
object_storage_secret_access_key = "your_secret_access_key"
# Имя бакета в Object Storage
object_storage_bucket_name = "your_bucket_name"
# Регион Object Storage
object_storage_region = "ru-central1"
# Endpoint Object Storage
object_storage_endpoint = "https://storage.yandexcloud.net/"
# Кому сервис верит на входе.
#
# Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком
# `Remote-User`, сходив к Authelia. Здесь остаётся один ключ — перечень адресов,
# чьему заголовку верить. Пустой перечень роняет старт: он значит «не верить
# никому», то есть сервис, поднявшийся никого не узнающим.
[auth]
# Адреса и подсети, с которых приходит обратный прокси. Сверяется адрес самого
# соединения, а не пересылаемый заголовок: пересылаемым распоряжается тот, кто
# шлёт запрос.
#
# **Перечень задаёт адрес прокси, а не весь частный диапазон.** Всякий, кто
# дотянулся до сервиса с адреса из этого перечня, называет себя кем угодно и
# получает чужой архив; `172.16.0.0/12` означало бы «любой контейнер на хосте»,
# включая чужие проекты. На сервере сюда ставят адрес сети, в которой стоит
# Caddy, — узкий и свой.
trusted_proxies = ["172.20.0.0/24"]
# Локальный вход без Authelia — рецепт целиком.
#
# Прокси на машине разработчика нет, а браузер заголовков не ставит — значит
# приложение локально не открылось бы вовсе. Заголовки входа подставляет сам
# сервис: второго процесса и второго порта для этого не нужно, приложение
# открывают по адресу сервиса.
#
# Три правки этого файла сверху вниз, и других не нужно:
#
# 1. Добавить в перечень выше пару петлевых адресов — обе записи, а не одну:
#
# trusted_proxies = ["172.20.0.0/24", "127.0.0.1", "::1"]
#
# Браузер разрешает localhost в IPv6 не реже, чем в IPv4, и перечень без
# `::1` даёт неузнанный запрос. Отказ подстановки при этом виден строкой
# журнала с адресом пира — по ней и опознаётся недостающая запись.
#
# 2. Поставить в секции [server] выше:
#
# debug = true
#
# 3. Раскомментировать секцию ниже и назвать в ней Remote-User. Ключ —
# имя заголовка, значение — то, чем сервис назовёт пришедшего. Принимаются
# три имени: Remote-User, Remote-Name, Remote-Email; иное роняет старт.
# Ключ Remote-User обязателен: без него сервис подставит всё прочее и не
# узнает никого.
#
# Второй вошедший получается другим значением Remote-User: логин и есть ключ
# учётной записи.
#
# Заполненная секция при debug = false роняет старт с именем ключа
# предохранителя: состояние «имитация есть, предохранителя нет» не читается
# никак, а обе его прочтения — поломка.
#
# [auth.test_headers]
# Remote-User = "local"
# Remote-Name = "Разработчик"
# Remote-Email = "local@example.com"
+4 -4
View File
@@ -12,21 +12,21 @@ if [ "${USER}" != "transcriber" ]; then
fi fi
if [ -z "${USER_GID}" ]; then if [ -z "${USER_GID}" ]; then
USER_GID="$(id -g ${USER})" USER_GID="$(id -g "${USER}")"
fi fi
if [ -z "${USER_UID}" ]; then if [ -z "${USER_UID}" ]; then
USER_UID="$(id -u ${USER})" USER_UID="$(id -u "${USER}")"
fi fi
# Change GID for USER? # Change GID for USER?
if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "$(id -g ${USER})" ]; then if [ -n "${USER_GID}" ] && [ "${USER_GID}" != "$(id -g "${USER}")" ]; then
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*/${USER}:\1:${USER_GID}/" /etc/group sed -i -e "s/^${USER}:\([^:]*\):[0-9]*/${USER}:\1:${USER_GID}/" /etc/group
sed -i -e "s/^${USER}:\([^:]*\):\([0-9]*\):[0-9]*/${USER}:\1:\2:${USER_GID}/" /etc/passwd sed -i -e "s/^${USER}:\([^:]*\):\([0-9]*\):[0-9]*/${USER}:\1:\2:${USER_GID}/" /etc/passwd
fi fi
# Change UID for USER? # Change UID for USER?
if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "$(id -u ${USER})" ]; then if [ -n "${USER_UID}" ] && [ "${USER_UID}" != "$(id -u "${USER}")" ]; then
sed -i -e "s/^${USER}:\([^:]*\):[0-9]*:\([0-9]*\)/${USER}:\1:${USER_UID}:\2/" /etc/passwd sed -i -e "s/^${USER}:\([^:]*\):[0-9]*:\([0-9]*\)/${USER}:\1:${USER_UID}:\2/" /etc/passwd
fi fi
-4
View File
@@ -1,4 +0,0 @@
{
"canon": 14,
"migrations": "migrations"
}
@@ -3,6 +3,7 @@
- **Дата:** 2026-08-11 - **Дата:** 2026-08-11
- **Источник:** [../research/pocketbase.md](../research/pocketbase.md) — записка - **Источник:** [../research/pocketbase.md](../research/pocketbase.md) — записка
разведки `pocketbase-admin-fit` разведки `pocketbase-admin-fit`
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
## Решение ## Решение
@@ -67,6 +68,10 @@ PocketBase заменяет SQLite с goqu и goose и берёт на себя
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с `pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
файлом атрибутов. Момент перехода назначает человек; данные прежней базы не файлом атрибутов. Момент перехода назначает человек; данные прежней базы не
переносятся по прежнему решению задачи `pocketbase-storage`. переносятся по прежнему решению задачи `pocketbase-storage`.
*Уточнено 2026-08-12:* каталог задаётся ключом `[storage] data_dir` со
значением `data`. Суффикс из десяти знаков дописывает конструктор имени,
которого сервис не зовёт, — имя задаёт он сам. Действующая раскладка —
[../database.md](../database.md), «Представление данных».
- `` вход перестаёт быть нашим: задача `oidc-login` переписывается с - `` вход перестаёт быть нашим: задача `oidc-login` переписывается с
собственной обработки ответа провайдера на настройку провайдера в PocketBase. собственной обработки ответа провайдера на настройку провайдера в PocketBase.
Что делать с сессией и где она живёт, решает уже не наш код. Что делать с сессией и где она живёт, решает уже не наш код.
@@ -3,6 +3,7 @@
- **Дата:** 2026-08-11 - **Дата:** 2026-08-11
- **Источник:** [../research/job-queue.md](../research/job-queue.md) — записка - **Источник:** [../research/job-queue.md](../research/job-queue.md) — записка
разведки `job-queue-choice` разведки `job-queue-choice`
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
## Решение ## Решение
@@ -0,0 +1,47 @@
# Кого пускать в сервис, решает правило провайдера, а не сервис
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Кого пускать, решает провайдер, а не сервис»
## Решение
Сервис пускает всякого, кого пропустил провайдер, и **своей проверки допуска не
делает**. Кто допущен, определяет правило Authelia на этого клиента — настройка
выкладки, лежащая вне репозитория.
## Почему
Authelia — общий провайдер контура, а не выделенный под этот сервис: учётная
запись в ней есть у всякого, кому её завели ради любого другого сервиса на том же
сервере. Ревью дизайна назвало следствие прямо: механизм, приглашающий «второго
человека», приглашает всех, кто уже есть у провайдера.
Очевидный ответ — проверять принадлежность к названной в конфиге группе своим
кодом. Владелец от него отказался: это завело бы **второе место**, где решается
допуск, и решать его пришлось бы в двух местах согласованно.
Цена отказа названа в источнике и повторена в модели угроз:
> Правило живёт вне репозитория, в настройках выкладки, и сервис на него
> полагается так же, как полагается на обратный прокси в части панели
> администратора. Настроенный слишком широко клиент открывает сервис всем, у кого
> есть учётная запись в общей Authelia, — и проверить это по коду нельзя.
Запись заводится как **намеренный отказ от очевидного подхода**: проверку группы
предложат снова, и без записанной причины она выглядит бесплатной.
## Последствия
- `+` допуск решается в одном месте, а не в двух; изменение круга допущенных не
требует ни правки кода, ни выкладки.
- `+` сервис не читает из ответа провайдера ничего сверх нужного для заведения
записи — ни групп, ни ролей.
- `` защита сервиса стала свойством настройки, лежащей в другом репозитории, и
ревью её проверить не может: ни один проход не увидит, что клиент настроен
слишком широко.
- `` ошибка в настройке клиента не имеет наблюдаемого признака внутри сервиса:
посторонний, которого пропустила Authelia, выглядит как законный пользователь.
- `` разграничения по владельцу нет, поэтому цена ошибки в настройке — все
записи и все расшифровки разом, а не одна учётная запись. Сузит это
`record-ownership`.
@@ -3,6 +3,7 @@
- **Дата:** 2026-08-12 - **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md), - **Источник:** [../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md](../../openspec/changes/archive/2026-08-12-pocketbase-storage/design.md),
раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал» раздел «Поле файла не помечаем защищённым, но ссылка не уезжает в журнал»
- **Статус:** заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md)
## Решение ## Решение
@@ -0,0 +1,52 @@
# Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Вход и возврат ведёт наш код, разбор ответа — хранилище»
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
## Решение
Обработчик возврата от провайдера зовёт **собственный адрес хранилища**
`auth-with-oauth2` внутри процесса, через его же роутер, а не по сети и не
разбирая ответ провайдера своими руками.
## Почему
Решение [ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
отдало разбор ответа провайдера хранилищу: только тогда учётные записи заводятся
сами и видны в панели. Это решение не пересматривается — пересматривается способ
до него дотянуться.
Проверка исходников библиотеки версии 0.39.10 показала, что обмен наружу не
экспортирован: он живёт неэкспортированной функцией за собственным маршрутом.
Остались три формы, и владелец выбрал первую:
> (а) внутрипроцессный вызов собственного маршрута `auth-with-oauth2`: решение
> 2026-08-11 соблюдено дословно, цена — петля «наш обработчик → наш роутер → наш
> обработчик», разбор JSON-ответа и потеря типизированной ошибки; (б) сборка
> обмена из экспортированных кусков с сохранением записи и связи через `app.Save`:
> прямой код без петли, цена — пересмотр решения 2026-08-11 отдельным ADR; (в)
> отложить вход до появления фронтенда.
Запись заводится как **намеренный отказ от очевидного подхода**: собрать обмен
своими руками выглядит проще и дешевле, и предложение вернётся, если причина не
записана.
## Последствия
- `+` разбор ответа провайдера, заведение учётной записи и связь её с внешним
провайдером остаются за хранилищем — решение 2026-08-11 соблюдено дословно, а
не «по духу».
- `+` наш код не знает ни одного поля ответа провайдера: обновление библиотеки
под смену формата ответа доезжает само.
- `` петля через собственный роутер: обработчик зовёт сервис, частью которого
сам является. Это новый для проекта вид узла, и его придётся объяснять на
каждом следующем изменении.
- `` ответ разбирается текстом, типизированная ошибка теряется: причина отказа
обмена доступна только кодом состояния.
- `` роутер хранилища пришлось собирать **один раз** и держать полем: его
сборка вешает обработчики на само приложение и без идентификатора, поэтому
повторная не заменяет прежние. Ревью кода нашло это построенным путём —
анонимный запрос копил обработчики без предела, а каждое сохранение задачи
конвейером проходило по всем накопленным.
@@ -0,0 +1,57 @@
# Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Что изменило ревью кода», плюс отчёт триажа
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт 3
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
## Решение
Поле файла в хранилище **помечается защищённым**, а правило просмотра коллекции
файлов пускает всякого узнанного. Ссылка `/api/files/<коллекция>/<запись>/<имя>`
перестаёт быть правом пройти по ней: нужен короткий токен файла, который берут,
предъявив сессию.
Запись заменяет [ADR-2026-08-12-file-link-open-but-not-logged](ADR-2026-08-12-file-link-open-but-not-logged.md).
## Почему
Прежнее решение было обусловленным и само назвало условие своего пересмотра:
> Решение действует до разграничения доступа: задачи `oidc-login` и
> `record-ownership` меняют условие, и тогда пометку стоит пересмотреть новой
> записью.
Условие наступило. Прежний довод — «право прочитать задачу даёт знание её
идентификатора, и файл встаёт вровень с `GET /api/status/:id`» — держался на том,
что опрос готовности открыт анонимно. Этот change закрывает опрос за вход, и
файл, оставшийся открытым, стал бы единственным анонимным путём к содержимому
записи — самому чувствительному, что есть у проекта.
Вторая половина прежнего решения остаётся в силе: имя файла в журнал по-прежнему
не пишется. Защищённое поле сужает право пройти, но не отменяет запрета —
строка журнала со ссылкой собирала бы половину ключа.
Пометки самой по себе оказалось мало, и это выяснило ревью кода прогоном:
защищённый файл судится **и** токеном, **и** правилом просмотра коллекции, а
незаданное правило означает «только владелец панели». Файл не получал ни аноним,
ни вошедший — сценарий спеки не исполнялся вовсе. Правило назначено тем же шагом
схемы.
## Последствия
- `+` содержимое записи перестало быть доступным по одному знанию ссылки; после
закрытия API это был последний анонимный путь к нему.
- `+` условие, названное прежней записью, отработало как задумано: решение
пересмотрено записью, а не молча.
- `` ссылка усложнилась для потребителя: браузер с одной кукой файла не
получает, нужен порядок «сессия → токен файла → ссылка». Будущее приложение
обязано этот шаг делать, и задача про прослушивание записи начинается с него.
- `` разграничения по владельцу нет: токен файла берёт всякий вошедший, и по
ссылке он получит **любую** запись, а не только свою. Сужение приносит
`record-ownership`; до неё круг сузился с «кто угодно из интернета» до «кто
угодно из вошедших», и это меньше, чем кажется.
- `` отзыва у выданного токена нет, как не было у ссылки; смягчает только его
короткий срок.
@@ -0,0 +1,56 @@
# Сессия живёт семь суток и не продлевает саму себя
- **Дата:** 2026-08-12
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
раздел «Что изменило ревью кода», плюс отчёт триажа
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
пункт 6
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
## Решение
Срок жизни сессии — **семь суток**, назначается при каждом подъёме сервиса.
Продление сессии **выключено**: адрес, которым хранилище меняет предъявленное
значение на новое, закрыт слоем приложения.
## Почему
Умолчание хранилища — пять суток и продлеваемая сессия. Второе делает первое
бессмысленным, и это выяснило ревью кода замером: предъявитель одного живого
значения продлевает себе доступ бессрочно, никуда не входя.
Значение имеет то, на чём держится вся остановка перерасхода. Паспорт опирается
на **отзыв доступа в Authelia** как на способ остановить того, кто тратит слишком
много. Но сервис после входа к провайдеру не обращается: подпись сессии считается
от значений в базе, и отзыв у провайдера до сервиса доходит **только** истечением
срока. При живом продлении не доходит никогда — человек, которому закрыли доступ,
сохраняет его навсегда.
Отвергнуто и названо ценой:
> сверяться с провайдером по расписанию — новая связь с Authelia и обработка её
> недоступности, работа шире задачи; принять как есть — тогда паспорт теряет
> способ остановить того, кто тратит слишком много.
Число семь суток выбрано владельцем как компромисс: реже входить против дольше
ждать, пока отзыв доедет.
Срок назначается **при подъёме, а не шагом схемы**, и это отдельное решение с
причиной: применённый шаг не переписывается, поэтому число, положенное туда,
разошлось бы со сроком жизни куки при первой же правке — браузер получил бы
новый срок, а хранилище продолжило выдавать прежний.
## Последствия
- `+` отзыв доступа у провайдера доходит до сервиса гарантированно, максимум за
семь суток; без этого он не доходил вовсе.
- `+` срок жизни сессии стал числом, которое кто-то выбрал, и правится он в одном
месте вместе со сроком куки.
- `` человек перевходит раз в неделю, и это заметно: своей страницы у сервиса
нет, так что вход начинается с перехода по адресу входа руками.
- `` семь суток — всё ещё окно, в которое отозванный доступ работает. Немедленно
закрыть чужую сессию можно только руками в панели, обновив ключ токенов записи;
своего адреса у этого нет.
- `` закрытие продления сделано слоем приложения, а не настройкой коллекции:
библиотека выдаёт сессию продлеваемой всегда, и отключить это в ней нечем.
Слой придётся помнить при всякой правке маршрутов.
@@ -0,0 +1,66 @@
# ADR-2026-08-12. Спекой нормируется и инструмент сборки, а не только поведение сервиса
- **Дата:** 2026-08-12
- **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решение 2
- **Статус:** устарело — 2026-08-13 владелец решил обратное: инструментарию в
спеках не место. Capability `toolchain` упразднена, замены у неё нет, а норма
шага осталась комментариями в `scripts/check-go-version.sh`
## Решение
Заведена capability `toolchain` — четвёртая, и первая, которая описывает **не
поведение сервиса** для его потребителей, а поведение инструмента, которым сервис
собирают. Потребитель у неё другой: тот, кто собирает.
Требование о согласованности объявленной версии Go живёт нормой в
`openspec/specs/toolchain/spec.md`, а не прозой в памятке. *Уточнено 2026-08-13:
файла по этому адресу больше нет, ссылка снята — capability упразднена, см.
статус записи.*
## Почему
Три существующие capability — `intake`, `pipeline`, `storage` — все про то, что
сервис делает для своих потребителей, а преамбула `architecture.md` прямо
говорила «поведение системы здесь не описывается — нормативно оно живёт в
`openspec/specs/`». Согласованность версий сборки под это определение не
подходит, и натяжение признано прямо в источнике:
> Признаём натяжение: три существующие capability описывают поведение сервиса для
> его потребителей, а `toolchain` описывает поведение инструмента разработки.
> Потребитель у него другой — тот, кто собирает сервис. Правило `config.yaml`
> говорит «поведение **или домен** системы»; инструмент сборки — домен, и именно
> как домен он здесь и назван.
Очевидные пути отвергнуты оба:
> **Отвергнуто: дописать в `pipeline`.** `pipeline` нормирует прогон воркера и
> захват задачи — поведение работающего сервиса. Версия сборщика с ним не
> меняется вместе.
>
> **Отвергнуто: обойтись без дельта-спеки.** Изменение вводит проверяемое
> требование — «расхождение роняет набор проверок», — и требование без дома
> проверяется только памятью того, кто его завёл. Обещание «образ собирается» уже
> один раз жило в трёх документах и во всех трёх было неверным.
Последнее и есть довод, перевесивший чистоту определения: дефект 2026-08-12
случился именно потому, что утверждение о версии сборки жило только прозой, в
трёх местах сразу, и никто не отвечал за его истинность.
## Последствия
- `+` у правила о версиях есть нормативный дом со сценариями, и по нему видно, что
проверено, а что оставлено человеку. Три требования, двадцать два сценария.
- `+` следующая задача про инструмент сборки знает, куда дописывать, и не заводит
вторую спеку о том же.
- `` определение capability в проекте стало шире, чем «поведение сервиса», и
граница теперь проходит по слову «домен». Следующее пограничное решение будет
ссылаться на этот прецедент — в том числе тогда, когда ссылаться не стоило бы.
- `` асимметрия: четыре однородных шага гейта живут в двух разных домах. У трёх
плагинных (`docs.py`, `tasks.py`, `openspec.py`) нормативного дома нет вовсе,
только строка в памятке; у четвёртого есть спека. Либо дома появятся у
остальных, либо асимметрия останется навсегда.
- `` имя `toolchain` выбрано в том числе из-за настройки среды разработчика:
первая редакция звалась `build`, и глобальный запрет чтения каталогов с таким
именем сделал спеку нечитаемой для проходов ревью. Имя, выбранное под
ограничение инструмента, а не под предмет, — слабое основание, и при следующем
пересмотре его стоит перепроверить.
@@ -0,0 +1,60 @@
# ADR-2026-08-12. Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента
- **Дата:** 2026-08-12
- **Источник:** [openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md](../../openspec/changes/archive/2026-08-12-go-1-26-upgrade/design.md), раздел `Decisions`, Решения 1 и 6
## Решение
Шаг сверки версий добывает числа чтением файлов и **не зовёт `go` ни в каком
виде** — ни `go mod edit -json`, ни `go list -m`, ни `go env`. Директива
`toolchain` в `go.mod` при этом запрещена: её наличие роняет шаг.
Дословно из источника:
> Способ это не самый удобный: разбор директивы через `go mod edit -json` короче
> и надёжнее регулярного выражения. Он же и опасный: вызов `go` тянет за собой
> `GOTOOLCHAIN`, `$PATH` и установленный тулчейн, а при непустом `GOTOOLCHAIN`
> `go` вправе полезть в сеть за нужной версией — то есть требование «без сети»
> перестало бы выполняться. Хуже того, исход шага стал бы зависеть от машины, а
> не от коммита.
## Почему
Причина не в аккуратности, а в том, что **ровно этой подменой и держался дефект,
ради которого шаг заведён**. 2026-08-12 требование модуля уехало на 1.25,
сборочный образ остался на 1.24, образ перестал собираться — и восемь шагов гейта
с шестью проходами ревью показали зелёное, потому что `go build ./...` шёл на
хостовом Go. Проверка судила по тому, что стоит на машине, вместо того что
записано в коммите. Шаг, зовущий `go`, воспроизвёл бы ту же подмену внутри себя:
зелёный там, где стоит нужная версия, и другой ответ на другой машине.
Отсюда же запрет `toolchain`. Директива — штатный механизм Go и очевидное
решение задачи расхождения: она заставила бы Go скачать нужную версию самому, и
сверять стало бы нечего. Отвергнута намеренно:
> Директива `toolchain` заставила бы Go скачивать нужный тулчейн сам, и
> расхождение с образом перестало бы ломать сборку. Но она же превращает сборку
> образа в сетевую операцию, а сборочный слой качает тулчейн при каждой сборке.
> Дороже и менее предсказуемо, чем строка сравнения.
Вдобавок она вводит **пятое место**, называющее версию, — то, которого закрытый
перечень из четырёх мест не знает: при `toolchain go1.27.0` четыре объявленных
числа сойдутся, а собирать будет пятое.
## Последствия
- `+` исход шага есть функция коммита. Проверено прогоном: с `PATH`, где нет
`go`, шаг даёт тот же код выхода и тот же вывод.
- `+` требование «без сети» выполняется по построению, а не обещанием: под
`strace` шаг не делает ни одного сетевого вызова.
- `+` пятое место закрыто: `toolchain` в `go.mod` роняет шаг с названной
причиной.
- `` разбор держится на регулярных выражениях `sed`/`awk` вместо готового
разбора, который дал бы сам `go`. Это дороже в сопровождении и хрупче: правка
образца ломает смежный случай беззвучно.
- `` запрет `toolchain` придётся снять или пересмотреть, если зависимость
однажды потребует версию выше той, что стоит у нас. Тогда эта запись
пересматривается, а не обходится.
- `` проверять сам скрипт нечем: `shellcheck` в гейт не заведён, тестов у него
нет. Из девятнадцати сценариев нормы машина гоняет один — тот, где всё
сошлось. Остаток объявлен и уехал отдельной задачей.
@@ -0,0 +1,67 @@
# Намерение объявляется признаком, а не выводится из ключа доступа
- **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-telegram-enabled-flag/design.md
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
## Решение
Вход Telegram включается отдельным признаком `telegram.enabled`, а `bot_token`
означает только доступ. Признак **обязателен**: умолчания у него нет, и файл
настроек без него негоден — сервис выходит с ошибкой настройки, назвав
недостающий ключ.
## Почему
Прежде пустой ключ доступа значил разом две вещи — «вход выключен намеренно» и
«ключа нет», — и сервис поднимался без бота в обоих случаях. Цена расхождения
падала на выкладку: файл настроек собирает Ansible, и потерянный при сборке ключ
выглядел для сервиса как решение владельца.
Умолчания у признака нет, и это **намеренный отказ от очевидного подхода**
булев ключ обычно заводят с умолчанием. Цитата из источника:
> умолчание — это угаданное намерение, а признак заводится ровно затем, чтобы
> намерение объявляли. Файл, где его забыли, одинаково плохо читается в обе
> стороны, и любое умолчание делает одну из двух ошибок тихой.
Отвергнуты оба умолчания. «Включён» — файл без признака работал бы «как-нибудь»,
и разница между объявленным и угаданным намерением исчезала бы ровно там, где её
завели. «Выключен» — первый же подъём после выкладки выключил бы бота молча, то
есть дал бы исход, против которого написано само требование.
Отсутствие ключа судит **разбор**, а не значение: `toml.MetaData.IsDefined`
отличает «не задан» от «задан ложным», тогда как нулевое значение `bool` у обоих
одинаковое. Форма поля с указателем отвергнута: указатель пережил бы проверку и
уехал к потребителям, где `nil` уже невозможен, но выглядит возможным.
Тем же решением закрыт разрез текста отказа при разборе файла настроек. Цитата
из источника:
> Пересказывать библиотеку нельзя: она собирает текст отказа из разбираемого
> куска файла, и оборванная строка секретного ключа уехала бы в журнал вместе со
> значением.
Норму держит инвариант «Секрет не покидает конфиг», а форму записи — конвенция
настроек. Спеки загрузку настроек не нормируют, и это назначено явно: загрузка
не принадлежит ни одной заведённой capability.
## Последствия
- `+` потерянный при сборке файла ключ доступа роняет старт вслух, а не оставляет
сервис работать в половину силы;
- `+` выключенный вход перестал быть поводом для предупреждения: решение
владельца сообщается записью «к сведению», а предупреждение осталось за тем,
чего владелец не выбирал, — недоступностью Telegram;
- `+` оборванная строка секретного ключа больше не уносит значение в журнал
контейнера;
- `` **порядок выкладки стал обязательным**: шаблон настроек обязан получить
признак раньше накатки образа, иначе сервис не поднимется вовсе. Правило живёт
в [architecture.md](../architecture.md), раздел «Эксплуатация», и задаётся там
по ключу, а не по файлу целиком;
- `` один путь молчаливой потери бота остался: признак, ошибочно собранный
как «выключен», отличим от решения владельца только записью журнала. Признак
поднятости входа тут не помощник — он равен нулю и при недоступности Telegram;
- `` отказ разбора файла настроек стал беднее на текст библиотеки: место и ключ
названы, а что именно в строке не так — нет. Плата принята ради инварианта,
помеченного необратимым.
@@ -0,0 +1,67 @@
# Недоступность Telegram подъёму сервиса не мешает
- **Дата:** 2026-08-13
- **Источник:** openspec/changes/archive/2026-08-13-start-without-telegram-token/design.md
- **Статус:** устарело — вход Telegram убран решением [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md); довод устоял и понадобится возврату входа
## Решение
Старт роняет только один исход сборки клиента бота — ответ Telegram «такого бота
нет». Всё прочее, включая недоступность Telegram и истёкший срок ожидания, даёт
подъём без Telegram: сервис работает по HTTP и говорит о неподнятом входе
записью журнала и метрикой.
## Почему
Очевидный подход был обратный, и он же стоял в первой редакции дизайна: любой
отказ сборки бота роняет старт, потому что «сервис, молча потерявший бота после
опечатки в токене, перестаёт отвечать своим отправителям, и узнать об этом было
бы неоткуда».
Ревью кода показало цену этого подхода. Цитата из источника:
> при `api.telegram.org`, отвечающем молчанием, процесс висит в `getMe` без
> ограничения времени: HTTP-вход не открыт, панель не открыта, `/health` не
> отвечает вовсе, воркеры не запущены, в журнале — ни строки.
То есть перезапуск в минуту чужой аварии оставлял без работы приём по HTTP,
панель и конвейер, которому Telegram не нужен вовсе. Паспорт при этом называет
основным входом приложение, а бот и HTTP API — дополняющими его.
Тем же ревью снят довод, на котором держалась прежняя редакция. Она утверждала,
что «Telegram не признал бота» и «до Telegram не дошли» различать нечем. Цитата
из источника:
> Различать есть чем: ответ Bot API приезжает своим типом с кодом, транспортный
> отказ — нашим после чистки, и одно от другого отделяется проверкой типа.
> Утверждение держалось на незнании библиотеки, а не на её устройстве.
Решение владельца: недоступность Telegram на старт приложения не влияет.
Из него следует второе, без которого оно невыполнимо: ожидание при сборке
ограничено сроком. Пока срока не было, недоступность не отличалась от подъёма.
Срок стоит только на сборке — длинный опрос им не ограничен, иначе он рвался бы
на каждом круге.
## Последствия
- `+` авария Telegram не роняет основной вход, панель и конвейер: сервис
поднимается и обрабатывает уже принятое;
- `+` опечатка в токене по-прежнему заметна: Telegram отвечает отказом, и старт
не проходит;
- `+` молчащий Telegram больше не вешает подъём бессрочно;
- `` долгая недоступность Telegram даёт сервис, работающий без бота, а
отправители в это время не получают ответов. Замена «узнать неоткуда» —
запись журнала при старте и признак поднятости входа метрикой;
- `` токен, не разбирающийся как часть адреса (перенос строки из шаблона
выкладки), Telegram не отвергает — его отвергает разбор адреса, и такой случай
попадает в недоступность, а не в ошибку настройки. Заметен он записью журнала,
а не отказом старта.
*Уточнено 2026-08-13:* исходов сборки клиента, роняющих старт, стало два —
к ответу «такого бота нет» добавился пустой ключ доступа при включённом входе.
Решение это не меняет: пустой ключ ошибкой настройки и был, просто прежде он
выражал ещё и отказ от входа, а теперь отказ выражает признак `telegram.enabled`
и до сборки клиента не доходит вовсе. Недоступность Telegram по-прежнему подъёму
не мешает — ровно как решено здесь. Разведение двух значений — отдельная запись,
[ADR-2026-08-13-telegram-intent-declared-not-inferred](ADR-2026-08-13-telegram-intent-declared-not-inferred.md).
@@ -0,0 +1,63 @@
# ADR-2026-08-14. Учётная запись с записями не удаляется, и это осознанный тупик
- **Дата:** 2026-08-14
- **Источник:** [openspec/changes/archive/2026-08-14-record-ownership/design.md](../../openspec/changes/archive/2026-08-14-record-ownership/design.md), раздел `Open Questions`
## Решение
Удаление учётной записи, у которой остались задачи расшифровки либо файлы,
отвергается — отказом с названной причиной. Способа удалить записи в сервисе нет
вовсе, поэтому до задачи про удаление записи такая учётная запись не удаляется
никак: ни владельцем панели, ни самим человеком.
Решение принято человеком на чекпоинте задачи `record-ownership` из трёх
предложенных способов.
Дословно из источника:
> **Что делать с записями удалённого пользователя?** Связь при выключенном
> каскаде снимает ссылку — записи остаются, но становятся ничьими и
> недостижимыми по API навсегда. Способы: запретить удаление учётной записи, пока
> у неё есть записи; держать рядом со связью неизменяемый снимок идентификатора;
> признать потерю ценой и записать её.
## Почему
Колонка владельца — связь с учётной записью, и каскадное удаление у неё
выключено: сервис объявлен архивом и молча удалить чужой архив не вправе. Одного
этого мало, и проверка по исходникам `pocketbase@v0.39.10` показала почему: при
выключенном каскаде хранилище **вынимает** идентификатор из поля связи и
сохраняет запись без проверок. Задачи остались бы на месте, но стали бы ничьими —
а ничья запись по правилу той же задачи не достаётся по API никому. Архив
человека исчезал бы молча, и восстановить владельца было бы нечем: прежнего
значения не остаётся нигде.
Прежнее обоснование выбора связи вместо строки — «связь удержит целостность» —
было неверным, и это выяснилось на ревью дизайна.
## Чем платим
Владелец панели упирается в отказ, а выхода из него сегодня нет: удаление записи
приносит отдельная задача. Тупик назван прямо, а не обнаружен потом.
Отказ обязан доезжать до спрашивающего: хранилище пропускает наружу только свою
ошибку роутера, а всякую другую подменяет сообщением про обязательную связь.
Подсказка эта ведущая — единственная обязательная связь у задачи это файл, — и
владелец панели, поверив ей, пошёл бы удалять записи руками, то есть делать ровно
то необратимое, ради предотвращения чего запрет и заведён. Это нашло ревью кода.
## Что рассматривалось и отвергнуто
- **Неизменяемый снимок идентификатора рядом со связью.** Пережил бы удаление, и
запись можно было бы вернуть человеку. Отвергнуто: владельцем становится любая
строка, и целостность, ради которой выбрана связь, теряется.
- **Признать потерю ценой и записать её.** Дешевле всего сегодня — удаления
пользователей в сервисе нет вовсе. Отвергнуто: архив, теряемый одной кнопкой в
панели, противоречит решению от 2026-08-11 о том, что сервис — архив.
## Связанное
Запрет ставит сама сборка хранилища, а не вызывающий: сборка, забывшая его
позвать, теряет защиту молча — и теряла, пока его добавляли отдельной строкой
запуска. Норма — `openspec/specs/storage`, «Учётная запись с записями не
удаляется».
@@ -0,0 +1,51 @@
# Остановка записи — признак, а не рубеж
- **Дата:** 2026-08-14
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
раздел «Остановка — признак, а не рубеж»
## Решение
Прежние состояния отказа и смерти (`failed`, `dead`) схлопнуты в **признак
остановки** с причиной: `halted_at`, `halt_reason`, `error_text`. Достигнутый
рубеж при остановке не стирается, и снятие признака продолжает работу с того
места, где запись встала.
## Почему
Цитата источника:
> **Признак** — принято: рубеж переживает остановку, продолжение идёт с места
> остановки, массовый перезапуск после выкатки правки делается одним
> обновлением, а различие «мы рассудили» против «мы перестали пробовать»
> остаётся причиной, которую человек читает.
Отвергнуты два варианта, оба с названной ценой:
> **Отдельное состояние на каждую причину** — отвергнуто: перечень состояний
> закрыт схемой, и каждая новая причина стоила бы необратимого шага.
>
> **Оставить как есть** — отвергнуто: именно из-за этого перезапись состояния
> руками в панели остаётся единственным способом вернуть запись в работу, и
> делается он наугад.
Прежняя модель описана решением
[ADR-2026-08-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md):
там состояние «мертва» заводилось взамен признака `is_error`, и довод был тот
же — «два способа вывести задачу из выборки расходятся». Довод устоял, а
носитель сменился: теперь единственный способ вывести запись из выборки — этот
признак, и состояние его больше не дублирует.
## Последствия
- `+` перезапуск перестал быть догадкой: запись продолжает с сохранённого
рубежа, а не начинает конвейер заново.
- `+` новая причина остановки стоит значения в закрытом перечне причин, а не
нового состояния и не нового шага схемы.
- `+` массовый возврат в работу после выкатки правки делается одним обновлением
колонки.
- `` в выборке захвата появилось четвёртое условие, и рубеж перестал быть
единственным, что выводит запись из работы: читать состояние записи теперь
надо двумя полями.
- `` перечень причин закрыт схемой, то есть новая причина всё же требует шага
схемы — дешевле прежнего, но не бесплатно.
@@ -0,0 +1,47 @@
# Ответ распознавателя хранится дословно, двоичной формой и вложением
- **Дата:** 2026-08-14
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
раздел «Сырой ответ провайдера хранится вложением, а не колонкой»
## Решение
Ответ SpeechKit сохраняется целиком — сообщения потока подряд, каждое своей
двоичной записью с длиной впереди, — и лежит **вложением** коллекции попыток
распознавания, а не колонкой.
## Почему
Цитата источника:
> Хранится он вообще потому, что **результат операции у провайдера не
> переспрашивается**. Отвергнутый вариант — не хранить и разобрать на лету:
> дешевле сегодня, но связь реплики с говорящим мы строить пока не умеем, и
> когда научимся, архив пересчитать будет не из чего, а повторная операция стоит
> денег за каждую запись.
Вложением, а не колонкой:
> Ответ на многочасовую запись — мегабайты. Хранилище читает запись целиком, а
> шаг опроса читает строку попытки раз в несколько секунд: положенный колонкой,
> ответ ехал бы в память при каждом опросе — тот же промах, что расшифровка в
> перечне колонок захвата сегодня.
Двоичной формой, а не текстовой, — решение ревью кода того же изменения. Замер:
текстовое представление собирается по нашей скомпилированной схеме и **молча
выбрасывает поля, которых в ней нет**, а провайдер добавляет их без
предупреждения. Двоичная форма неизвестные поля переносит: они переживают запись
и чтение и станут читаемыми, когда схема обновится. Ради этого архив и заводился.
## Последствия
- `+` архив пересчитывается из сохранённого без единого рубля: связь реплики с
говорящим станет доступна, когда мы научимся её читать.
- `+` шаг опроса читает строку попытки, не поднимая мегабайты в память.
- `` **формат файла на диске объявлен необратимым**: сохранённое не читается
глазами и не разбирается ничем, кроме нашего же кода, а прочесть архив без
сервиса нельзя вовсе.
- `` каталог данных растёт быстрее прежнего: ответ многословнее самой
расшифровки — несёт альтернативы, время каждого слова и разбор говорящих.
Потолок в 256 МиБ на вложение назван строкой в `database.md`, а сколько там на
деле у шестичасовой записи, не мерил никто.
@@ -0,0 +1,45 @@
# Предел простоя остаётся часом, хотя он короче самой работы
- **Дата:** 2026-08-14
- **Источник:** openspec/changes/archive/2026-08-14-record-centric-model/design.md,
раздел «Сторожей двое, и предела времени — два числа»
## Решение
Сторож застревания ограничивает время записи в рубеже двумя числами: **час** на
свою работу, **сутки** на ожидание чужой операции. Час меньше времени, которое
многочасовая запись занимает на приведении, и это принято сознательно.
## Почему
Ревью дизайна показало, что число противоречит расчётному потолку записи:
> Расчётный потолок записи — шесть часов, приведение такой записи идёт дольше
> часа по построению, а срок захвата шага приведения стоит сегодня восемью
> часами. Значит длинная запись, отказавшая один раз и ждущая повтора дольше
> часа, будет остановлена сторожем застревания вместо расшифровки.
Предложено было вывести предел из срока захвата — двенадцать часов на свою
работу. Владелец решил оставить час, и довод записан цитатой:
> Оставляем час. Тут нужно принять, что это скорее про зависшую задачу, потому
> что пока идёт обработка даже длинной записи мы всегда можем проверить, жив ли
> процесс конвертера.
Довод держится на том, что остановка теперь **обратима**: она не стирает рубежа,
и снятие признака возвращает запись туда, где она стояла (см.
[ADR-2026-08-14-halt-is-a-flag-not-a-stage](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md)).
Цена ложной остановки поэтому равна одному движению владельца, а не потерянной
записи.
## Последствия
- `+` зависшая запись обнаруживается за час, а не за восемь.
- `+` число живёт в настройках и правится без шага схемы, если класс начнёт
всплывать.
- `` длинная запись, отказавшая один раз и прождавшая повтора дольше часа,
останавливается как застрявшая — владельцу приходится снимать признак руками.
- `` предел этот работает только по записи, вернувшейся в выборку. У держателя,
погибшего жёстко, запись невидима сторожу до истечения **срока захвата** её
рубежа, то есть восьми часов у приведения; замер и оговорка стоят строкой в
`database.md`.
+39
View File
@@ -0,0 +1,39 @@
# Приложение живёт своим пространством адресов, а не общим с хранилищем
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
раздел «Переезд в `/app/`, а слой сессии — на корень»
## Решение
Все адреса приложения переехали из `/api/` в собственный корень `/app/`, а слой
предъявления сессии повешен на **группу корня**, а не на перечень адресов.
## Почему
Пространство `/api/` принадлежит хранилищу: оно вешает туда собственные наборы
адресов, и поменять этот префикс нельзя — он литерал библиотеки, а не настройка.
Свободных имён сегодня хватает, но соседство остаётся: обновление библиотеки
вправе занять новое имя рядом с нашим, и разойдутся они молча — тем же адресом
начнёт отвечать не тот обработчик.
Прецедент в проекте уже принят тем же доводом: адреса входа вынесены на `/auth/*`
решением от 2026-08-12.
Слой на корень, а не на перечень: «перечень рос бы с каждым новым адресом
приложения, и забытый в нём адрес молча перестал бы принимать куку».
## Последствия
- `+` соседство с чужими адресами кончилось: имя, занятое библиотекой, наших
адресов больше не задевает;
- `+` новый адрес приложения получает слой предъявления по построению, а не по
памяти того, кто его добавил;
- `` правило неизвестного пути перечисляет теперь четыре корня сервиса вместо
одного: `/api/`, `/app/`, `/auth/` и `/_/`;
- `` ограничитель частоты хранилища, настроенный на его собственный корень,
наших адресов не покрывает — своё правило заводится нами, и его включение
вводит в действие заодно умолчательные правила хранилища;
- `` ломка полная: прежние адреса приёма и опроса отвечают `404`. Оплачено
стадией — на сервере данных нет, внешней программы на прежнем контракте не
существует.
+33
View File
@@ -0,0 +1,33 @@
# Страница архива задаётся ключом, а не номером
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
раздел «Страница задаётся ключом, а не номером»
## Решение
Постраничное чтение своих записей идёт непрозрачным ключом по паре «время
заведения и идентификатор». Номер страницы отвергнут.
## Почему
«Приём пишет в голову той же таблицы записей, которую читает список, и человек,
загрузивший запись и листающий свой архив, — штатный сценарий. Номер страницы
сдвинул бы окно на единицу: последний элемент первой страницы пришёл бы вторым
разом первым элементом второй, а один элемент между ними не пришёл бы никогда.
Отказ молчаливый — ни кода, ни строки в журнале, — и человек видел бы архив, в
котором записи нет.»
Ключ полный: у записей, принятых одним запросом, время совпадает, и порядок
между ними одним лишь временем не определён.
## Последствия
- `+` запись, заведённая между двумя страницами, не даёт ни повтора, ни
пропуска;
- `+` порядок между записями с равным временем устойчив;
- `` экран с нумерацией страниц так не сделать — листать можно только
«дальше». Архиву это не нужно;
- `` ключ приходит от клиента и потому разбирается: время приводится к виду
хранилища, иначе побайтовое сравнение молча обращает условие в постоянную
истину или ложь.
@@ -0,0 +1,62 @@
# Node зовётся контейнером, а не ставится на машину разработчика
- **Дата:** 2026-08-15
- **Источник:** [../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md](../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md),
раздел «Node не ставится на машину, а зовётся контейнером»
## Решение
Шаг сборки приложения гоняет установщик пакетов и сборщик **внутри контейнера**,
а не вызывает их из `PATH`:
> Требованием к машине разработчика становится docker, которым и так собирается
> образ, — второго устанавливаемого окружения сверх `ffmpeg` не появляется
> вовсе.
Образ сборочного окружения берётся из ступени `Dockerfile`, а не объявляется
вторым числом в `Taskfile.yml`.
## Почему
Довод в дизайне назван прямо:
> Так снимается расхождение, которое иначе завелось бы молча: версия Node на
> машине разработчика и версия в образе — два разных числа, и собранное ими
> приложение различается ровно тогда, когда различаются они.
Отвергнуты два очевидных подхода, и оба с названной ценой. **Поставить Node на
машину** — вводит второе устанавливаемое окружение и разъезжается с версией в
образе. **Дать выбор — контейнер или локальный Node** — это второй способ делать
одно и то же, и собранное ими различалось бы в зависимости от того, у кого что
стоит.
## Почему это ADR
Запись проходит триггер **намеренным отказом** от очевидного подхода: поставить
Node на машину — ровно то, что делают по умолчанию, и отказ от этого надо
объяснить один раз, а не на каждом вопросе «почему у нас нельзя просто
`npm run build`».
## Что это меняет в прежнем решении
[ADR-2026-08-11-spa-on-vue](ADR-2026-08-11-spa-on-vue.md) записал последствием,
что «машина разработчика получает второе требуемое окружение сверх `ffmpeg`», и
подразумевал под ним Node. Окружением оказался **docker**. Сам выбор фреймворка и
наличие шага сборки это не пересматривает, поэтому статуса «заменено на» у той
записи нет: заменена не она, а толкование одного её последствия.
## Последствия
- `+` версия сборочного окружения живёт **одним** местом — ступенью
`Dockerfile`, — и своего шага сверки ей не нужно.
- `+` собранное в наборе проверок и собранное в образе совпадает, потому что
совпадает окружение сборки, а не потому что «обычно совпадает».
- `` **набор проверок перестаёт работать без docker**, и отказ этот приходит
кодом окружения. Тем же кодом приходит отказ реестра пакетов: сетезависимых
шагов в наборе становится два вместо одного.
- `` контейнер ходит под тем же пользователем, что и вызвавший, а кэш
установщика уводится наружу — обе частности обязательны: без них собранное
ляжет от `root`, а зависимости будут тянуться заново каждый прогон.
- `` **вес и время самой ступени в образе неизвестны**: финальный образ от неё
не растёт (ступень в рабочий слой не копируется), а время сборки решением
владельца от 2026-08-15 не замеряется вовсе.
@@ -0,0 +1,59 @@
# Обязательность владельца держит схема, а не приём
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
разделы «Схема теряет только необязательность владельца» и «Владелец записи
перестаёт быть необязательным и в модели»
## Решение
Колонка владельца у аудиозаписи и у файла перестала принимать пустое значение —
шагом схемы `202608140003`. Ничья запись не заводится ничем: ни приёмом, ни
конвейером, ни рукой в панели. Поле владельца в модели стало обычной строкой
вместо ссылки, которой позволено отсутствовать.
Существующие строки шаг **не проверяет**, и это принято сознательно: искать ничьи
строки надо запросом до выкладки.
## Почему
Цитата источника:
> **Держать обязательность одним приёмом, схему не трогать.** Так было задумано
> сперва, и это оставляло дыру: ничью запись заводили руками в панели, она
> уходила в конвейер, стоила денег на распознавание и не доставалась потом
> никому. Решение владельца от 2026-08-14 — обязательность держит схема.
Прежнее решение было обратным и записано спекой `storage`: «Колонка MUST
допускать пустое значение… Обязательность для приёма по HTTP держит сама
capability `intake`, а не схема». Цену за него платили записи входа Telegram — у
них владельца не было по построению. Вход убран
([ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md)),
исключение исчезло вместе с ним, и владелец сервиса подтвердил, что записей без
владельца в боевой базе нет.
Про непроверку существующих строк цитата источника:
> **Проверяется это запросом, а не прогоном шага**, и разница выяснилась ревью с
> оракулом: хранилище держит обязательность связи проверкой записи при
> сохранении, а не ограничением таблицы. Смена признака на базе с ничьей записью
> проходит зелёным и такую запись оставляет… Заставить шаг считать строки самому
> владелец решил не делать: безопасность держится ручной проверкой, и она названа
> первым шагом плана перехода.
Правило «пустой владелец не совпадает ни с одной записью» при этом осталось и
избыточным не стало: схема запрещает **заводить** ничью запись, а правило —
**спрашивать** ничьим именем.
## Последствия
- `+` значения «владельца нет» не существует ни на одном уровне: ни в схеме, ни в
модели, ни в отборе.
- `+` дыра «ничью запись заводят руками в панели» закрыта тем же механизмом, что
и приём, — одним, а не двумя.
- `` откат шага возвращает необязательность, но операционно недостижим: команд
библиотеки сервис не подключает, и это верно для всех шагов схемы проекта.
- `` ничья запись, если её проглядят перед выкладкой, становится незакрываемой:
захват выдаёт её воркеру, а всякое сохранение — включая то, которым ставится
признак остановки, — отказывает. Следа не остаётся ни в метрике, ни в журнале
событий, только строка в логе контейнера.
@@ -0,0 +1,37 @@
# Длительность и размер лежат колонками записи, и равенство со строкой файла не поддерживается
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
раздел «Три новых колонки записи и один шаг схемы»
## Решение
Длительность и размер принятого легли колонками аудиозаписи, хотя обе величины
уже есть у строки её файла. Равенство между ними не поддерживается никем —
намеренно. «Неизвестно» эти колонки не выражают: ноль означает ноль.
## Почему
Обе величины показываются в списке, а список по норме `storage` читается без
содержимого. Ревью дизайна возражало: величины станут копиями, которые некому
держать равными. Решением владельца колонки остались, а равенство объявлено
**ненужным**: «на записи лежит снимок принятого, взятый приёмом один раз; на
файле — величины той копии, которой файл является сейчас». Уточнение
длительности — перечитали метаданные, сменили источник, нарезали длинную запись
— меняет вторые и не трогает первые. Это разные вопросы: «что человек прислал» и
«что лежит сейчас».
Отличимость «неизвестно» от нуля снята после ревью кода и по замеру: числовая
колонка хранилища пустого значения не держит вовсе и кладёт пустое нулём.
Платить за отличимость четвёртой колонкой-признаком либо текстовым типом у чисел
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными
метаданными отвергается отказом и не заводится.
## Последствия
- `+` страница списка не читает по строке файла на каждую запись;
- `+` смысл у двух пар чисел разный и записан нормой, а не подразумевается;
- `` в применённом шаге схемы навсегда остаются две колонки, повторяющие
величины строки файла; расхождение между ними — не поломка, и заметить его
нечем;
- `` запись, заведённая рукой в панели без величин, покажет человеку ноль.
@@ -0,0 +1,40 @@
# Метка убранного входа не выставляется вовсе, а не обнуляется
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
раздел «Метка убранного входа не выставляется вовсе»
## Решение
Признак поднятого входа остался, а метки убранного входа в метриках нет вовсе —
ни со значением единицы, ни со значением нуля. Ряд `transcriber_intake_up` с
меткой `telegram` не появляется после выкладки.
## Почему
Цитата источника:
> Признак поднятого входа остаётся, метка `telegram` у него больше не появляется.
> Ноль вместо неё читается как «вход есть, но не поднялся», то есть как поломка;
> владелец, у которого на этот признак стоит отбор, увидел бы аварию на ровном
> месте.
Отвергнут очевидный подход — оставить ряд со значением нуля. Он выглядит
бережнее (отбор не ломается), но говорит неправду: значение нуля у этого признака
означает именно неподнятый вход, а не отсутствующий.
С единственным оставшимся входом проверяемым осталось только **множество меток**:
значение нуля у него недостижимо, потому что страница метрик отдаётся тем же
сервером, что и приём, — чтобы прочитать признак, надо дотянуться до входа, о
котором он сообщает. Различать поднятый и неподнятый вход признак станет снова,
когда входов у сервиса станет больше одного.
## Последствия
- `+` наблюдатель не видит вечного нуля, который читался бы как незакрытая
авария.
- `` отбор вида `transcriber_intake_up == 0` по убранному входу перестаёт
срабатывать молча: исчезновение ряда ловится `absent()`, а не сравнением.
Владельцу, если такой отбор был заведён, править его руками.
- `` требование «различать поднятый и неподнятый» стало непроверяемым до
возвращения второго входа, и это сказано в самом требовании прямо.
@@ -0,0 +1,63 @@
# Вход Telegram убран целиком, а не выключен признаком
- **Дата:** 2026-08-15
- **Источник:** openspec/changes/archive/2026-08-15-remove-telegram-intake/design.md,
разделы «Context» и «Формы решения, между которыми выбирали»
## Решение
Вход Telegram убран из сервиса целиком: клиент, транспорт обновлений, отправитель
сообщений, сборка входа при старте, список допущенных людей, секция настроек и
зависимость. Убран **временно** — возврат заводится новым изменением вместе со
связью чата с учётной записью.
Хранилище при этом не тронуто: колонки `tg_chat_id`, `tg_reply_message_id` и
значение `telegram` перечня источников остаются в схеме вместе с записями,
которые их заполнили.
## Почему
Цитата источника:
> Сервис принимает записи двумя входами, и входы расходятся в главном: у записи,
> пришедшей из приложения, есть владелец, а у записи, пришедшей от бота, владельца
> нет и быть не может — связи чата с учётной записью сервис не ведёт. Пока такие
> записи заводятся, правило «каждая запись принадлежит человеку» действует
> наполовину.
Отвергнуты две формы решения, обе с названной ценой:
> **Выключить вход признаком, код оставить.** Признак `telegram.enabled` заведён
> 2026-08-13 и обязателен, а приём по HTTP владельца уже требует: одна правка
> ключа в боевом файле даёт «новых записей без владельца не заводится» ценой ноля
> строк кода и мгновенным возвратом. Отвергнуто по причине из раздела «Why»:
> двойная модель остаётся в коде, и оговорку про бота продолжает платить каждая
> следующая задача.
>
> **Сузить бота до исходящего канала.** Приём убрать, отправку оставить с одним
> адресатом — чатом владельца строкой настроек. Отвергнуто потому, что заводит
> понятие «канал уведомления владельца», которое тут же переделает задача
> `ntfy-delivery`.
Решениями, которые это изменение отменяет, были
[ADR-2026-08-13-telegram-intent-declared-not-inferred](ADR-2026-08-13-telegram-intent-declared-not-inferred.md)
и
[ADR-2026-08-13-telegram-outage-does-not-block-startup](ADR-2026-08-13-telegram-outage-does-not-block-startup.md):
оба нормировали подъём входа, которого больше нет. Доводы их при этом устояли и
понадобятся возврату — оба продолжают отвечать на вопрос «что делать с входом,
чей внешний собеседник недоступен».
## Последствия
- `+` модель одна: оговорка про запись без владельца ушла из спек приёма,
доступа, конвейера и хранилища.
- `+` зависимость `go-telegram-bot-api` ушла из манифеста вместе с двумя путями
утечки токена, которые проект закрывал двумя задачами.
- `` у сервиса не осталось входа, которым человек может воспользоваться:
приложения нет, личных ключей для программ нет, и до этих задач запись кладут
собранным руками запросом с сессией из браузера. Владелец окно принял.
- `` записи, застрявшие в конвейере на минуту выкладки, доходят до текста, и
ответа в чат по ним не уходит. Смягчения нет: чат и есть убираемый вход.
- `` бот у Telegram остаётся зарегистрированным и на вид живым, а ключ доступа —
в настройках выкладки под возврат входа (решение владельца от 2026-08-14).
Отправитель голосового не получит ни ответа, ни отказа.
@@ -0,0 +1,73 @@
# Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC
- **Дата:** 2026-08-22
- **Источник:** [../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md](../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md), разделы Р1 и Р3
## Решение
Сервис перестаёт вести вход сам. Кто пришёл, он узнаёт из заголовка
`Remote-User`, поставленного обратным прокси, который сходил к Authelia;
заголовку верят только с адреса из объявленного перечня, а адрес берётся у
самого соединения. Учётная запись заводится первым обращением с новым логином и
находится по нему же дальше.
Убраны целиком: корень `/auth` с тремя адресами, куки `transcriber_session` и
`transcriber_login`, сверка состояния и проверочный код PKCE, обмен кода
внутрипроцессным запросом к роутеру хранилища, слои предъявления куки и запрета
продления, приведение настроек провайдера к конфигу, секрет клиента и срок жизни
сессии.
## Почему
Цитата из источника, раздел Р3:
> Сервис не выдаёт браузеру ни куки, ни токена. Каждый запрос узнаётся заново, по
> заголовку, который прокси поставил, сходив к Authelia.
>
> Это и есть выгода задачи: отзыв доступа перестаёт ждать. Пока сервис выдавал
> значение, живущее семь суток, отозванный у провайдера человек работал до
> истечения этого значения, и другого канала отзыва не было.
Оттуда же, Р1 — почему доверие судится адресом соединения, а не пересылаемым
заголовком:
> **`X-Forwarded-For` и его родня.** Значение целиком задаёт тот, кто шлёт
> запрос. Барьер, который подделывается той же строкой, что и обходится, не
> барьер вовсе.
Отвергнут промежуточный вариант — заголовок как вход, сессия хранилища как
продолжение (Р3):
> Дешевле в работе (слой срабатывал бы раз в неделю, а не на каждом запросе), но
> возвращает ровно то, что задача убирает: значение, переживающее отзыв. Семь
> суток вернулись бы вместе с ним.
Контур к решению был готов заранее: обратный прокси уже отдавал `Remote-*` трём
соседним сервисам того же контура, а правила для этого сервиса там не было
вовсе — он не выложен.
## Последствия
- `+` Отзыв доступа действует со следующего запроса, а не через семь суток:
Authelia судит каждое обращение.
- `+` Секрет клиента исчез из конфига и из базы. Изъятие из инварианта «Секрет не
покидает конфиг» снято: чтение файла базы больше не равносильно чтению
секрета.
- `+` Своего протокола входа у сервиса не осталось — вместе с ним исчезли пять
накопившихся задач о его механике.
- `+` Панель закрывается доменом, а не правилом на литерал пути; обход подменой
знака перестаёт существовать.
- `` **Весь барьер держится на настройке прокси.** Прокси, добавляющий заголовок
вместо замены, открывает сервис любому под любым именем. Половину беды сервис
закрывает сам — запрос с двумя значениями заголовка не узнаёт никого, — вторую
проверить отсюда нечем: правило живёт в `pet-project-server`.
- `` **Логин у провайдера переиспользуем**, и новый его владелец получает архив
прежнего. Неизменяемого признака заголовок не приносит; не допускать
переиспользования — работа провайдера. Обратная сторона: переименование
заводит новую запись, а прежняя остаётся с архивом, который нечем ни слить, ни
убрать.
- `` Поиск учётной записи идёт на каждом запросе к области приложения вместо
раза в неделю. Уникальный индекс делает это одним обращением к базе; замера не
требовалось — сервисом пользуются единицы человек.
- `` Половина работы лежит вне репозитория: до того как правило прокси и правило
Authelia на домен заведут, сервис не узнает никого.
@@ -0,0 +1,98 @@
# Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком
- **Дата:** 2026-08-22
- **Источник:** [../research/storage-without-pocketbase.md](../research/storage-without-pocketbase.md) —
записка разведки о выборе хранилища
## Решение
PocketBase уходит из проекта целиком: состояние записей и метаданные переезжают
в SQLite, с которым сервис работает напрямую через `modernc.org/sqlite`, файлы
записей — в свой каталог со своей раскладкой, маршруты и слои — на `net/http`,
шаги схемы — на свой раннер. Панель администратора теряется и **не заменяется
ничем**: пока идёт стройка, остановленную запись возвращает в работу запрос к
базе.
**Два решения абзаца выше сменились при разметке изменения**, и заменившее
названо здесь.
Шаги схемы двигает библиотека `github.com/pressly/goose/v3`, а не свой раннер.
Инструмент выбрал владелец 2026-08-22: библиотека уже была в этом проекте и ушла
вместе с PocketBase, а из трёх норм, которые накат обязан выполнять, две
выполняет сама.
Остановленную запись возвращает в работу подкоманда `cmd/devtools resume`, а не
запрос к базе руками. Возврат сбрасывает не одно поле записи и пишет событие
журнала с происхождением `entity.EventOriginHuman`; рука за клавиатурой не делает
ни того, ни другого. Последствие ниже — «возврат остановленной в работу […]
делает запрос к базе руками» — читается этой сменой.
Доводы обоих решений записаны в
[design.md](../../openspec/changes/archive/2026-08-23-storage-without-pocketbase/design.md),
разделы «Шаги схемы двигает `goose`, а не свой раннер» и «Панель не заменяется
ничем, а возврат в работу делает подкоманда оснастки».
Запись заменяет три:
[ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md),
[ADR-2026-08-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md) и
[ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md).
**Что из заменённых решений подтверждается, а не отменяется.** Очередь остаётся
своей таблицей, захват — одним запросом с `RETURNING`, готовую библиотеку
очереди по-прежнему не берём: замер, которым это решено, снят на
`modernc.org/sqlite` — том самом драйвере, который остаётся и после ухода.
Отменяется у той записи одно слово: таблица перестаёт быть коллекцией.
Приложение остаётся в своём корне `/app/`
([ADR-2026-08-15-app-namespace](ADR-2026-08-15-app-namespace.md)). Файл записи
остаётся закрытым — но проверкой владельца в своём обработчике, а не защищённым
полем коллекции и коротким токеном.
## Почему
Разведка мерила не «хранилище против хранилища», а то, что библиотека держит в
этом коде. Цитата из источника:
> Разрез «хранилище против хранилища» вопроса не покрывает: библиотека держит
> шесть ролей сразу, и только две из них про хранение.
Довод, на котором стоял перевод, отпал сам. Источник, раздел о трёх доводах:
> Вход делает само приложение с 2026-08-22 […]: пришедшего называет заголовок
> прокси, а учётную запись заводит наш `EnsureUser`. Пользователи в коллекции
> есть **потому, что их пишет наш код**, а не провайдер библиотеки. Довод,
> которым отвергнут отвергнутый вариант, перестал быть верным.
Отвергнут вариант «уйти в два шага», оставив панель жить в промежутке, и
отвергнут решением владельца: панель на стройке заменяется запросом к базе, а
вторая порция работы стоит дороже, чем то, что она сберегает.
Обстоятельство, которое назначило момент:
> на сервере данных нет и сервис остановлен, поэтому смена стоит только кода.
> Дешевле она не станет никогда — каталог `internal/controller/http` прирастает
> кодом на чужих типах с каждой задачей.
## Последствия
- `+` периметр сервиса становится только нашим. Панель `/_/` исчезает вместе с
дефектом `/%5f/` из [../security.md](../security.md), а пространство
хранилища `/api/` — вместе с необходимостью держать его открытым ради файлов.
- `+` пропадает секрет, которого не было до перевода, — пароль суперпользователя
панели.
- `+` файлы ложатся своей раскладкой, и загрузка частями, узнавание по хеш-сумме,
удаление записи и вторая копия рядом становятся обычной работой с файлами.
- `+` из сборки уходят шесть модулей, достижимых только через библиотеку:
`imaging`, `mailyak`, `jwt`, `fexpr`, `cobra`, драйвер MySQL.
`modernc.org/sqlite` остаётся, и сборка по-прежнему обходится без CGO.
- `` владелец сервиса остаётся без панели. Правку записи, возврат остановленной
в работу и просмотр очереди до появления экранов делает запрос к базе руками.
Задачи `audiorecord-actions` и `play-recording-in-app` этим становятся не
улучшением, а заменой утраченного инструмента.
- `` шаги схемы, отдачу файла, ограничитель частоты и настройку базы пишем и
сопровождаем сами. Единственный писатель у `modernc.org/sqlite` — наша забота
с этого дня.
- `` раскладка каталога данных меняется необратимо. Цена сегодня нулевая:
стройка, на сервере пусто; после первой боевой записи она перестаёт быть
нулевой.
- `` 1497 строк контроллера и 3075 строк его проверок написаны на
`*core.RequestEvent` и переписываются целиком.
@@ -0,0 +1,76 @@
# Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина
- **Дата:** 2026-08-23
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
решение 4
## Решение
Отладочная подстановка заголовков входа не требует от настроек ничего сверх
самого предохранителя `[server] debug`. Решение владельца на чекпоинте записано
в источнике дословно:
> «предохранитель по адресам не делаем, полагаемся только на параметр debug».
Рассматривалось требование, чтобы при включённом предохранителе перечень
доверенных адресов состоял только из петлевых записей; оно снято вместе с
предикатом «петлевая запись», который заводился ровно ради него.
Согласованность с барьером узнавания при этом остаётся: подставленный заголовок
проходит тот же перечень доверенных адресов, что и пришедший, и судит адрес та
же функция. Предохранителем это не служит — «от конфига она не требует ничего и
круга тех, кто мог назваться кем угодно, не расширяет».
## Почему
Решение покупает работоспособность отладочного входа там, где адрес пира не
петлевой:
> отладочный вход работает **внутри контейнера** — адрес пира там принадлежит
> сети докера, и она же стоит в боевом перечне, — а локальный прогон не
> переставляет перечень доверенных адресов на петлевой: петлевые записи
> добавляются к тем, что в нём уже стоят.
Цена названа в источнике прямо, и владелец принял именно её:
> Что этим потеряно, и это надо назвать прямо: боевую поломку больше не ловит
> машина. Сервис, поднятый в бою с включённым предохранителем и заполненной
> имитацией, отдаст архив всякому, кто дотянулся до него с доверенного адреса, —
> а доверенный адрес в бою это адрес обратного прокси, то есть **любой запрос,
> пришедший обычным путём**.
Между боевой выкладкой и открытым входом остаётся три вещи, и других нет:
умолчание предохранителя «выключено»; отказ старта при заполненной имитации без
предохранителя; боевой конфиг, который рендерит шаблон Ansible, а не
копируют с машины разработчика.
Отвергнуты вместе с адресным предохранителем ещё два подхода. **Принудительно
слушать петлевой адрес при включённом предохранителе** — «меняет поведение молча
… и закрывает ровно то, что решение покупает: внутри контейнера сервис слушает не
петлю». **Новый ключ `[server] listen`** — «публичная поверхность настроек ради
предохранителя, которого решением владельца нет».
## Почему это ADR
Триггер — **намеренный отказ** от очевидного подхода. Требовать петлевой перечень
при включённом отладочном входе — первое, что предлагает всякий, кто читает
модель угроз; отказ от этого оставляет боевую поломку, которую машина не
исключает, и объяснить его надо один раз здесь, а не на каждом ревью, которое
эту дыру находит заново.
## Последствия
- `+` Отладочный вход работает и на машине разработчика, и внутри контейнера:
перечень доверенных адресов остаётся границей доверия, а не признаком отладки.
- `+` Локальный прогон не переставляет перечень на петлевой — петлевые записи к
нему добавляются.
- `+` Предиката «петлевая запись» в коде нет вовсе: он заводился ради одной этой
проверки.
- `` **Машина не исключает боевую поломку «конфиг с `debug = true` и
заполненной имитацией».** Такой сервис поднимется на любом перечне доверенных
адресов и назовёт своим именем всякого, кто пришёл обычным путём. Записано это в модели
угроз, [security.md](../security.md), «Периметр», и в спеке
[access](../../openspec/specs/access/spec.md).
- `` Одна из трёх опор лежит вне репозитория: шаблон Ansible из
`pet-project-server`. Проверить её отсюда нечем — тем же свойством обладает
правило прокси про заголовки `Remote-*`.
@@ -0,0 +1,86 @@
# Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс
- **Дата:** 2026-08-23
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
решения 1, 6, 7 и 10
## Решение
Заголовки входа на машине разработчика ставит сам сервис — отдельным слоем
цепочки корня приложения, а не вспомогательным процессом рядом:
> отдельный слой цепочки корня приложения, стоящий **перед**
> `TrustedHeaderIdentity` и **после** ограничителя частоты. Он правит заголовки
> запроса и ничего больше не делает: учётной записи не заводит, отказов не
> выдаёт, в контекст не пишет.
Включают слой два новых ключа настроек — предохранитель `[server] debug` и
секция значений `[auth.test_headers]`. Прежний вспомогательный процесс уходит:
> **Решено** владельцем на чекпоинте: подкоманда удаляется. Назначения у неё не
> остаётся — всё, ради чего её поднимали, делает сам сервис, — и второго способа
> входить локально не остаётся тоже.
Имена заголовков служат именами ключей секции, но набор принимаемых имён
порождают константы транспорта: дом у имён остаётся один, а ключ, не совпавший
ни с одним из них, роняет старт.
## Почему
Владелец назвал желаемое: один бинарник, различия между запусками — в конфиге.
Дизайн записал это целью:
> Локальный запуск идёт одним процессом и одной командой; **вход** — то, кем
> назвался пришедший, — отличает тестовый прогон от боевого содержимым файла
> настроек, и ничем больше.
Довод в пользу слоя перед узнаванием, а не внутри него:
> Отлаживается **та же** ветка кода, что работает в бою: подставленный заголовок
> неотличим от пришедшего от Caddy к моменту, когда его читает узнавание.
Отвергнуты три очевидных подхода, и у каждого названа цена. **Подстановка внутри
`TrustedHeaderIdentity`** — «узнавание получило бы второй источник значений и
ветку, которой в бою нет. Отлаживалась бы не боевая ветка, а её отладочный
двойник». **Произвольная карта имён заголовков в конфиге** — «опечатка
`Remote-Usr` даёт „сервис меня не узнаёт“ без единого следа». **Вырезать
подстановку из боевой сборки тегом сборки:**
> сборка образа в гейте не проверяется вовсе (`CLAUDE.md`, «Гейт»), и тег,
> забытый в одной ступени, дал бы ровно ту тишину, которой избегает пункт 3.
## Почему это ADR
Триггер сработал дважды. **Дорогой откат:** решение заводит два имени ключа
настроек, а имя ключа конфига `CLAUDE.md` называет необратимым; вернуться к
вспомогательному процессу значит поднять удалённую подкоманду, убрать оба ключа
из настроек и переписать рецепт локального запуска, разошедшийся по образцу
конфига, `README.md`, `CLAUDE.md` и конвенции настроек. **Намеренный отказ:**
вырезать отладочный код из боевой сборки тегом сборки — то, что делают по
умолчанию, и отказ от этого объясняется один раз здесь, а не на каждом вопросе
«почему подстановка вообще есть в боевом бинарнике».
## Последствия
- `+` Локальный запуск идёт одним процессом и одной командой; приложение
открывают по адресу сервиса, второго порта нет.
- `+` Отлаживается боевая ветка узнавания: подставленный заголовок неотличим от
пришедшего от прокси к моменту, когда его читают.
- `+` Второго способа входить локально не остаётся, и документация перестаёт
каждый раз говорить, какой из способов чей.
- `+` Имена заголовков остаются с одним домом — константами транспорта; ключ, не
совпавший ни с одним из них, роняет старт и называет принимаемые имена.
- `` **Местный инструмент больше не воспроизводит поломки контура.** Цена
названа в источнике прямо: два значения `Remote-User`, заголовок с
недоверенного адреса, цепочка `X-Forwarded-For` — всё это теперь
воспроизводит только автотест, ставящий заголовок сам.
- `` В боевом бинарнике появляется код, называющий пришедшего без провайдера.
Что его держит и чего у него нет — [ADR-2026-08-23-no-address-guard-for-debug-login](ADR-2026-08-23-no-address-guard-for-debug-login.md).
- `` У ключа `[server] debug` закрытый перечень следствий, и держать его
придётся руками: новое поведение привязывается к ключу только отдельным
решением владельца и получает своё требование спеки
[access](../../openspec/specs/access/spec.md). Ключ с открытым перечнем
следствий обрастает ими молча.
- `` Каждый новый логин имитации заводит учётную запись, а удалять их сервис не
умеет. Локальная база ронится и пересоздаётся свободно, в бою подстановка
выключена — но лишние записи копятся.
+30 -4
View File
@@ -21,7 +21,10 @@
- Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, дата — когда решение реально принято. - Имя файла — `ADR-ГГГГ-ММ-ДД-slug.md`, дата — когда решение реально принято.
Слаг **английский по сути, а не транслитом**: `queue-as-table`, не Слаг **английский по сути, а не транслитом**: `queue-as-table`, не
`ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`. `ochered-tablicej`. Форму имени и слаг проверяет `docs.py check`.
- Записи неизменяемы: передумали — новая запись, старой ставится статус. - Записи неизменяемы **в решении**: передумали — новая запись, старой ставится
статус. Уточнить прежнюю запись можно только строкой «*Уточнено ГГГГ-ММ-ДД:*» в
разделе «Последствия» и только фактом, который решения не меняет, — например
действующим адресом того, что решение завело.
- Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и - Активная запись статуса не имеет. Значений два: `заменено на ADR-…` и
`устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и `устарело`; ставятся полем меты записи — `- **Статус:** …` рядом с датой и
источником, а не абзацем в теле. источником, а не абзацем в теле.
@@ -32,14 +35,37 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| --- | --- | --- | | --- | --- | --- |
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | | | 2026-08-23 | [Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина](ADR-2026-08-23-no-address-guard-for-debug-login.md) | |
| 2026-08-23 | [Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс](ADR-2026-08-23-test-headers-substituted-by-service.md) | |
| 2026-08-22 | [Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком](ADR-2026-08-22-storage-without-pocketbase.md) | |
| 2026-08-22 | [Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC](ADR-2026-08-22-login-by-trusted-header.md) | |
| 2026-08-15 | [Node зовётся контейнером, а не ставится на машину разработчика](ADR-2026-08-15-node-in-container-not-on-machine.md) | |
| 2026-08-15 | [Приложение живёт своим пространством адресов, а не общим с хранилищем](ADR-2026-08-15-app-namespace.md) | |
| 2026-08-15 | [Страница архива задаётся ключом, а не номером](ADR-2026-08-15-cursor-paging.md) | |
| 2026-08-15 | [Длительность и размер — снимок принятого колонками записи](ADR-2026-08-15-record-snapshot-columns.md) | |
| 2026-08-15 | [Вход Telegram убран целиком, а не выключен признаком](ADR-2026-08-15-telegram-intake-removed-temporarily.md) | |
| 2026-08-15 | [Обязательность владельца держит схема, а не приём](ADR-2026-08-15-owner-required-by-schema.md) | |
| 2026-08-15 | [Метка убранного входа не выставляется вовсе, а не обнуляется](ADR-2026-08-15-removed-intake-has-no-metric-label.md) | |
| 2026-08-14 | [Предел простоя остаётся часом, хотя он короче самой работы](ADR-2026-08-14-stuck-limit-stays-an-hour.md) | |
| 2026-08-14 | [Ответ распознавателя хранится дословно, двоичной формой и вложением](ADR-2026-08-14-provider-payload-stored-verbatim.md) | |
| 2026-08-14 | [Остановка записи — признак, а не рубеж](ADR-2026-08-14-halt-is-a-flag-not-a-stage.md) | |
| 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.md) | |
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | устарело |
| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | |
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) |
| 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | | | 2026-08-12 | [Каталог данных задаётся одним ключом `[storage] data_dir`](ADR-2026-08-12-single-data-dir-config-key.md) | |
| 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | | | 2026-08-11 | [Границу распознавания доменного признака держит норма, а не код](ADR-2026-08-11-domain-marker-boundary-by-norm.md) | |
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | | | 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | | | 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
| 2026-08-11 | [Приложение пишем на Vue, а Node входит в гейт и в образ](ADR-2026-08-11-spa-on-vue.md) | | | 2026-08-11 | [Приложение пишем на Vue, а Node входит в гейт и в образ](ADR-2026-08-11-spa-on-vue.md) | |
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | | | 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | | | 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
| 2026-08-11 | [Проверки не зовут внешних программ](ADR-2026-08-11-stub-adapters-in-tests.md) | | | 2026-08-11 | [Проверки не зовут внешних программ](ADR-2026-08-11-stub-adapters-in-tests.md) | |
Решения, принятые до заведения канона 2026-08-10, источника в архиве изменений Решения, принятые до заведения канона 2026-08-10, источника в архиве изменений
+316 -88
View File
@@ -5,126 +5,307 @@
помечены маркером долга и переезжают туда первой же задачей, которая их трогает. помечены маркером долга и переезжают туда первой же задачей, которая их трогает.
Документ описывает **сегодняшнее** устройство. Куда проект идёт — в Документ описывает **сегодняшнее** устройство. Куда проект идёт — в
[passport.md](passport.md) и в [tasks/ROADMAP.md](../tasks/ROADMAP.md); что из [passport.md](passport.md) и в [tasks/BACKLOG.md](../tasks/BACKLOG.md); что из
этого ещё не решено — в разделе «Открытые вопросы». этого ещё не решено — в разделе «Открытые вопросы».
Заведены три capability: Заведённые capability нормируют **поведение сервиса** для его потребителей —
все до одной. Инструмент, которым сервис собирают, спеками не нормируется вовсе:
у набора проверок и сборки другой потребитель — тот, кто собирает, — и решением
от 2026-08-13 его нормы живут в самих шагах, их проверках и
[conventions/go-linters.md](conventions/go-linters.md).
- [intake](../openspec/specs/intake/spec.md) — **только приём по HTTP**: его - [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
нормируют проверки, написанные задачей `http-handler-tests-never-green` входов**: приём только от узнанного, имя отправителя не доходит ни до
2026-08-11; хранилища, ни до журнала, метка метрики несёт только известное расширение, а
наблюдатель видит единственный поднятый вход. Задачи
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
`pocketbase-storage` и `oidc-login` 2026-08-12,
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
2026-08-14, `storage-without-pocketbase` 2026-08-22;
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, состояние «мертва» и пауза перед задачи и срок его протухания, число попыток, остановка признаком, пауза перед
повтором: задачи `errors-as-instead-of-typecast` 2026-08-11 и повтором и молчание конвейера наружу: задачи
`pocketbase-storage` 2026-08-12. Переходы состояний и отмена контекста посреди шага остаются `errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12,
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
2026-08-14 и `storage-without-pocketbase` 2026-08-22. Переходы состояний и отмена
контекста посреди шага остаются
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки; долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` и её файл, как файл отдаётся и что видит владелец: задачи `pocketbase-storage`
2026-08-12. 2026-08-12 и `storage-without-pocketbase` 2026-08-22. Последняя убрала
встроенное хранилище целиком: база стала своей, файлы — своим каталогом,
панель владельца исчезла и не заменена ничем;
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
целиком и вложением, как из сохранённого строится структура реплик без
повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
Задача `record-centric-model` 2026-08-14;
- [archive](../openspec/specs/archive/spec.md) — **архив своих записей глазами
приложения**: пространство адресов `/app/` и единая форма отказа с
машиночитаемым кодом, пределы, которыми сервис ограничивает загрузку, и само
чтение — страница записей ключом, карточка без текста и текст названного вида.
Здесь же обязанность, переехавшая с убранного опроса готовности: причину
остановки владелец записи узнаёт карточкой. Задача `json-api-for-spa`
2026-08-15;
- [webapp](../openspec/specs/webapp/spec.md) — **приложение в браузере**: чем
сервис его отдаёт, каким адресом оно открывается, что делает обновление
страницы посреди него и что человек видит, открыв его. Здесь же правило
неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения
ответов и то, что раздача пишет в журнал. Задача `spa-skeleton` 2026-08-15;
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
его дальше: узнавание по заголовку доверенного источника, заведение учётной
записи первым обращением и то, какие адреса остаются открытыми. Собственный
вход через OIDC жил здесь с 2026-08-12 по 2026-08-22 и убран задачей
`trusted-header-login` — вместе с куками, сессией и её сроком. Здесь же разграничение записей по владельцу: принятая запись
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
записи не бывает вовсе — колонка владельца пустого значения не принимает.
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14. Здесь же
изъятие отладочного запуска: при включённом предохранителе `[server] debug`
заголовки входа подставляет сам сервис значениями из `[auth.test_headers]`с
отказами старта, строкой журнала и закрытым перечнем следствий ключа. Задача
`config-test-headers-login` 2026-08-23; решения —
[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md)
и [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md).
Поведение прочих узлов, включая приём из Telegram, по-прежнему живёт только в Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде.
коде. Задача, которая его трогает, дописывает спеку своей capability. Задача, которая его трогает, дописывает спеку своей capability.
## Принципы ## Принципы
- **Один процесс.** Бот, HTTP-сервер и фоновые воркеры живут в одном бинарнике и - **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно. делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища, воркер - **Очередь таблицей.** Состояние задачи лежит таблицей базы; неделимость
забирает работу одним запросом с захватом. Внешний брокер не заводим: нагрузка захвата и порядок выборки нормирует
— единицы записей в день (оценка владельца, не замер). Готовую библиотеку [pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим».
очереди тоже не заводим — решено 2026-08-11, Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца,
не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение [ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение
кандидатов в [research/job-queue.md](research/job-queue.md). кандидатов в [research/job-queue.md](research/job-queue.md). Решение пережило
- **Шаг конвейера идемпотентен по повтору.** Задача, брошенная на середине, уход встроенного хранилища: замер снят на том же драйвере, и отменилось у него
достаётся снова по истечении срока захвата и проходит шаг заново. одно слово — таблица перестала быть коллекцией.
- **Ядро зависит от интерфейсов.** `internal/service` знает только - **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда
`internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в задача возвращается в работу, нормирует
`main.go`. [pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
в работу»; здесь это принцип письма шага, а не описание поведения.
- **Подставной собеседник в боевом бинарнике объявлен своим ключом.** Дом ему —
код или оснастка; в боевом бинарнике он появляется только отдельным решением
владельца и только под ключом, названным своим предметом: имитацию заголовков
входа объявляет секция `[auth.test_headers]`, подмену распознавания — правка
кода (`internal/adapter/recognizer/memory.go`). Ключ, названный общим словом,
обрастает следствиями молча, и выключить его перестаёт означать «сервис ведёт
себя как в бою». Предохранитель `[server] debug` вторым именем собеседнику при
этом не служит и правилу не противоречит: собой он не называет ничего, а держит
**закрытый** перечень следствий, и перечень этот ведёт спека
[access](../openspec/specs/access/spec.md), «Предохранитель отладки включает
только подстановку заголовков». Новое следствие вешается на ключ только новым
требованием той же спеки.
- **Чистая архитектура.** Зависимости направлены внутрь, к домену: внутренний
слой не знает внешнего никогда. `internal/service` знает только
`internal/contract`; ffmpeg, Yandex и хранилище подставляются в точке входа
`cmd/transcriber`. Слои, их дома и словарь модели — раздел «Слои и модель
домена» ниже. Правило механизировано тестами-сканерами `internal/archrules`, и
они же держат обратные направления: транспорты не знают друг о друге, адаптер
не знает ни ядра, ни транспортов, транспорт не знает адаптеров. Изъятие,
разрешавшее транспорту знать адаптер хранилища, снято 2026-08-22 вместе с
предметом: HTTP-поверхность была роутером встроенного хранилища, а стала своей,
и правило на это направление заведено впервые.
## Слои и модель домена
**Подход — чистая архитектура.** Зависимость идёт только внутрь: домен не знает
ни хранилища, ни транспорта, а знание о внешнем мире живёт интерфейсом в портах
и реализацией в инфраструктуре.
| Слой | Дом | Что живёт | Чего не знает |
| --- | --- | --- | --- |
| Домен | `internal/entity` | сущности, объекты-значения, доменные события, инварианты значениями | ничего, кроме стандартной библиотеки и единой точки времени `internal/clock` |
| Порты | `internal/contract` | интерфейсы репозиториев и внешних служб, типизированные ошибки | реализаций |
| Прикладной слой | `internal/service` | шаги конвейера: порядок, повтор, приговор | адаптеров и входов |
| Инфраструктура | `internal/adapter` | репозитории, ffmpeg, Yandex, шаги схемы | ядра и входов |
| Входы | `internal/controller` | HTTP и пул воркеров | друг друга |
| Сборка | `cmd/transcriber` | подстановка реализаций в порты, подъём сервера и пула | — |
Направления держат тесты-сканеры `internal/archrules` — все, кроме чистоты
самого домена. **Её не держит ничто**: правила смотрят ядро, входы и адаптеры, а
импорт внешней библиотеки в `internal/entity` сегодня пройдёт молча.
**Модель домена ведётся тактическими шаблонами DDD.** Шаблон называется здесь
вместе со своим сегодняшним предметом — перечень растёт вместе с моделью:
- **Сущность** — `entity.AudioRecord`: у неё идентичность и поведение
(`MoveToState`, `Halt`, `Resume`, `Postpone`), а не набор полей при сервисе;
- **корень агрегата** — она же: файлы, тексты, структура, попытки распознавания и
журнал событий принадлежат записи и живут её идентификатором, а правит агрегат
держатель захвата;
- **объект-значение** — `entity.Stage` со своими сроками, `entity.StuckLimits`,
`entity.Replica`, `entity.RecognitionResult`: сравниваются по значению и своей
идентичности не имеют;
- **доменное событие** — `entity.RecordEvent`: что случилось с записью, чьей
рукой и чем кончилось;
- **репозиторий** — интерфейсы `internal/contract`, реализации под
`internal/adapter/repo`;
- **служба домена** — правило, не принадлежащее одной сущности, живёт функцией
пакета домена (`entity.WorkingStages`, `entity.SanitizeOriginalFilename`);
- **фабрика** — `entity.NewInProgressResult` и соседи: значение приходит
согласованным, а не заполняется полями снаружи.
**Анемичной модели не заводим.** Новое поведение записи ищет дом сначала в
домене; прикладной слой назначает порядок шагов, а не правила. Признак нарушения
наблюдаем: правило о записи, записанное в `internal/service` условием над её
полями, принадлежит `internal/entity`.
Изъятий у подхода сегодня нет: последнее — транспорт знал адаптер хранилища —
снято задачей `storage-without-pocketbase` 2026-08-22. Своя отдача файла и свои
маршруты вернули транспорту независимость от инфраструктуры, а узнавание
пришедшего приходит ему интерфейсом `contract.UserRepository`.
## Компоненты ## Компоненты
Каждый — строкой со ссылкой на capability, а не пересказом её требований. Каждый — строкой со ссылкой на capability, а не пересказом её требований.
<!-- канон: поведение → openspec/specs/intake, delivery --> <!-- канон: поведение → openspec/specs/intake, pipeline, storage; ещё НЕ переехало: приведение записи к рабочему формату -->
| Компонент | Где | Что делает | | Компонент | Где | Что делает |
| --- | --- | --- | | --- | --- | --- |
| Telegram-бот | `internal/controller/tg` | Принимает голосовые, аудиофайлы и документы с аудио, скачивает их, заводит задачу | | HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, подстановка заголовков входа отладочного запуска, узнавание, требование учётной записи. Условия, при которых звено подстановки встаёт в цепочку, нормирует [access](../openspec/specs/access/spec.md), «Отладочный запуск называет пришедшего настройками» |
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи | | Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Воркеры | `internal/controller/worker` | Крутят по одному шагу конвейера, опрашивая базу | | Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Сервис расшифровки | `internal/service` | Конвейер: приём, конвертация, распознавание, отдача результата |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности | | Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit | | Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
| Отправитель Telegram | `internal/adapter/telegram` | Отправка текста, деление длинного по словам | | Репозитории | `internal/adapter/repo/sqlite` | Учётные записи, записи, файлы, тексты, структура, попытки распознавания и журнал событий — таблицами базы; захват — одним запросом с `RETURNING` по пишущему соединению |
| Репозитории | `internal/adapter/repo/pocketbase` | Задачи и файлы коллекциями хранилища; захват — сырым запросом | | Файлы записей | `internal/adapter/repo/sqlite`, `store.go` | Подкаталог на запись под её идентификатором; укладка атомарна — временное имя рядом и переименование |
| Панель владельца | там же, `panel.go` | Правка задачи в панели проходит те же правила перехода, что и правка из кода | | Шаги схемы | `internal/adapter/repo/sqlite/migrations` | Файл на шаг, версия — число в начале имени; накатывает `pressly/goose/v3` под своим замком |
| Оснастка владельца | `cmd/devtools` | Возврат остановленной записи в работу. Панели у сервиса нет и не будет: экраны правки приносят отдельные задачи |
| Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет |
| Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
<!-- канон: поведение → openspec/specs/pipeline; ещё НЕ переехало: спека заведена, но это в ней не описано --> Цепочка рубежей — `uploaded``normalized``submitted``transcribed`
`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его
Конвейер: `created``converted``transcribe``done` либо `failed`. Три [pipeline](../openspec/specs/pipeline/spec.md), «Рубеж записи называет
воркера двигают по одному переходу, каждый опрашивает базу раз в секунду. Задача, достигнутое». Отказ рубежом не является: он ставит признак остановки, а рубеж
исчерпавшая попытки, уходит в `dead` мимо этой цепочки: её переводит туда не шаг, сохраняется — там же, «Остановка записи — признак, а не рубеж». Шаг выбирается
а тот, кто её захватил. по рубежу одним местом, воркеры к шагам не привязаны, а их число приходит
настройкой.
## Внешние границы и форматы ## Внешние границы и форматы
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
Файл скачивается по ссылке `file.Link(token)` обычным `http.Get`. Telegram не
отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с - **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением. `UsePathStyle`. Ключ объекта — имя файла записи, то есть её идентификатор с
расширением; идентификаторы строит `internal/ident` и они ULID, а не UUID.
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель - **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
`deferred-general`, авторизация заголовком `Api-Key`. Распознавание `deferred-general`, авторизация заголовком `Api-Key`. Распознавание
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
через `operation.api.cloud.yandex.net:443`, текст читается потоком. через `operation.api.cloud.yandex.net:443`, текст читается потоком.
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`. - **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
- **SQLite через `modernc.org/sqlite`.** Драйвер на чистом Go: CGO сборке не
нужен. База и файлы записей лежат под одним каталогом данных.
- **`github.com/BurntSushi/toml`.** Формат единственного источника настроек.
Негодный TOML останавливает старт; незнакомый ключ разбор не судит и молча
отбрасывает — наблюдение и чем оно проверено, в
[research/toml-unknown-keys.md](research/toml-unknown-keys.md).
- **`github.com/pressly/goose/v3`.** Шаги схемы — библиотекой, а не командной
строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте.
Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных
берём сами.
- **Node и его установщик пакетов.** Нужны только сборке приложения и на машину
не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`.
Требованием к машине разработчика поэтому становится docker. Реестр пакетов —
сетезависимый адрес набора проверок; все такие перечислены в
[CLAUDE.md](../CLAUDE.md), «Гейт».
## Эксплуатация ## Эксплуатация
- **Где работает, что рядом, кто перезапускает:** один контейнер на личном - **Где работает, что рядом, кто перезапускает:** один контейнер на личном
сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом — сервере, разворачивает и перезапускает Ansible из `pet-project-server`. Рядом —
обратный прокси, который публикует HTTP-порт наружу. обратный прокси, который публикует HTTP-порт наружу.
- **Порядок выкладки: конфиг после образа.** Прежде здесь стояло правило,
разное для двух ключей секции Telegram; с убранным входом оно потеряло предмет
целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть.
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
сервис поднимает молча. Чем это обеспечено и как проверено —
[research/toml-unknown-keys.md](research/toml-unknown-keys.md); тем же
свойством безопасно и обратное направление: прежний образ поднимается на
конфиге с ключами, которых он ещё не знает.
- **Откат образа на версию до 2026-08-22 не работает вовсе.** Каталог данных
сменил раскладку целиком: база зовётся другим файлом, файлы записей лежат
другими путями, а учёт применённых шагов ведёт другая таблица. Прежний образ на
таком каталоге поднимется, накатит **свои** шаги в пустое место и заведёт
вторую, чужую схему рядом. Лечится повторной выкладкой вперёд; обратного шага
схемы нет и не планируется.
Прежние два порога — шаги `202608140002` и `202608220001` — этим поглощены: до
выкладки `record-centric-model` откат работал, после перестал, а с уходом
встроенного хранилища перестал окончательно. Окно порога сегодня пусто: сервис
не выложен. Строка стоит здесь потому, что порог принято называть прямо, а не
потому, что риск сегодня чем-то грозит.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает - **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
медленно» читается вместе с тем, что таймаута нет ни у одного обращения медленно» читается вместе с тем, что таймаута нет ни у одного обращения
наружу — [database.md](database.md), «Настройки с числовым значением»: наружу — [database.md](database.md), «Настройки с числовым значением»:
<!-- канон: поведение → openspec/specs/conversion, recognition --> <!-- канон: поведение → openspec/specs/intake, pipeline, storage -->
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации | | Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; карточка записи отдаёт рубеж `done` с пустым перечнем доступных видов текста |
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» | | *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | База (файл на диске) | Старт кончается отказом с именем шага схемы либо шаг падает на каждом запросе | Ожидание занятой базы задано числом; исчерпав его, операция отказывает, и запись остаётся пригодной к повтору | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** пользователь Telegram — сразу, по молчанию бота - **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи:
или по сообщению об ошибке. Владелец — по метрике остановленная запись отдаёт признак остановки и её причину. Владелец — по метрике
`transcriber_worker_job_count` с меткой `error="true"` и по логам контейнера. `transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
Отдельного оповещения нет. называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
- **Характер потока:** непрерывный, но разреженный. Бот держит длинный опрос, имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
три воркера опрашивают базу вхолостую с паузой из «падает приведение» отличается от «падает распознавание». Плюс логи
контейнера. Отдельного оповещения нет.
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
на смену рубежа, на остановку и на возврат в работу; ни один шаг конвейера на
него не смотрит. Читается запросом к базе: ни панели, ни экрана у него нет.
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением». [database.md](database.md), «Настройки с числовым значением».
## Единые точки проекта ## Единые точки проекта
| Что | Где | | Что | Где |
| --- | --- | | --- | --- |
| Приём аудио и заведение задачи | `TranscribeService.createTranscribeJob` — через него идут оба входа | | Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище |
| Правка задачи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает | | Возврат остановленной записи в работу | `cmd/devtools resume` — зовёт домен и пишет событие журнала записи с происхождением «человек»; колонок сама не пишет |
| Захват задачи воркером | `TranscriptJobRepository.FindAndAcquire` — один запрос с `RETURNING` | | Выдача идентификатора строки | `internal/ident` — ULID в нижнем регистре, монотонный внутри миллисекунды; разбор пришедшего снаружи — там же |
| Подключение к базе | `internal/adapter/repo/sqlite.Open` — пишущее соединение одно, чтение своим пулом, настройки строкой подключения обоих |
| Накат схемы | `internal/adapter/repo/sqlite.Migrate` — до подъёма входов и до старта воркеров, под замком каталога данных |
| Раскладка файлов записи | `internal/adapter/repo/sqlite.Store` — подкаталог на запись; путь на диске за её пределы не выходит |
| Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру |
| Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг | | Рабочая копия файла на диске | `FileRepository.Localize`, `Stage`, `StageEmpty` — они же дают единственный способ её убрать (`WorkFile.Close`); зовёт его шаг |
| Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния | | Переход записи на рубеж | `entity.AudioRecord.MoveToState` — чистит служебные поля прошлого рубежа и ставит время входа |
| Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю | | Откладывание работы | `entity.AudioRecord.Postpone` — ставит паузу и снимает захват, рубежа не трогая |
| Остановка и перезапуск | `entity.AudioRecord.Halt` и `Resume`; запись причины и события — `TranscribeService.halt`, одно место на все причины |
| Разбор конфигурации | `internal/config.LoadConfig` | | Разбор конфигурации | `internal/config.LoadConfig` |
| Чтение времени | `internal/clock``Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
| Метрики | `internal/metrics`, префикс имени `transcriber_` | | Метрики | `internal/metrics`, префикс имени `transcriber_` |
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` | | Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
| Отображение доменной ошибки в ответ | `internal/controller/http.mapDomainError` — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой `OneErrorForm`, стоящий снаружи всех прочих |
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
| Узнавание предъявителя | `sqlite.UserRepository.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом `contract.UserRepository``internal/controller/http.TrustedHeaderIdentity` |
| Приём значения заголовка | `internal/entity.AcceptProviderLogin`, `AcceptDisplayName`, `AcceptEmail` — правило одно на все способы представиться |
| Имена заголовков входа | `internal/controller/http.IdentityHeaderNames` вместе с константами рядом — тройка `Remote-*` перечисляется отсюда, а не по месту. она же порождает набор имён, принимаемых секцией `[auth.test_headers]`; что делает старт с ключом вне набора, нормирует [access](../openspec/specs/access/spec.md), «Настройка, открывающая вход всем, роняет старт» |
| Сверка адреса пира с перечнем доверенных | `internal/controller/http`, `identity.go``peerAddress` и `isTrusted`. Зовут их узнавание, подстановка заголовков отладочного запуска и ограничитель частоты. Свой сверщик разошёлся бы с общим молча — разбор разворачивает IPv4 в оболочке IPv6, и разница пришлась бы ровно на те адреса, ради которых он заводится |
| Ограничитель частоты | `internal/controller/http.RateLimit` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса |
Единых точек, которых **нет** и которые ожидались бы: идентификаторы Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось.
генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()` Время ушло из перечня отсутствий 2026-08-13 — его читает `internal/clock`, и
по месту, отображения доменной ошибки в код HTTP-ответа нет — обработчик решает запрет держит линтер; отображение доменной ошибки — 2026-08-15 задачей
сам. `json-api-for-spa`, и до неё обработчик решал сам: опрос отвечал `404` на упавшую
базу, а приём — `500` на негодный файл; выдача идентификаторов — 2026-08-22
задачей `storage-without-pocketbase`, и до неё их выдавало встроенное хранилище
своим алфавитом, а сервис звал `uuid.NewString()` по месту.
## Деплой ## Деплой
@@ -133,33 +314,73 @@
образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой
`inv pl -- transcriber` из `pet-project-server`. `inv pl -- transcriber` из `pet-project-server`.
Сборка двухступенчатая, финальный слой — alpine с `ca-certificates` и `ffmpeg`, Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение
процесс работает под непривилегированным пользователем `transcriber`. собирается первым — вшивание требует готового каталога, — а в рабочий слой Node
не попадает. Финальный слой — alpine с `ca-certificates` и `ffmpeg`, процесс
работает под непривилегированным пользователем `transcriber` — в образе он
назван числом, `USER 1000:1000`, а не именем: имя разрешает в идентификатор сам
образ, и хост, которому надо понять владельца файлов в смонтированном каталоге,
разрешить его не может. Числа те же, что при заведении пользователя.
Ступень бинарника собирает **одну** точку входа — `./cmd/transcriber`, а не весь
пакет: рядом в `cmd/` живёт `devtools`, оснастка разработчика, и в образе ей
делать нечего.
Оснастка лежит **одним** пакетом с подкомандами, а не пакетом на инструмент, и
это счёт, а не вкус: каждый отдельный пакет стоит четырёх мест — строка сборки
здесь, «Деплой» в этом файле, «Команды» в памятке, `README`, — и забытая строка
сборки тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
четыре места однажды, сколько бы подкоманд в нём ни завелось.
Ступень приложения стоит на образе с glibc, а не на alpine, и решает это не вес:
у musl запрос имени идёт `A` и `AAAA` разом и ждёт **оба** ответа, поэтому
DNS-сервер, молчащий на `AAAA`, оставляет установщика пакетов без адреса при
живом `A`. Установщик уходит в повторы с нарастающей паузой на каждом пакете, и
сборка не краснеет, а **висит** — исход хуже красного. Слои этой ступени в
рабочий слой не едут, поэтому её вес остаётся ценой одной сборки.
**По весу финальный образ от ступени приложения не растёт вовсе:** она отдаёт
следующей только собранное, а сама в рабочий слой не копируется. Вшитое
приложение прибавляет к бинарнику 86 072 байта. Время сборки образа не
замерялось и замеряться не будет — решение владельца от 2026-08-15.
## Открытые вопросы ## Открытые вопросы
- **Учётные записи.** Вход через OIDC, провайдер — Authelia, а ответ провайдера - **Учётные записи.** Кто пришёл, сервис узнаёт заголовком, который ставит
обрабатывает PocketBase, а не наш код обратный прокси, сходив к Authelia; учётная запись заводится первым обращением
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Не решено, где живёт сессия и находится по логину у провайдера. Задача `trusted-header-login` 2026-08-22.
и как связываются пользователь Telegram и пользователь веба. Панель Собственного входа, куки и срока сессии у сервиса не осталось — отзыв доступа
администратора при этом Authelia не закрывает: у неё свой пароль судит провайдер на каждом запросе, а не однажды выданное значение. Норма —
суперпользователя. [access](../openspec/specs/access/spec.md), решение —
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA, [ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md).
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
зависит возвращение убранного входа.
Второго периметра на порту сервиса при этом не осталось: панель администратора
ушла вместе со встроенным хранилищем 2026-08-22, и закрывать её на прокси
больше нечего.
- **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение
открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и
списка нет — их делают `upload-and-status-screen` и `records-list-screen`.
Решено делать SPA,
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
сборкой Vite — 2026-08-11, сборкой Vite — 2026-08-11,
[ADR](adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в [ADR](adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в
[research/spa-framework.md](research/spa-framework.md). Тем же решением Node [research/spa-framework.md](research/spa-framework.md). Тем же решением Node
входит в гейт и слоем в сборку образа. Пишет это `spa-skeleton`; во что входит в гейт и слоем в сборку образа; как именно он зовётся — решением
обходится слой Node в образе, не замерялось. Не решено, брать ли готовый набор [ADR](adr/ADR-2026-08-15-node-in-container-not-on-machine.md) 2026-08-15.
компонентов. Не решено, брать ли готовый набор компонентов.
- **Уведомления.** Пользователь веба узнаёт о готовности только опросом. - **Уведомления.** Пользователь веба узнаёт о готовности только опросом карточки.
Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
текст расшифровки начинает уходить на сторону — сдвиг периметра текст расшифровки начинает уходить на сторону — сдвиг периметра
[security.md](security.md). [security.md](security.md).
- **Долгие записи.** Потолок сегодня неизвестен и не замерялся: 20 МиБ на приём - **Долгие записи.** Потолок сегодня неизвестен и не замерялся: ограничения
из Telegram — точно, ограничения `deferred-general` по длине — нет. Расчётный `deferred-general` по длине не выяснены. Расчётные
потолок проекта — шесть часов, и он взят с запасом, а не замером. шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
записи живёт в хранилище»; откуда взято число —
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
записка не узнала». Записка описывает умолчания ушедшей библиотеки, и живой
она осталась только этим числом.
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart - **Приём большого файла.** Форма читается целиком, предел памяти под multipart
задан числом в [database.md](database.md), «Настройки с числовым значением»; задан числом в [database.md](database.md), «Настройки с числовым значением»;
обрыв начинает загрузку заново. обрыв начинает загрузку заново.
@@ -174,24 +395,31 @@
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них - **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его всегда целой. Готового копирования по расписанию у сервиса нет вовсе: оно
или нет, тоже не решено. ушло вместе со встроенным хранилищем, и заводить своё пока не решено.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а - **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом. сервис определяет содержимое сам, то ли часть записей теряется на этом.
- **Видео.** Дорожку из видеофайла бот принимает по MIME-типу `video/`, но - **Видео.** Дорожка из видеофайла к приёму допускается — расширение он берёт из
конвертер этот случай не проверялся. имени и о годности содержимого спрашивает источник метаданных, — но конвертер
на этом случае не проверялся.
- **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12 - **Очередь.** Модель очереди сделана задачей `pocketbase-storage` 2026-08-12
([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)) и нормирована ([ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md)), перестроена
спекой `pipeline`. Не решено, отказываться ли от холостого опроса: три воркера вокруг аудиозаписи задачей `record-centric-model` 2026-08-14 и нормирована
дают 259 200 запросов в сутки при нагрузке в единицы записей в день, и во что спекой `pipeline`. Не решено, отказываться ли от холостого опроса: он
это обходится, никто не мерил. даёт сотни тысяч запросов к базе в сутки — расчёт из числа воркеров и их
паузы, а не замер
([research/job-queue.md](research/job-queue.md), «Как снималось»), — при
нагрузке в единицы записей в день, и во что это обходится, никто не мерил.
Хранилище при этом сменилось задачей `storage-without-pocketbase` 2026-08-22
([ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md)), а модель очереди
пережила смену: отменилось одно слово — таблица перестала быть коллекцией.
- **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать - **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой — счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
выкладке сегодня нет. выкладке сегодня нет.
- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено - **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost. считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает Появляется ещё одна внешняя зависимость, платная, и текст расшифровки начинает
уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не
решено, отдельный это шаг конвейера или продолжение шага распознавания. решено, отдельный это шаг конвейера или продолжение шага распознавания.
+19 -29
View File
@@ -6,7 +6,8 @@
**Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее **Прозой остаётся только то, что не выражается правилом.** Свойство, ставшее
правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в правилом линтера или тестом-сканером, отсюда **удаляется** и переезжает в
перечень «Механизировано» ниже. Причина: файл на несколько сотен строк перечень «Механизировано» записи [go-linters.md](go-linters.md) — дома правил,
которыми машина читает код. Причина: файл на несколько сотен строк
размазывает внимание по тривиальному — и модель, и человек добросовестно размазывает внимание по тривиальному — и модель, и человек добросовестно
проверят именование и не дойдут до формы решения. проверят именование и не дойдут до формы решения.
@@ -17,12 +18,14 @@ severity — в [CLAUDE.md](../../CLAUDE.md).
Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же
задачи. Код transcriber написан раньше и **части правил не следует**: ключи — задачи. Код transcriber написан раньше и **части правил не следует**: ключи —
UUID вместо ULID, время берётся `time.Now()` по месту, лог пишется на каждом UUID вместо ULID, лог пишется на каждом шаге и дублируется воркером, `msg`
шаге и дублируется воркером. предложение с заглавной буквы вместо константной категории.
Из этого перечня одно уже закрыто: доменные ошибки проверялись приведением типа Часть перечня закрыта. Доменные ошибки проверялись приведением типа до
до 2026-08-11, задача `errors-as-instead-of-typecast`. Приведение типа на этом 2026-08-11, задача `errors-as-instead-of-typecast`. Время брали `time.Now()` по
месте больше не долг, а регрессия. месту до 2026-08-13 — теперь его читает единая точка `internal/clock`, и правило
держит линтер. Образец конфига звался `config.dist.toml` до 2026-08-14, задача
`config-example-toml`. Эти места больше не долг, а регрессия.
Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на
htmx, а здесь решено делать SPA — и перенесённый текст снят целиком. htmx, а здесь решено делать SPA — и перенесённый текст снят целиком.
@@ -40,33 +43,20 @@ htmx, а здесь решено делать SPA — и перенесённы
`errors.As`, трансляция доменной ошибки на внешней границе, sentinel против `errors.As`, трансляция доменной ошибки на внешней границе, sentinel против
типизированной. типизированной.
- [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл - [config.md](config.md) — конфигурация: TOML, секреты рендерит выкладка в файл
`0600`, самодокументируемый `config.dist.toml`, проверка на старте. `0600`, самодокументируемый `config.example.toml`, проверка на старте.
- [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT - [database.md](database.md) — БД и идентификаторы: время в UTC RFC 3339, TEXT
ULID, разбор на входной границе, естественные ключи у деталей. ULID, разбор на входной границе, естественные ключи у деталей.
- [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике, - [web-ui.md](web-ui.md) — веб-UI: Vue 3 с Vite и статикой в бинарнике,
однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка однофайловые компоненты, таблица маршрутов, состояние в экране, одна обёртка
над `fetch`, показ ошибок и состояний списка. над `fetch`, показ ошибок и состояний списка.
- [go-linters.md](go-linters.md) — линтеры и механизированные проверки: два круга
(pre-commit и гейт), перечень правил и подавлений, порядок заведения нового
правила. Про инструменты, а не про то, как писать тесты.
## Механизировано ## Что из этого проверяет машина
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в Перечень правил, доведённых до проверки, и место настройки каждого — в записи
промптах ревью не пересказывается. [go-linters.md](go-linters.md). Там же сказано, что из перечисленного в прочих
записях осталось прозой и потому проверяется человеком на каждом ревью заново, и
| Правило | Где механизировано | там же названы остатки правил — то, что правило не ловит. Числа механизированного
| --- | --- | здесь нет намеренно: оно протухает при каждом новом правиле.
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `send` |
| Форматирование исходников | `.golangci.yml``gofmt` |
| Подозрительные конструкции языка | `.golangci.yml``govet`, `staticcheck`, `ineffassign`, `unused` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
| Раскладка документов, битые ссылки, миграция без правки `database.md` | `docs.py check` |
Не названное здесь место механизации означает, что проход по конвенциям будет
добросовестно проверять уже проверенное.
**Из перечисленного в записях правилом выражено одно** — сравнение ошибок через
`errors.Is` и `errors.As` (`errorlint`, строка таблицы выше). Прозой остаётся всё
прочее: ни константный `msg` лога (`sloglint`), ни запрет `fmt.Print*` и
`os.Getenv` (`forbidigo`), ни запрет сторонних пакетов ошибок (`depguard`), ни
архитектурные тесты-сканеры. Это следующий шаг переноса в правило: свойство,
оставшееся прозой, проверяет человек на каждом ревью заново.
+103 -26
View File
@@ -4,20 +4,23 @@
Правила оформления кода (How), не спецификация поведения. Правила оформления кода (How), не спецификация поведения.
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту. **Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: образец называется `config.dist.toml`, а не `config.example.toml`; Главные: комментариями снабжена половина полей; единого места проверки на старте
комментариями снабжена половина полей; валидации на старте нет вовсе, кроме нет: у секций `[auth]`, `[pipeline]` и `[storage]` свой `Validate()` в точке
проверки пустых ключей внутри адаптеров. входа, а пустые ключи `[yandex]` ловит конструктор распознавателя.
**Механизировано:** ничего. Запрет `os.Getenv` для конфигурации правилом линтера **Механизировано:** запрет `os.Getenv` `forbidigo` в `.golangci.yml`
не выражен, и `godotenv` в `main.go` загружает `.env` — то есть окружение сейчас ([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
участвует. приезжают из TOML»: наш рабочий код окружение не читает вовсе — читателя `.env`
в `cmd/transcriber` сняли 2026-08-23 вместе с зависимостью. Окружение остаётся
у границы SDK: `aws-sdk-go-v2` в `internal/adapter/recognizer/yandex/s3.go`
зовёт `config.LoadDefaultConfig`, а тот читает `AWS_PROFILE`, `AWS_CA_BUNDLE`,
`AWS_ENDPOINT_URL` и `AWS_ENDPOINT_URL_S3` и смотрит `~/.aws/config`.
## Принципы ## Принципы
- **Конфигурация — только TOML.** Переменные окружения для конфигурации **не - **Конфигурация — только TOML.** Переменные окружения для конфигурации **не
используем**: окружение наследуется дочерними процессами и видно через используем**: окружение наследуется дочерними процессами и видно через
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`. `/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
*Расхождение:* `main.go` зовёт `godotenv.Load()` и молча продолжает без файла.
- Грузим **один раз при старте** в одну типизированную структуру `Config` - Грузим **один раз при старте** в одну типизированную структуру `Config`
(под-структуры по секциям). Дальше по коду читаем только её — чтения файла в (под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
прикладном коде нет, только загрузчик `internal/config`. прикладном коде нет, только загрузчик `internal/config`.
@@ -28,13 +31,13 @@
- Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге** - Имя конфига по умолчанию — **`config.toml`**, ищется в **рабочем каталоге**
процесса. процесса.
- Путь переопределяется опцией **`-c path`** или **`--config=path`**. - Путь переопределяется опцией **`-c path`** или **`--config=path`**.
- Образец в репозитории — **`config.dist.toml`** (см. ниже); реальный - Образец в репозитории — **`config.example.toml`** (см. ниже); реальный
`config.toml` не коммитится. `config.toml` не коммитится.
## config.dist.toml — самодокументируемый образец ## config.example.toml — самодокументируемый образец
`config.dist.toml` коммитим как единый справочник по конфигу: все секции и все `config.example.toml` коммитим как единый справочник по конфигу: все секции и
поля. **Каждое поле снабжаем комментарием**, из которого ясно: все поля. **Каждое поле снабжаем комментарием**, из которого ясно:
- **зачем** поле — что оно меняет в поведении; - **зачем** поле — что оно меняет в поведении;
- **допустимые значения** — перечисление или границы; - **допустимые значения** — перечисление или границы;
@@ -45,20 +48,37 @@
port = <N> # порт HTTP-сервера port = <N> # порт HTTP-сервера
shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды shutdown_timeout = <N> # ждать мягкой остановки сервера, секунды
force_shutdown_timeout = <N> # ждать остановки воркеров, секунды force_shutdown_timeout = <N> # ждать остановки воркеров, секунды
users_while_list = ["<@name>"] # кому отвечает бот; строка автора Telegram
``` ```
Значения намеренно заменены плейсхолдерами: предмет конвенции — форма Значения намеренно заменены плейсхолдерами: предмет конвенции — форма
комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и комментария, а числа, совпадающие с фактом до цифры, от факта неотличимы и
начинают врать молча при смене умолчания. Действующие умолчания и их смысл живут начинают врать молча при смене умолчания. Действующие умолчания и их смысл живут
одним домом — таблица «Настройки с числовым значением» в одним домом — таблица «Настройки с числовым значением» в
[../database.md](../database.md); `config.dist.toml` — источник истины по составу [../database.md](../database.md); `config.example.toml` — источник истины по
полей. составу полей.
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»). Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
*Расхождение:* секции `[server]` в `config.dist.toml` не хватает поля *Расхождение:* перечень доверенных адресов в секции `[auth]` образца заполнен
`users_while_list`, из-за чего бот на свежем конфиге отвечает отказом всем. примером — подсетью docker, — а не оставлен пустым: пустое значение не говорит,
какой формы значение здесь ждут, а сервис с пустым перечнем не поднимается вовсе.
Секретов в этой секции больше нет: они ушли 2026-08-22 вместе с собственным
входом.
Там же, комментарием под секцией, стоит рецепт локального входа **одним связным
блоком**, а не тремя комментариями по месту: правки связаны между собой, и
применённая порознь любая из них роняет старт либо оставляет сервис никого не
узнающим. Рабочей строкой в образце стоит боевое значение —
перечень с адресом прокси и `debug = false`, — а секция имитации закомментирована
целиком: образец описывает боевую выкладку, а локальный вход — способ до неё
дойти, и два рабочих значения в одном файле читались бы как выбор без указания,
какое из них чьё.
*Расхождение:* петлевые адреса в рецепте названы **парой**`127.0.0.1` и
`::1`, — а не одним значением, хотя правило секции требует от образца только
формы значения. Причина в цене: браузер разрешает `localhost` в IPv6 не реже,
чем в IPv4, и перечень без `::1` даёт неузнанный запрос там, где человек ждёт
входа.
## Поля по дискриминатору `type` ## Поля по дискриминатору `type`
@@ -69,7 +89,7 @@ users_while_list = ["<@name>"] # кому отвечает бот; стр
- **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор - **Проверка — по `type`.** Для каждого поддерживаемого значения свой набор
обязательных полей; поля других значений не требуются. Неизвестное значение — обязательных полей; поля других значений не требуются. Неизвестное значение —
ошибка на старте с перечислением поддерживаемых. ошибка на старте с перечислением поддерживаемых.
- **Образец — по `type`.** В `config.dist.toml`: - **Образец — по `type`.** В `config.example.toml`:
- основное (умолчательное) значение **предзаполнено** рабочими значениями; - основное (умолчательное) значение **предзаполнено** рабочими значениями;
- альтернативные — **блоками-комментариями ниже**, каждый со своим описанием - альтернативные — **блоками-комментариями ниже**, каждый со своим описанием
полей (зачем, границы, единицы — как у обычных полей); полей (зачем, границы, единицы — как у обычных полей);
@@ -85,17 +105,39 @@ Ansible из `pet-project-server`). Приложение просто читае
секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не секретов в коде нет. Источник истины секрета — внешнее хранилище выкладки, не
репозиторий и не окружение. репозиторий и не окружение.
- Секретные поля transcriber: `telegram.bot_token`, `yandex.speech_kit_api_key`, - Секретные поля transcriber: `yandex.speech_kit_api_key`,
`yandex.object_storage_access_key_id`, `yandex.object_storage_access_key_id`,
`yandex.object_storage_secret_access_key`. `yandex.object_storage_secret_access_key`. Секрет клиента OIDC отсюда ушёл
2026-08-22 вместе с собственным входом.
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`, - Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
владелец — пользователь процесса (`1000:1000`). владелец — пользователь процесса (`1000:1000`).
- В `config.dist.toml` секретные поля — пустые строки. - В `config.example.toml` секретные поля — пустые строки.
*Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые *Расхождение:* сейчас там стоят подсказки вида `your_..._here`, а не пустые
строки, и загрузчик их не отличает от настоящего значения. строки, и загрузчик их не отличает от настоящего значения.
- Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво - Загрузчик на старте проверяет, что обязательные секреты не пусты (ловит криво
отрендеренный файл) — см. «Проверка и остановка на старте». отрендеренный файл) — см. «Проверка и остановка на старте».
- В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность». - В логи секреты не попадают — см. [logging.md](logging.md), «Безопасность».
- **Отказ загрузки настроек не несёт содержимого файла.** Текст такого отказа
собирает библиотека разбора, и собирает она его из разбираемого куска:
`toml.ParseError` кладёт в сообщение само значение («Invalid float value: %q»).
Оборванная кавычка в строке секретного ключа — типовая поломка криво
отрендеренного шаблона выкладки — уносит ключ в журнал контейнера целиком, а
инвариант «секрет не покидает конфиг» помечен необратимым. Поэтому отказ
разбора пересобирается своими словами: путь, строка, столбец и последний ключ,
без сообщения библиотеки. Прочие отказы декодера (несовпадение типов,
неподдерживаемый тип) собраны из имён ключей и типов, значений в них нет, и их
текст остаётся как есть — иначе за разборчивость отказа платили бы там, где
платить не за что.
**Файл, способный нести секрет, называется в `.gitignore` и в
`.dockerignore`.** Путей наружу у такого файла два, и закрывает
их разное: git держит `.gitignore`, а контекст сборки образа — `.dockerignore`,
потому что docker `.gitignore` не читает. Сегодня в обоих названы `config.toml`
и `.env`. Правило записано прозой и держится чтением: сверка двух списков стала
бы проверкой над проверкой, а такие проект не заводит
([../../CLAUDE.md](../../CLAUDE.md), «Запреты»). До 2026-08-23 парность не
называл ни один документ, и прогон, снимавший мёртвого читателя `.env`, снял
строку с одной стороны — вернуло её ревью.
## Проверка и остановка на старте ## Проверка и остановка на старте
@@ -110,15 +152,50 @@ Ansible из `pet-project-server`). Приложение просто читае
- ключи внешних сервисов не пусты. - ключи внешних сервисов не пусты.
*Расхождение:* `LoadConfig` проверяет только существование файла и разбирает *Расхождение:* `LoadConfig` проверяет только существование файла и разбирает
TOML. Пустой токен бота ловится в `NewTelegramController` уже после старта, и TOML. Пустые ключи Yandex ловятся в конструкторе распознавателя, и там процесс
приложение продолжает работу без бота; пустые ключи Yandex ловятся в выходит с кодом 1. Единого места проверки нет.
конструкторе распознавателя, и вот там процесс уже выходит с кодом 1. Единого
места проверки нет. Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
**Проверка, охватывающая две секции разом, живёт методом на корневой `Config`.**
Такая сегодня одна — `ValidateTestHeaders`: она судит `[server] debug` против
`[auth.test_headers]`, и ни в `Validate()` секции сервера, ни в `Validate()`
секции входа не помещается — секция начала бы знать о чужой секции. Зовётся она
из `cmd/transcriber` рядом с остальными. Имена принимаемых заголовков приходят
ей **доводом**, а не читаются из пакета настроек: дом у них один — константы
транспорта, — а `internal/config` транспорта не знает и знать не должен, иначе
`cmd/devtools`, которому нужен один разбор конфига, линковал бы всю поверхность
HTTP.
Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит
на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки
и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой
базы и число соединений читающего пула: ноль у первого отдаёт «база занята»
первому же воркеру, ноль у второго означает пул без предела — то есть настройку,
которой не управляют. Причина в цене умолчания: поднявшись с
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
силе для прочих секций, где секреты есть.
## Структура в коде ## Структура в коде
- Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`. - Весь разбор и проверка — в `internal/config`; наружу отдаётся готовая `Config`.
- Одна корневая структура `Config` с под-структурами по секциям (`Server`, - Одна корневая структура `Config` с под-структурами по секциям. Перечень
`Database`, `Storage`, `Yandex`, `Telegram`). секций и полей здесь не повторяем: источник истины по составу —
`config.example.toml`, действующие числа — [../database.md](../database.md),
«Настройки с числовым значением». Каталог данных задаётся одним ключом
`[storage] data_dir`
([ADR](../adr/ADR-2026-08-12-single-data-dir-config-key.md)).
- Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле - Умолчания задаются в `defaultConfig()`, файл их перекрывает. Новое поле
требует правки обоих мест. требует правки обоих мест.
- **Обязательное поле — поле, у которого умолчания нет намеренно.** Умолчание у
такого поля было бы угаданным намерением, и одна из двух ошибок стала бы
тихой. Форма записи: умолчания нет ни в `defaultConfig()` (причина — строкой
комментария у самого поля), ни по нулевому значению типа; присутствие ключа
судит **разбор**`MetaData.IsDefined` из `toml.DecodeFile`, — потому что
значение отличить «не задано» от «задано нулём» не позволяет. В
`config.example.toml` у поля стоит значение свежей установки. Первым таким
полем был `telegram.enabled`; секция убрана 2026-08-14, и живого примера у
правила сейчас нет.
+34 -26
View File
@@ -2,29 +2,32 @@
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md). Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber этому не **Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует этому
следует ни в одном пункте: ключи — UUID v4, а не ULID; время — `time.Now()` по целиком: ключи — ULID в нижнем регистре, выдаёт их единая точка `internal/ident`
месту вызова в локальной зоне, а не единой точкой в UTC; единой точки генерации (с 2026-08-22, задача `storage-without-pocketbase`), время читает единая точка
и разбора нет. Правила действуют на новый код; переписывание существующего — `internal/clock` (с 2026-08-13), и правило времени держит линтер. Расхождений у
отдельная работа, и до неё расхождение читается как долг, а не как нарушение. записи не осталось.
**Механизировано:** ничего. Ни правила линтера, ни теста-сканера под эти пункты **Механизировано:** сверка изменённого шага схемы с
в transcriber нет. [../database.md](../database.md) (`docs.py check`), чтение времени единой точкой
(`forbidigo` плюс `internal/clock`) и согласованность колонок очереди
(тест-сканер `internal/archrules`). Прочие пункты — прозой; адреса —
[go-linters.md](go-linters.md), «Механизировано».
## Первичные ключи — ULID, не автоинкремент ## Первичные ключи — ULID, не автоинкремент
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется - **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи. **приложением** в момент создания записи. Выдача монотонна внутри одной
*Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков миллисекунды: колонка времени несёт секунды, и порядок записей одной секунды
собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id` задаёт ключ. Порядок ленты берут парой «время заведения и ключ» — одного
хронологией не является: порядок берут по колонке времени с ключом. времени мало.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология), - Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален между таблицами — поиск по голому id находит все целиком), глобально уникален между таблицами — поиск по голому id находит все
записи сущности в логах. записи сущности в логах.
- **Точка генерации и разбора одна**: создание — при вставке записи в - **Точка генерации и разбора одна**`internal/ident`: `New` выдаёт, `Parse`
репозитории, разбор — на входных границах. Самодельных генераторов по месту разбирает пришедшее снаружи. Самодельных генераторов по месту вызова не
вызова не заводим. заводим.
## Канонический вид — lowercase ## Канонический вид — lowercase
@@ -44,23 +47,28 @@
## Прочее ## Прочее
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые - Enum-поля (`state`, `halt_reason`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код. значения держит код. Прежде часть перечней закрывала схема — правку руками вела
*Расхождение:* перечень состояний задачи закрыт схемой (`SelectField`), а не панель владельца, и она вправе была завести значение, которого сервис не
кодом — ради панели владельца: правка руками не должна заводить состояние, знает. Панели нет с 2026-08-22, правка идёт только нашим кодом, и закрытый
которого конвейер не знает. Цена названа: шестое состояние потребует нового перечень в схеме остался бы ценой — новое значение стоило бы нового шага — без
шага схемы. покупателя.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например - Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет `2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`). лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени. вставка падает громко. Измерение длительности — не метка времени.
- Миграции — шаги PocketBase на Go (`internal/adapter/repo/pocketbase`): Умолчаний вида `CURRENT_TIMESTAMP` в схеме нет ни у одной колонки, и вид один
коллекции и их поля заводятся кодом. При изменении структуры обновляем схему на все — включая те, что пишет только сам сервис: своего типа времени у SQLite
[../database.md](../database.md) тем же изменением. нет, а колонка, заполненная то одним видом, то другим, молча обращает условие
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким срока захвата в константу.
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и - Миграции — шаги `pressly/goose/v3` на Go
разошедшийся вид обращает условие в постоянную истину или ложь — молча. (`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия — число в
начале имени): таблицы, их колонки и индексы заводятся кодом. При изменении
структуры обновляем схему [../database.md](../database.md) тем же изменением.
- Время в запросе кладётся и сравнивается тем же видом, каким оно лежит в
колонке. Сравнение строк побайтово, и разошедшийся вид обращает условие в
постоянную истину или ложь — молча.
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`: - Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
сравнение по неуникальному значению делает порядок обработки сравнение по неуникальному значению делает порядок обработки
невоспроизводимым. невоспроизводимым.
+62 -24
View File
@@ -9,9 +9,10 @@
Главное: единой точки отображения доменной ошибки в ответ нет, обработчики Главное: единой точки отображения доменной ошибки в ответ нет, обработчики
решают сами. решают сами.
**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint` в **Механизировано:** приведение типа и `err == ErrX` ловит `errorlint`,
`.golangci.yml`. Запрета сторонних пакетов ошибок (`depguard`) нет — сторонних сторонние пакеты ошибок `depguard`, узнавание ошибки по тексту сообщения —
пакетов ошибок в проекте и так нет. тест-сканер `internal/archrules`. Перечень и адреса —
[go-linters.md](go-linters.md), «Механизировано».
## Базовая идиома: stdlib ## Базовая идиома: stdlib
@@ -64,9 +65,14 @@ transcriber — **приложение, а не библиотека**: внеш
вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там, вызывающему нужны **данные** ошибки. Достаём `errors.As`. Не плодим типы там,
где хватает sentinel. где хватает sentinel.
Сегодня в проекте три типизированные ошибки, и данные несёт только одна: Типизированные ошибки проекта несут данные все до одной:
`contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError` `contract.JobNotFoundError` (состояние и сообщение), `contract.NoopJobError`
(состояние), `tg.EmptyBotTokenError` (без полей — уместнее sentinel). (состояние), `contract.LostAcquisitionError` (идентификатор задачи).
Правило это однажды нарушал `tg.EmptyBotTokenError` — тип без полей, — и был
снят задачей `local-run-without-telegram-token` 2026-08-13 в пользу sentinel'а.
Оба ушли из проекта 2026-08-14 вместе с входом Telegram; пример остаётся здесь
как случай, а не как живой код.
## Граница и трансляция: приватный и публичный канал ## Граница и трансляция: приватный и публичный канал
@@ -76,8 +82,8 @@ transcriber — **приложение, а не библиотека**: внеш
- **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой - **Приватный канал — логи** (владелец сервиса). Полная ошибка со всей цепочкой
`%w` и контекстом. Пишется один раз на доменной границе — см. `%w` и контекстом. Пишется один раз на доменной границе — см.
[logging.md](logging.md). [logging.md](logging.md).
- **Публичный канал — пользовательские поверхности** (Telegram, веб-UI, HTTP - **Публичный канал — пользовательские поверхности** (веб-UI, HTTP API). Сюда
API). Сюда отдаём: отдаём:
- **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и - **человекочитаемое сообщение** по доменной ошибке — не сырой `err.Error()` и
не детали реализации (`database/sql`, пути на диске, имена внешних сервисов); не детали реализации (`database/sql`, пути на диске, имена внешних сервисов);
- **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему - **корреляционный ключ** для владельца — идентификатор задачи, чтобы по нему
@@ -92,29 +98,51 @@ transcriber — **приложение, а не библиотека**: внеш
- **отображение доменной ошибки в статус и сообщение** — единой точкой для - **отображение доменной ошибки в статус и сообщение** — единой точкой для
HTTP и веба: HTTP и веба:
| Доменная ошибка | Статус | Сообщение | | Доменная ошибка | Статус | `error_code` | Сообщение |
| --- | --- | --- | | --- | --- | --- | --- |
| задача не найдена | 404 | «задача не найдена» | | пришедший не узнан | 401 | `unauthorized` | «сервис вас не узнал» |
| файл не приложен, формат не распознан | 400 | «некорректный ввод» | | запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
| задача ещё выполняется, действие сейчас недопустимо | 409 | «действие недоступно в текущем состоянии» | | файл не приложен, формат не распознан, негодное значение параметра, негодный диапазон | 400 | `bad_request` | «некорректный ввод» |
| прочее | 500 | «внутренняя ошибка» | | запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
| текста или копии файла запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
| прочее | 500 | `internal` | «внутренняя ошибка» |
Ветвь `403`/`forbidden` ушла отсюда 2026-08-22 вместе со своим единственным
случаем: им был владелец панели, предъявивший собственный токен хранилища.
Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку
учётную запись заводит само.
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
логирующая граница спишет его в `ERROR` вместо `DEBUG`. логирующая граница спишет его в `ERROR` вместо `DEBUG`.
*Расхождение:* такой точки нет. `internal/controller/http/transcribe.go` **Тело отказа несёт два поля — `error_code` и `message`.** Кода HTTP не
отвечает 404 на **любую** ошибку `GetByID`, включая сбой базы, и 500 на хватает: «файл негоден», «поля записи нет» и «неизвестный вид» — все три
любую ошибку заведения задачи. `400`, а приложению надо решать, предлагать ли повтор. Разбор русской фразы
был бы единственным оставшимся путём. Норму держит спека `archive`.
Точка живёт в `internal/controller/http.mapDomainError` и названа в
[architecture.md](../architecture.md), «Единые точки проекта». Прежнее
расхождение — «такой точки нет, обработчик решает сам» — закрыто задачей
`json-api-for-spa` 2026-08-15.
**Часть отказов рождается не в обработчике** — предел тела, ограничитель
частоты, неизвестный путь под корнем приложения, негодный диапазон в запросе
файла — и до этой точки не доходит вовсе. С 2026-08-22 отдельного слоя
перевода им не нужно: маршрутизатор и слои написаны нами, и каждый из них
отвечает **своей доменной ошибкой** через ту же точку. Прежде их приводил к
общей форме слой `OneErrorForm`, стоявший снаружи всех прочих и переводивший
тело чужой библиотеки; библиотеки не осталось, и второй формы отказа взяться
неоткуда.
### Разовый ответ и сохранённая диагностика ### Разовый ответ и сохранённая диагностика
У публичной границы две поверхности, и правило сырого текста для них разное. У публичной границы две поверхности, и правило сырого текста для них разное.
- **Разовый ответ на действие** (тело HTTP-ответа, сообщение бота по результату - **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
команды) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
полная ошибка остаётся в логах по идентификатору задачи. полная ошибка остаётся в логах по идентификатору задачи.
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это - **Сохранённая диагностика состояния** — колонка `error_text` аудиозаписи. Это
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим **поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
и полезен. Но: и полезен. Но:
- **секреты запрещены** — токены, ключи, пароли, заголовок - **секреты запрещены** — токены, ключи, пароли, заголовок
@@ -124,9 +152,9 @@ transcriber — **приложение, а не библиотека**: внеш
- **внешнее значение в тексте усекается на границе, а его размер называется - **внешнее значение в тексте усекается на границе, а его размер называется
числом рядом**: без этого непонятно, насколько сокращать. числом рядом**: без этого непонятно, насколько сокращать.
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения, а *Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
пользователь Telegram видит отдельный человекочитаемый текст — это часть Наружу он при этом не выходит: карточка записи отдаёт причину остановки без
правила соблюдена. машинного текста — эту часть правила держит спека `archive`.
## panic ## panic
@@ -135,10 +163,20 @@ transcriber — **приложение, а не библиотека**: внеш
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) — - Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
это значения `error`. это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не - `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
ронял процесс. В transcriber это `gin.Recovery()`; у воркеров и у бота такой ронял процесс. В transcriber его ставит свой слой `http.Recover`: паникующий
границы **нет**: паника в шаге конвейера роняет процесс целиком. обработчик отдаёт `500` нашей формой тела, а строка о панике идёт в журнал
владельца. Слой стал своим 2026-08-22 вместе с роутером — прежде его вешала
чужая библиотека. У воркеров такой границы **нет**: паника в шаге конвейера
роняет процесс целиком.
## Несколько ошибок ## Несколько ошибок
- Сбор независимых ошибок (проверка конфига — все проблемы разом) — - Сбор независимых ошибок (проверка конфига — все проблемы разом) —
`errors.Join`; проверка собранного по-прежнему через `errors.Is`. `errors.Join`; проверка собранного по-прежнему через `errors.Is`.
*Расхождение:* проверку конфига пункт называет поимённо, а ни одна из них так не
устроена: `errors.Join` в `internal/config` не зовётся нигде, и всякая проверка
возвращается на первом несовпадении. Заметило ревью задачи
`config-test-headers-login` 2026-08-23 — тем же прогоном, каким добавили
`ValidateTestHeaders`, ведущую себя так же. Человек, заполняющий конфиг
впервые, чинит одну ошибку за прогон.
+242
View File
@@ -0,0 +1,242 @@
# Линтеры и механизированные проверки
Конвенция о том, **чем машина читает наш код**: какие свойства доведены до
правила, чем каждое проверяется, когда оно запускается и что осталось человеку.
Свойство, ставшее правилом, из прозы соседних записей удаляется и появляется
здесь строкой — эта запись его принимает.
**Чего здесь нет: как писать тесты.** Запись говорит об инструментах и правилах —
линтерах, тестах-сканерах, шагах проверок, — а не о том, что должен утверждать
юнит-тест и какой у него оракул. Это другой предмет, и живёт он в
[../review.md](../review.md): «Типовые узлы» перечисляют свойства, которые тест
обязан проверять. Тест-сканеры ниже попадают в эту запись не потому, что они тесты, а
потому, что они правила: у них нет ни фикстур, ни поведения — они читают
исходники.
Пока язык у проекта один, и запись названа по нему. Появится второй — у него
будет своя запись, а два круга останутся общими.
Устройство ниже **переносимо**: разделы «Два круга» и «Как заводят новое
правило» — не особенность transcriber и переносятся в другой Go-проект как есть.
Своё здесь — перечень правил и подавлений.
## Границы: где что живёт
Чтобы факт не жил в двух местах:
- **семантика гейта** — команда целиком, база диффа, словарь кодов выхода, что
красит безусловно, чего в гейте намеренно нет и кто тогда обязан это гонять —
в [CLAUDE.md](../../CLAUDE.md), раздел «Гейт». Здесь это не повторяется: у гейта
один дом, и он у памятки, потому что её читают прежде работы;
- **как писать код** — соседние записи этой конвенции ([README.md](README.md) —
индекс). Свойство, ставшее правилом, оттуда удаляется и попадает в перечень
ниже; обратный перенос запрещён — правило, оставшееся ещё и прозой, проверяют
дважды;
- **настройка конвейера ревью, вопросы по темам и журнал дефектов** —
[../review.md](../review.md). Перечень ниже говорит этим вопросам, чего
спрашивать уже не нужно;
- **поведение сервиса** — нормативные спеки `openspec/specs/`. Шаги набора
проверок туда не входят: инструментарий спеками не нормируется, и спека
`toolchain`, заведённая под шаг сверки версий Go, упразднена 2026-08-13. Своего
дома у нормы этого шага теперь нет вовсе — она живёт комментариями в
`scripts/check-go-version.sh`, и проверок у шага нет: двадцать сценариев снесены
тем же решением. Второй самодельный
шаг — `migrations` — не проверен и не был: он прогнан мутацией на трёх исходах
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
него нет, и дрейф его собственного шаблона имени никто не поймает. Долгом это
не числится: проверок над проверками проект не заводит —
[CLAUDE.md](../../CLAUDE.md), «Запреты».
## Два круга: pre-commit и гейт
Проверки идут двумя кругами, и круг выбирается по цене прогона.
| | pre-commit (`lefthook.yml`) | гейт (`task gate`) |
| --- | --- | --- |
| Когда | на каждый коммит | перед тем как считать задачу сделанной |
| На чём | на **затронутых файлах** | на всём дереве |
| Сколько идёт | около секунды | десятки секунд |
| Что делает с находкой | `gofmt` правит и добавляет в коммит, прочее роняет коммит | роняет прогон |
Перечень работ pre-commit и то, что остаётся только гейту, — в
[CLAUDE.md](../../CLAUDE.md), раздел «Гейт». Здесь важен принцип: **pre-commit не
подменяет гейт**. Он ловит дешёвое и местное, а сборка, тесты целиком, сверки
документов и запрос к базе уязвимостей идут в гейте — иначе коммит стоил бы
минуту, и хук отключили бы через день.
Полный набор проверок в pre-commit не переносится сознательно; обратное решение
— «гонять всё на каждый коммит» — известно и отклонено по этой же причине.
## Механизировано
Проверяется командами из [CLAUDE.md](../../CLAUDE.md); прозой не дублируется и в
промптах ревью не пересказывается.
### Ошибки и отказы
| Правило | Где механизировано |
| --- | --- |
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules``TestОшибкаНеУзнаётсяПоТексту` |
| Непроверенное возвращаемое значение ошибки | `.golangci.yml``errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove`, отложенные `(*sql.Rows).Close` и `(*sql.Tx).Rollback` и запись тела ответа (`json.Encoder.Encode`, `http.ResponseWriter.Write`) |
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml``errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml``nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
| Отказ выборки из базы не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml``rowserrcheck`, `sqlclosecheck`. Предмет у правил появился 2026-08-22: выборки идут своим `database/sql`, и обе ветви ловятся на живом коде |
| Обращение к базе идёт с контекстом (`ExecContext`, `QueryContext`, `BeginTx`) | `.golangci.yml``noctx`. Контекст у репозиториев свой — почему, названо в [../database.md](../database.md), «Представление данных» |
| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml``depguard` |
### Структура и границы
| Правило | Где механизировано |
| --- | --- |
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Транспорты не знают адаптеров | `internal/archrules``TestТранспортыНеЗнаютАдаптеров`. Правило заведено 2026-08-22: изъятие, разрешавшее транспорту знать адаптер хранилища, снято вместе с предметом |
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки записи согласованы: что пишет отображение ↔ что спрошено чтением ↔ что доезжает до сущности ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках (`TestКолонкиЗаписиПишутсяИЧитаются`, `TestПрочитанныеКолонкиДоезжаютДоСущности`, `TestКолонкиЗаписиЗаведеныШагомСхемы`). Закрывает инвариант «колонки записи правятся в трёх местах» (CLAUDE.md, major), которого компилятор не держит. Имя колонки ищется в телах нужных функций, а не в файле целиком |
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
### Отмена и внешний собеседник
| Правило | Где механизировано |
| --- | --- |
| Запрос и внешний процесс заводятся с контекстом (`exec.CommandContext`, `http.NewRequestWithContext`, `QueryContext`) | `.golangci.yml``noctx`. Единая точка не нужна: контекст приезжает доводом, а контракты `internal/contract` несут его первым |
| Контекст приезжает сверху, а не заводится по месту (`context.Background()` в середине цепочки) | `.golangci.yml``contextcheck` |
| Тело ответа HTTP закрывается | `.golangci.yml``bodyclose`. Отдельно от `errcheck`: там `(io.ReadCloser).Close` объявлен исключением, и незакрытое тело от невыясненного `Close` неотличимо |
### Время, вывод, конфигурация
| Правило | Где механизировано |
| --- | --- |
| Время читают `clock.Now` (метка, UTC) и `clock.Start` (длительность, монотонные часы) — не `time.Now` по месту | `.golangci.yml``forbidigo`; единая точка — `internal/clock` |
| Вывод идёт через `slog`, а не `fmt.Print*` и не встроенными `print`/`println` | `.golangci.yml``forbidigo`. Не ловит `fmt.Fprintln(os.Stdout, …)` — первый аргумент по имени функции не судится; остаток прозой в [logging.md](logging.md) |
| Конфигурация приезжает из TOML, а не из окружения | `.golangci.yml``forbidigo`: `os.Getenv`, `os.LookupEnv`, `os.Environ`, `os.ExpandEnv` — все четыре, иначе запрет обходится соседним именем |
| Форма вызова `slog`: только пары «ключ-значение», атрибуты (`slog.String` и прочие) не употребляются вовсе; `msg` — константа | `.golangci.yml``sloglint` (`kv-only` запрещает атрибуты целиком, а не только смешение) |
### Код проверок и подавления
| Правило | Где механизировано |
| --- | --- |
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml``forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml``testifylint` |
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик и логгер; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Что делает шаг без компилятора C и каким кодом краснеет — [CLAUDE.md](../../CLAUDE.md), «Гейт» |
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml``nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
### Форма кода и файлов вне Go
| Правило | Где механизировано |
| --- | --- |
| Форматирование исходников | `.golangci.yml``gofmt`; на pre-commit правится на месте |
| Подозрительные конструкции языка | `.golangci.yml``govet`, `staticcheck`, `ineffassign`, `unused` |
| Опечатка в комментарии и в тексте ошибки | `.golangci.yml``misspell` |
| Скрипты оболочки | `Taskfile.yml` → шаг `shell` (`shellcheck`), он же на pre-commit |
| Форма `Dockerfile` | `Taskfile.yml` → шаг `dockerfile` (`hadolint`), он же на pre-commit |
| Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) |
| Форматирование и статический анализ кода приложения | `web/biome.json` → Biome, зовётся шагом `front` командой `npm run check`. Разбирает и однофайловые компоненты; правила — набор `recommended` плюс своя форма (одинарные кавычки, точка с запятой по необходимости) |
| Типы разметки и кода приложения | `vue-tsc`, и он входит в **команду сборки**, а не стоит отдельным шагом: несобираемое приложение и непроверенные типы — один отказ |
| Поведение экранов приложения | `Taskfile.yml` → шаг `front`, юнит-тесты Vue (`npm run test`). Без них требование «приложение показывает вошедшего» не проверял бы никто, а набор проверок оставался бы зелёным на сломанном экране |
### Хранилище, документы, секреты, зависимости
| Правило | Где механизировано |
| --- | --- |
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни база: применённое считается своей таблицей учёта. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` |
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
| Достижимая из кода уязвимость в зависимостях | `Taskfile.yml` → шаг `vulns` (`govulncheck ./...`) |
Не названное здесь место механизации означает, что проход по конвенциям будет
добросовестно проверять уже проверенное.
## Подавления: что и почему
Подавление — это решение, а не настройка, поэтому каждое названо поимённо и с
причиной. Причина живёт строкой рядом с подавлением (в `.golangci.yml` или
`Taskfile.yml`), а здесь — их перечень, чтобы видеть все разом.
| Подавлено | Где | Почему |
| --- | --- | --- |
| `errcheck` на `defer Close` и `os.Remove` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен |
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
| `noctx` на `httptest.NewRequest` в `*_test.go` | `.golangci.yml`, `exclusions` | Фикстура запроса к обработчику в том же процессе: внешнего собеседника за ней нет, отменять нечего. Изъятие названо по имени этой функции, а не выключением `noctx` на проверках: настоящий внешний вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему подсуден, и это проверено мутацией |
| `SC1007` в `scripts/check-go-version.sh` | директива в скрипте | Ложное срабатывание на идиому `CDPATH= cd`, которая защищает `cd` от чужого `CDPATH` |
| `DL3007` (`alpine:latest`) | `Taskfile.yml`, шаг `dockerfile` | Открытая задача `pin-runtime-image-base`; до её решения шаг краснел бы на известном |
| `DL3018` (закрепить версии `apk`) | там же | Alpine не держит старые версии пакетов в репозитории: закрепление ломает сборку через недели |
## Что остаётся прозой
**Из перечисленного в записях конвенций правилом выражено не всё.** Прозой
остаётся то, чему нет ни готового правила, ни детерминированного оракула:
уровень лога по адресату, единая логирующая точка на доменной границе, словарь
имён полей, канонический вид идентификатора, естественные ключи у деталей.
Свойство, оставшееся прозой, проверяет человек на каждом ревью заново, и правило,
снявшее с него эту работу, всегда выигрыш.
Названы поимённо и **остатки правил** — то, что правило не ловит и потому
осталось человеку:
- вывод в stdout через `fmt.Fprintln(os.Stdout, …)` и `os.Stdout.WriteString`:
`forbidigo` судит по имени вызванной функции, а не по её первому аргументу;
- проверка, судящая ответ мимо recorder — через свой `http.ResponseWriter`;
- **отсутствие** контекста у сигнатуры: `contextcheck` ловит обрыв цепочки —
`context.Background()` там, где контекст был доводом, — но метод, у которого
довода нет вовсе, правилу не виден. Первый проброс контекста в новый адаптер
остаётся человеку;
- шаг `migrations` судит только те шаги схемы, которые **есть в базе диффа**: у
добавленного после неё файла статус `A`, и правка такого файла законна — он
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
молчит на всём каталоге, и на подозрении база задаётся руками
(`task migrations BASE=<rev>`);
- чистота домена: правила смотрят ядро, входы и адаптеры, а импорт внешней
библиотеки в `internal/entity` сегодня пройдёт молча. Названо в
[../architecture.md](../architecture.md), «Слои и модель домена».
Отдельно названы **правила, чей подъём отклонён**:
- `key-naming-case` у `sloglint` — словарь полей намеренно смешанный: доменные
поля `snake_case`, системные домены с точкой (`http.method`, `ext.service`);
- `msg-style: lowercased` у `sloglint` — это ровно конвенция «`msg` — короткая
константа в нижнем регистре», но код называет сообщения предложениями с
заглавной, и это объявленное *Расхождение*. Цена подъёма — переписать больше
ста вызовов, и она не заплачена;
- закрепление версий пакетов `apk` (`DL3018`) — см. подавления выше.
**Кандидат, ждущий решения:** `clock.Now` и `clock.Start` отдают один тип, поэтому
`time.Since(clock.Now())` компилируется и молча меряет длительность настенными
часами — ровно то, против чего пакет и написан. Держал бы это компилятор, будь у
`Start` свой тип с методом `Elapsed()`. Сегодня таких мест нет.
## Как заводят новое правило
Порядок один и тот же, и последние два шага пропускать нельзя.
1. **Найти дом.** Дом выбирается по тому, чем свойство выражается, а не по тому,
что проще включить: настройка готового линтера, запрет по имени, тест-сканер
исходников или свой шаг набора проверок.
2. **Написать причину рядом.** Правило без причины снимают при первом же
неудобстве: тот, кто снимает, не знает, что оно ловило.
3. **Починить находки, а не подавить.** Подавление годится, когда правило
говорит не о том, что мы имели в виду; тогда оно попадает в перечень выше с
причиной. Подавление «пока некогда» — это отложенная работа, и её место в
каталоге задач, а не в конфиге.
4. **Проверить мутацией.** Внести ровно то нарушение, против которого правило
написано, и убедиться, что проверка краснеет и называет место. Правило,
принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны
они в [../review.md](../review.md) (журнал 2026-08-11 про недостижимую норму,
2026-08-13 про обходимый текстовый запрет).
**Мутация обязана собираться.** Правка, снявшая последнее употребление
импорта, роняет сборку, а не проверку: вывод при этом похож на отказ, и
мутацию легко засчитать сработавшей. Прежде чем верить красному, убедись, что
красное — от проверки.
**Мутация ставится по одному нарушению на строку.** `golangci-lint` печатает
с одной строки исходника **одну** находку (умолчание `uniq-by-line`), и
мутация, задевшая сразу два правила, покажет только первое: так молчали
`sqlclosecheck` и `rowserrcheck` на пробе, где та же строка уже краснела от
`noctx`. Проверять правило пробой, где оно единственное нарушенное.
5. **Записать строкой здесь** и удалить прозу из конвенции, если правило её
заменило.
+62 -43
View File
@@ -11,8 +11,13 @@ OpenSpec.
категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает
ошибку выше, где её логируют снова. ошибку выше, где её логируют снова.
**Механизировано:** ничего. Ни `sloglint`, ни `forbidigo` в `.golangci.yml` не **Механизировано:** форма вызова — `sloglint`: только пары
включены, поэтому правилами не выражено ни одно из перечисленного ниже. «ключ-значение», `msg` константой, **атрибуты (`slog.String` и прочие) не
употребляются вовсе**. Запрет `fmt.Print*` и встроенных `print`/`println`
`forbidigo`; вывод в stdout через `fmt.Fprintln(os.Stdout, …)` правилом не
ловится и остаётся прозой этой записи. Прозой остаются также уровень по адресату,
единая логирующая точка и словарь имён полей: оракула у них нет. Адреса —
[go-linters.md](go-linters.md), «Механизировано».
## Принципы ## Принципы
@@ -23,22 +28,28 @@ OpenSpec.
`jq` без регулярных выражений. `jq` без регулярных выражений.
```json ```json
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"job accepted","capability":"intake","job_id":"…","source":"telegram","duration_seconds":137} {"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
``` ```
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`. *Расхождение:* текстовый обработчик ставит `cmd/transcriber`
`slog.NewTextHandler(os.Stdout, …)`.
*Изъятие:* оснастка разработчика `cmd/devtools` печатает не через `slog`, а
stdlib-логом в поток ошибок. Это выбор, а не долг: её вывод читает человек в
терминале, в сбор он не едет, а текст подсказки по командам `slog`-ом
выглядел бы хуже, чем есть.
## Сообщение ## Сообщение
- `msg` — короткая константа в нижнем регистре: `job accepted`, - `msg` — короткая константа в нижнем регистре: `record accepted`,
`recognition done`, `conversion failed`. Данные — в атрибутах: `recognition done`, `conversion failed`. Данные — в атрибутах:
`log.Info("job accepted", "job_id", id, "source", "telegram")`. `log.Info("record accepted", "record_id", id, "source", "api")`.
- `msg` — чистая категория без префикса подсистемы: `recognition done`, а не - `msg` — чистая категория без префикса подсистемы: `recognition done`, а не
`recognize: done`. Подсистему выносим в поле `capability`, не в текст. `recognize: done`. Подсистему выносим в поле `capability`, не в текст.
- **Смена состояния задачи — единая категория `state transition`** с полями - **Смена состояния задачи — единая категория `state transition`** с полями
`from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь `from`, `to` и причиной. Любой переход пишет этот `msg`, чтобы весь
жизненный цикл собирался одним отбором: жизненный цикл собирался одним отбором:
`jq 'select(.msg=="state transition" and .job_id=="…")'`. Физический эффект `jq 'select(.msg=="state transition" and .record_id=="…")'`. Физический эффект
сверх перехода — отдельная запись своей категории (`file converted`, сверх перехода — отдельная запись своей категории (`file converted`,
`text delivered`), она запись перехода не подменяет. `text delivered`), она запись перехода не подменяет.
@@ -55,7 +66,7 @@ OpenSpec.
| `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов | | `DEBUG` | разработчику при отладке; в продакшене выключен | `GET /health`, пустой прогон воркера, проверка готовности операции распознавания, тела запросов и ответов внешних сервисов |
| `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** | | `INFO` | владельцу, разбор постфактум | приём записи, переход задачи, конвертация выполнена, текст отправлен, старт и остановка процессов, **событийный вызов внешнего сервиса** |
| `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания | | `WARN` | владельцу, «может стать проблемой» | повтор внешнего вызова, задача досталась повторно по истечении захвата, пустой текст распознавания |
| `ERROR` | владельцу, в разбор | внешний сервис недоступен, задача ушла в `failed`, необработанная ошибка | | `ERROR` | владельцу, в разбор | внешний сервис недоступен, запись остановлена признаком, необработанная ошибка |
Правила: Правила:
@@ -73,9 +84,14 @@ OpenSpec.
- `slog` не разделяет CRITICAL и FATAL — сбой на старте логируем `ERROR` и - `slog` не разделяет CRITICAL и FATAL — сбой на старте логируем `ERROR` и
завершаем процесс с ненулевым кодом. завершаем процесс с ненулевым кодом.
*Расхождение:* уровень зашит константой в `main.go`, `DEBUG` включить нечем. *Расхождение:* уровень зашит константой в `cmd/transcriber`, `DEBUG` включить нечем.
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит. Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
*Изъятие:* строка о подставленных заголовках входа адресована разработчику, а
идёт на `INFO` — уровень и его довод нормирует спека
[access](../../openspec/specs/access/spec.md), «Отладочный запуск виден в
журнале».
## Время ## Время
- Поле — `time` (ключ `slog` по умолчанию). - Поле — `time` (ключ `slog` по умолчанию).
@@ -89,19 +105,22 @@ OpenSpec.
- Доменные поля — плоский `snake_case`. - Доменные поля — плоский `snake_case`.
- Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`, - Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`,
`ext.*`. `ext.*`, `webapp.*`.
- JSON плоский: все поля на верхнем уровне, без вложенности. - JSON плоский: все поля на верхнем уровне, без вложенности.
| Когда добавляем | Поля | | Когда добавляем | Поля |
| --- | --- | | --- | --- |
| на входящий HTTP-запрос | `transport` (`http`, `telegram`), `http.method`, `http.route`, `http.status_code`, `duration_ms` | | на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms`, `http.path_length`. **Запрошенного пути в строке нет ни под каким корнем**: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. В `http.route` идёт маршрут из закрытого перечня — точный адрес наблюдения либо образец адреса приложения, — а всё прочее обозначается одним общим значением |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `job_id`, `file_id`, `source` | | на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
| на запись об ошибке | `error` | | на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` | | на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт). Правило о пути — строкой выше, общее: вместо пути в `http.route` стоит `<приложение>` |
| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум. Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
*Расхождение:* в коде встречаются `job_id`, `file_id`, `operation_id`, *Расхождение:* в коде встречаются `record_id`, `file_id`, `operation_id`,
`worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и `worker`, `path`, `src_path`, `dest_path` — то есть словарь сложился сам и
пересечён с этим лишь частично. пересечён с этим лишь частично.
@@ -115,16 +134,16 @@ OpenSpec.
чтобы ключ дописывался на каждую запись сам: чтобы ключ дописывался на каждую запись сам:
```go ```go
log := log.With("job_id", job.Id, "capability", "conversion") log := log.With("record_id", record.Id, "capability", "conversion")
``` ```
- Все записи одной задачи собираются одним отбором: - Все записи одной задачи собираются одним отбором:
`jq 'select(.job_id=="…")' app.jsonl`. `jq 'select(.record_id=="…")' app.jsonl`.
## Ошибки ## Ошибки
Ошибки Go логируем как атрибут, а не как текст сообщения: Ошибки Go логируем как атрибут, а не как текст сообщения:
`log.Error("conversion failed", "error", err, "job_id", id)`. Ключ — `error`. `log.Error("conversion failed", "error", err, "record_id", id)`. Ключ — `error`.
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только - Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст оборачивают и возвращают (`fmt.Errorf("…: %w", err)`), не логируя — контекст
@@ -132,7 +151,7 @@ log := log.With("job_id", job.Id, "capability", "conversion")
- Логируем ошибку **один раз — на границе доменного слоя**, которая определяет - Логируем ошибку **один раз — на границе доменного слоя**, которая определяет
исход операции. Логирует эта единая точка, а не каждый транспорт — так исход операции. Логирует эта единая точка, а не каждый транспорт — так
транспорты остаются тонкими. Границы в transcriber: транспорты остаются тонкими. Границы в transcriber:
- приём записи (`CreateJobFromTelegram`, `CreateJobFromApi`); - приём записи (`CreateJobFromApi`);
- **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`, - **шаг конвейера** (`FindAndRunConversionJob`, `FindAndRunTranscribeJob`,
`FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера; `FindAndRunTranscribeCheckJob`) — исход шага, вызванного циклом воркера;
- завершение и отказ задачи (`completeJob`, `failJob`). - завершение и отказ задачи (`completeJob`, `failJob`).
@@ -158,16 +177,15 @@ log := log.With("job_id", job.Id, "capability", "conversion")
*Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и *Расхождение, и оно системное:* сегодня шаг конвейера логирует ошибку `Error` и
тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две тут же возвращает её воркеру, который логирует её второй раз. Один сбой даёт две
записи. Плюс `internal/controller/http/transcribe.go` пишет через `log.Printf` записи.
мимо `slog` целиком.
## Внешние сервисы: логируем все вызовы ## Внешние сервисы: логируем все вызовы
**Каждый** вызов внешнего сервиса логируется. Поля: **Каждый** вызов внешнего сервиса логируется. Поля:
- `ext.service``telegram`, `speechkit`, `object-storage`, `ffmpeg`; - `ext.service``speechkit`, `object-storage`, `ffmpeg`;
- `ext.operation` — логическая операция (`getFile`, `sendMessage`, - `ext.operation` — логическая операция (`RecognizeFile`, `GetOperation`,
`RecognizeFile`, `GetOperation`, `PutObject`, `convert`); `PutObject`, `convert`);
- `ext.status_code` — код ответа, если применим; - `ext.status_code` — код ответа, если применим;
- `duration_ms` — длительность вызова; - `duration_ms` — длительность вызова;
- `retry` — номер попытки, если повторы были. - `retry` — номер попытки, если повторы были.
@@ -187,13 +205,12 @@ log := log.With("job_id", job.Id, "capability", "conversion")
*Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только *Расхождение:* обёртки `ext.*` нет. Из внешних вызовов логируется только
конвертация (через метрику длительности) и запуск распознавания; заливка в конвертация (через метрику длительности) и запуск распознавания; заливка в
Object Storage, скачивание файла из Telegram и опрос операции не логируются Object Storage и опрос операции не логируются никак.
никак.
## HTTP и проверка здоровья ## HTTP и проверка здоровья
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`, - Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
`http.status_code`, `duration_ms`, `transport`. `http.status_code`, `duration_ms`, `http.path_length`, `transport`.
- **Поле, которое уже даёт логгер с подставленным ключом, руками не - **Поле, которое уже даёт логгер с подставленным ключом, руками не
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
потребитель молча оставит одно из значений. Правило проверяется чтением, потребитель молча оставит одно из значений. Правило проверяется чтением,
@@ -202,19 +219,17 @@ Object Storage, скачивание файла из Telegram и опрос оп
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
`INFO` они не пишутся. `INFO` они не пишутся.
Расхождения здесь больше нет: слой журналирования запросов свой, Расхождения здесь больше нет: слой журналирования запросов свой
`main.go`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics` `internal/controller/http`, `journal.go`. `/health` и `/metrics` идут на `DEBUG`,
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе. то есть при боевом `INFO` не пишутся вовсе.
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден **Журнал у сервиса один.** Второй, куда встроенное хранилище клало путь целиком
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера, вместе с адресом отправителя, ушёл вместе с самим хранилищем 2026-08-22.
по которому разбирают отказы, эта таблица не попадает.
## Безопасность: что не логируем ## Безопасность: что не логируем
Никаких секретов в полях и сообщениях. Под запретом: Никаких секретов в полях и сообщениях. Под запретом:
- токен бота Telegram;
- ключ SpeechKit и заголовок `Authorization`; - ключ SpeechKit и заголовок `Authorization`;
- пара ключей Object Storage; - пара ключей Object Storage;
- **сам текст расшифровки и имена файлов пользователя** — это содержимое личной - **сам текст расшифровки и имена файлов пользователя** — это содержимое личной
@@ -230,22 +245,26 @@ Object Storage, скачивание файла из Telegram и опрос оп
- При сомнении не логируем значение, логируем факт его наличия - При сомнении не логируем значение, логируем факт его наличия
(`"has_api_key", true`). (`"has_api_key", true`).
- **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.** - **Ошибка HTTP-транспорта несёт URL — возможный носитель секрета.**
`*url.Error` из `net/http` встраивает полный URL запроса, а токен Telegram `*url.Error` из `net/http` встраивает полный URL запроса, а секрет иногда
живёт прямо в пути (`…/bot<TOKEN>/…`). Такую ошибку разворачивают в живёт прямо в пути. Такую ошибку разворачивают в
первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается, первопричину на границе клиента **до** лога и до обёртки: URL отбрасывается,
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта. в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
*Расхождение:* вычистки нет. Скачивание файла из Telegram идёт обычным Живого случая у этого правила сейчас нет: единственный секрет, стоявший в пути
`http.Get(file.Link(token))`, и ошибка этого вызова содержит токен бота. Сегодня обращения, — токен бота, и он ушёл вместе с входом Telegram 2026-08-14. Разбор
она не логируется — то есть утечки нет, но защищает от неё только отсутствие случая и цена промаха записаны в [../review.md](../review.md), 2026-08-13:
строки лога. конвенция числила утечку расхождением с оценкой «не логируется», и оценка была
неверной.
*Расхождение:* расширение берётся из имени отправителя дословно *Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост (`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
расширением, и в журнал оно попадает полем пути. Наружу — в метку метрики — этот расширением. В журнал оно идёт **собственным полем** строки приёма — это
хвост не выходит: там расширение приводится к перечню известных форматов. Остаток объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
описан в [../security.md](../security.md). «Инварианты»); ни имени файла на диске, ни пути к нему в журнале нет вовсе
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
там расширение приводится к перечню известных форматов. Остаток описан в
[../security.md](../security.md).
## Куда пишем и уровень ## Куда пишем и уровень
@@ -258,5 +277,5 @@ Object Storage, скачивание файла из Telegram и опрос оп
## Анализ ## Анализ
- Повседневно — `jq`: `jq 'select(.job_id=="…")' app.jsonl`. - Повседневно — `jq`: `jq 'select(.record_id=="…")' app.jsonl`.
- Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла. - Тяжёлое (сведение, соединение) — DuckDB поверх JSONL прямо из файла.
+60 -19
View File
@@ -10,17 +10,19 @@
описывала htmx с прямым запретом на шаг сборки и реактивные фреймворки; она снята описывала htmx с прямым запретом на шаг сборки и реактивные фреймворки; она снята
целиком вместе со сменой решения на SPA 2026-08-10. целиком вместе со сменой решения на SPA 2026-08-10.
**Кода приложения ещё нет.** Правила ниже выведены из выбора и из замера на Правила ниже применены каркасом приложения (`spa-skeleton`, 2026-08-15): до него
пробном экране, а не из написанного кода: первым их применяет и проверяет они были выведены из выбора и из замера на пробном экране, а не из написанного
`spa-skeleton`. Место, где правило разойдётся с тем, что окажется удобным, — кода. Место, где правило разойдётся с тем, что окажется удобным, — повод править
повод править эту запись, а не обходить её молча. эту запись, а не обходить её молча.
Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок
наружу — [errors.md](errors.md). наружу — [errors.md](errors.md).
**Механизировано:** типы разметки и кода проверяет `vue-tsc`, и он входит в **Механизировано:** типы разметки и кода проверяет `vue-tsc`, и он входит в
команду сборки, а не стоит отдельным шагом. Правил линтера для кода приложения команду сборки, а не стоит отдельным шагом. Форматирование и статический анализ
пока нет. держит **Biome**, поведение экранов — **юнит-тесты Vue**; оба шага входят в набор
проверок наравне со сборкой. Инструменты зовутся контейнером, а не из `PATH`:
требованием к машине разработчика остаётся docker, а не установленный Node.
## Что решено про само приложение ## Что решено про само приложение
@@ -33,11 +35,13 @@
- **Шрифты и скрипты — со своего хоста**, без внешних. Внешних ресурсов времени - **Шрифты и скрипты — со своего хоста**, без внешних. Внешних ресурсов времени
выполнения нет. выполнения нет.
- **Офлайн-чтения расшифровок и очереди отправки без сети не делаем** — граница - **Офлайн-чтения расшифровок и очереди отправки без сети не делаем** — граница
цели [web-access](../../tasks/items/web-access.md). Без сети приложение из [паспорта](../passport.md). Без сети приложение показывает состояние, а не
показывает состояние, а не пустой экран. пустой экран.
- **Web Push не делаем**: уведомления идут через apprise и ntfy, цель - **Web Push не делаем**: уведомления идут через apprise и ntfy — решение живёт
[ready-notification](../../tasks/items/ready-notification.md). в [architecture.md](../architecture.md), «Уведомления», делает его
- **Записи звука в приложении не делаем** — файл выбирают системным диалогом. [ntfy-delivery](../../tasks/items/ntfy-delivery.md).
- **Записи звука в приложении не делаем** — граница из
[паспорта](../passport.md), «Диктофон»; файл выбирают системным диалогом.
## Фреймворк и сборка ## Фреймворк и сборка
@@ -49,18 +53,43 @@
делать то же самое. делать то же самое.
- **Собранная статика неизменяема и адресуется хешем в имени.** Имена придумывает - **Собранная статика неизменяема и адресуется хешем в имени.** Имена придумывает
Vite, руками их не задаём: от этого зависит обновление установленного Vite, руками их не задаём: от этого зависит обновление установленного
приложения. приложения. Сервис на это правило опирается, но проверить его не может — имён
он не выбирает, — поэтому дом правила здесь, а не в спеке: долгий срок
хранения он ставит **по каталогу** сборщика, и файл, положенный туда без
отпечатка в имени, останется в хранилище браузера навсегда.
- **Зависимости ставятся из файла замка командой, которая его не правит.** Иначе
набор проверок пачкает рабочее дерево, обновление зависимости приезжает в
коммит без чьего-либо решения, а собранное в наборе проверок перестаёт
совпадать с собранным в образе.
- **Шаг сборки входит в `task gate` и в сборку образа.** Красная сборка статики - **Шаг сборки входит в `task gate` и в сборку образа.** Красная сборка статики
роняет гейт наравне с `go build`. роняет гейт наравне с `go build`.
## Маршруты ## Маршруты
- **Четыре экрана, одна таблица маршрутов** через `createRouter`. Маршруты по - **Одна таблица маршрутов** через `createRouter`. Маршруты по файлам не
файлам не включаем: сборочная надстройка роутера пятой версии стоит 34 пакета включаем: сборочная надстройка роутера пятой версии стоит 34 пакета в
в установке и на четырёх маршрутах не окупается. установке и на нашем числе маршрутов не окупается
([research/spa-framework.md](../research/spa-framework.md), «Vue»). Сколько
экранов и какие — не здесь: состав нормирует спека
[webapp](../../openspec/specs/webapp/spec.md).
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование - **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
к серверу: неизвестный путь **вне** `/api/` отдаёт `index.html`, а не `404`; к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
пути внутри `/api/` в приложение не проваливаются никогда. `404`; путь внутри корня в приложение не проваливается никогда. Корень
сегодня **один**`/app/` у приложения, — плюс `/health` и `/metrics`
отдельными адресами. Корни `/auth/`, `/api/` и `/_/` сняты 2026-08-22: первый
ушёл с собственным входом, два других — со встроенным хранилищем и его
панелью, и пути под ними стали обычными путями вне корней. Приложение уехало
из общего `/api/` решением владельца 2026-08-15, и корень свой сохранило:
соседа, ради которого выбирался, больше нет, а формы запросов и ответов от
смены хранилища не изменились ни одним полем. Перечень корней сервису не
описывают, а из него **порождают** регистрацию маршрутов: описанный порознь,
он разошёлся бы с ними молча.
- **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика,
которому не нашлось файла, отвечает `404`. Правило — вторая половина
предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и
подменить их разметкой значит ответить `200` на то, чего нет. Браузер отвергнет
такой ответ по типу содержимого, человек увидит пустой экран, а в кодах
ответов сервиса не останется ничего.
- **Экран не знает, как он открыт.** Данные экран берёт по своему адресу, а не - **Экран не знает, как он открыт.** Данные экран берёт по своему адресу, а не
получает от предыдущего: приложение открывают по ссылке и обновляют страницу получает от предыдущего: приложение открывают по ссылке и обновляют страницу
посередине. посередине.
@@ -78,12 +107,17 @@
- **Обёртка — единственное место, где читается код ответа.** Она же превращает - **Обёртка — единственное место, где читается код ответа.** Она же превращает
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
не `Response`. не `Response`.
- **Сессии у сервиса нет вовсе**, и приложение не хранит ничего: кто пришёл,
называет заголовок обратного прокси, а приложение узнаёт его ответом API.
Кук сервис не ставит — это свойство сторожится проверкой. Норма —
[access](../../openspec/specs/access/spec.md), решение —
[ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md).
## Показ ошибок и состояний ## Показ ошибок и состояний
- **Текст ошибки приходит с сервера и показывается как есть.** Своих текстов под - **Текст ошибки приходит с сервера и показывается как есть.** Своих текстов под
коды ответа приложение не сочиняет: единая форма ошибки — обязанность API коды ответа приложение не сочиняет: единая форма ошибки — обязанность API
([json-api-for-spa](../../tasks/items/json-api-for-spa.md)), и второй словарь (спека [archive](../../openspec/specs/archive/spec.md)), и второй словарь
на клиенте разошёлся бы с первым. на клиенте разошёлся бы с первым.
- **Отсутствие связи — состояние, а не ошибка.** Сорванный запрос показывается - **Отсутствие связи — состояние, а не ошибка.** Сорванный запрос показывается
строкой «связи нет», а не пустым экраном и не сообщением браузера. строкой «связи нет», а не пустым экраном и не сообщением браузера.
@@ -94,11 +128,18 @@
## Что не решено ## Что не решено
- **Инструмент статического анализа проверен наполовину.** Biome взят решением
владельца 2026-08-15 и разбирает однофайловые компоненты; замены он потребует,
если перестанет их держать. Тогда это отдельное решение, а не подстановка по
ходу.
- **Проверка типов держится на пятой линии TypeScript.** С седьмой `vue-tsc`
не работает: новый компилятор не отдаёт точку входа, которую тот зовёт.
Проверено прогоном 2026-08-15.
- **Набор компонентов и стили.** Своя разметка или готовый набор — не решено, а - **Набор компонентов и стили.** Своя разметка или готовый набор — не решено, а
готовый способен удвоить собранный файл готовый способен удвоить собранный файл
([research/spa-framework.md](../research/spa-framework.md), «Чего разведка не ([research/spa-framework.md](../research/spa-framework.md), «Чего разведка не
узнала»). узнала»).
- **Устройство service worker и версионирование статики** — задача - **Устройство service worker и версионирование статики** — задача
[installable-pwa](../../tasks/items/installable-pwa.md). [installable-pwa](../../tasks/items/installable-pwa.md).
- **Где живёт сессия и как приложение узнаёт вошедшего** — открытый вопрос - **Как связать чат Telegram с учётной записью** — открытый вопрос; от него зависит возвращение убранного 2026-08-14 входа
«Учётные записи» в [../architecture.md](../architecture.md). «Учётные записи» в [../architecture.md](../architecture.md).
+411 -108
View File
@@ -1,149 +1,452 @@
# Схема хранилища # Схема хранилища
Хранилище, коллекции, правило времени и идентификаторов. База, таблицы, раскладка файлов, правило времени и идентификаторов.
Хранилище **встроенная PocketBase 0.39.10**: она держит и базу, и файлы Хранилище **своё**: база SQLite через `modernc.org/sqlite` (CGO сборке не нужен)
записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`, и файлы записей своим каталогом рядом с ней. Ключ конфигурации один
умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому `[storage] data_dir`, умолчание `data`. Встроенная PocketBase, державшая до
CGO сборке не нужен. 2026-08-22 и базу, и файлы, и панель, и маршрутизатор, ушла из проекта целиком —
задача `storage-without-pocketbase`,
[ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md).
Схему двигают **шаги миграций PocketBase** на Go, каталог **База принимает одного писателя.** Пишущий пул держит одно соединение — драйвер
`internal/adapter/repo/pocketbase`, файл шага — `migrations.go`. Шаг пишет единственным, и несколько воркеров, пришедших писать разом мимо этого
регистрируется при загрузке пакета, а накатывается при подъёме хранилища правила, получают отказ по занятости на записи результата шага, то есть после
(`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не оплаченной работы. Чтение идёт отдельным пулом: в журнале упреждающей записи
переписывается — изменение только новым шагом: применённое хранилище считает по читатели не мешают писателю.
имени файла.
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита. Журнал упреждающей записи, соблюдение внешних ключей и ожидание занятой базы
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в задаются **строкой подключения обоих пулов**, а не запросом после открытия: две
хранилище, задаёт сервис, и это `<uuid><расширение>`. из трёх настроек в SQLite принадлежат соединению, а не базе, а пул заводит новые
соединения по мере надобности — запрос настроил бы одно из многих. Операция,
которая читает и следом пишет, идёт целиком по пишущему соединению: читающую
транзакцию SQLite до пишущей не повышает и отказывает по занятости немедленно.
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки Схему двигают **шаги `github.com/pressly/goose/v3`** — библиотекой, а не
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе командной строкой. Каталог `internal/adapter/repo/sqlite/migrations`, файл на
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite шаг, версия шага — число в начале имени файла. Перечень шагов приходит
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или провайдеру доводом, провайдер заводится в точке входа и получает пишущий пул,
постоянную ложь молча. накат идёт **до подъёма входов и до старта воркеров**, а отказ шага роняет старт.
Применённый шаг не переписывается — изменение только новым шагом.
Того, что единой точки генерации идентификатора и времени нет, здесь не Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
повторяем: перечень единых точек и их отсутствий держит соединении. Порядок шагов детерминирован и выводится из версии, а не из порядка
[architecture.md](architecture.md), «Единые точки проекта». чтения каталога; две одинаковых версии дают отказ сбора.
## Коллекции **Исключающую блокировку наката держим сами.** Библиотека под SQLite её не
поставляет вовсе — её запиратели объявлены только для PostgreSQL, а провайдер без
запирателя накатывает без всякой блокировки. Замок берётся на файле
`data/migrate.lock` (`syscall.Flock`, `LOCK_EX`) и снимается закрытием
дескриптора; с умершим процессом его снимает ядро, поэтому просроченного замка,
который надо чистить руками, не остаётся.
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
пути**, а префикс наводится только на каталог. Где этот префикс задан —
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано».
**Идентификаторы** — ULID в нижнем регистре, `TEXT`, 26 знаков алфавита
Crockford. Выдаёт их приложение единой точкой `internal/ident`; внутри одной
миллисекунды выдача монотонна, потому что колонка времени несёт секунды и
порядок записей одной секунды задаёт ключ. Идентификатор, пришедший снаружи,
разбирается на границе: разбор проверяет вид и приводит регистр, а негодный
считается несуществующей записью и до базы не доходит.
Тем же идентификатором зовётся **подкаталог записи** в каталоге данных, а имя
файла внутри него — `<ULID><расширение>`.
**Время**`TEXT` в RFC 3339, UTC, суффикс `Z`, секундная точность:
`2006-01-02T15:04:05Z`. Ширина записи постоянная, поэтому лексикографический
порядок совпадает с хронологией. Вид один на **все** колонки времени, включая
те, что пишет только сам сервис: своего типа времени у SQLite нет, колонка
хранит то, что в неё положили, и колонка, заполненная то одним видом, то другим,
обратила бы условие срока протухания захвата в постоянную истину или ложь молча.
Время ставит приложение единой точкой `internal/clock`. **Умолчаний вида
`CURRENT_TIMESTAMP` в схеме нет**: умолчание писало бы свой вид времени, а
вставка, забывшая проставить время, при нём прошла бы молча.
## Таблицы
**Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки и
вид текста были закрыты `CHECK`-подобным типом хранилища, потому что панель
владельца правила запись руками и вправе была завести значение, которого сервис
не знает. Панели нет, правка идёт только нашим кодом, и закрытый перечень в схеме
остался бы ценой — новое значение стоило бы нового шага — без покупателя.
### `users`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID, выдаёт приложение |
| `provider_login` | TEXT, уникален | Логин человека **у провайдера**: то значение, которым его называет обратный прокси заголовком `Remote-User`. Ключ учётной записи |
| `name` | TEXT | Имя, пригодное к показу; берётся при заведении и вторым обращением не переписывается |
| `email` | TEXT | Адрес почты; необязателен |
| `created_at`, `updated_at` | TEXT | Время |
Уникальность почты держится **частичным** индексом (`WHERE email <> ''`), поэтому
записи без почты уживаются друг с другом. Уникальность логина — обычным.
Ключом почта не служит вовсе: адрес меняется, и первое обращение с чужим адресом
досталось бы чужой записи.
### `files` ### `files`
Один файл на одну физическую копию: исходник, результат конвертации и копия в Одна строка на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
Object Storage — три разные записи. приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
считается — она существует только потому, что провайдер распознавания читает
аудио по адресу, и её ключ живёт в строке попытки распознавания.
| Поле | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `id` | TEXT PK | ULID |
| `file` | file | Сам файл; пусто у копии в Object Storage | | `owner_id` | TEXT → `users(id)` | Владелец копии; пустого значения не принимает |
| `location` | select | `local` или `s3` | | `record_id` | TEXT | Запись, которой копия принадлежит: имя её подкаталога |
| `object_key` | TEXT | Ключ объекта; пусто у местной копии | | `file_name` | TEXT | Имя файла в этом подкаталоге; задаёт сервис |
| `size` | INTEGER | Размер в байтах | | `size_bytes` | INTEGER | Размер копии в байтах |
| `created`, `updated` | DATETIME | Проставляет хранилище | | `format` | TEXT | Расширение без точки, в нижнем регистре |
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
| `created_at` | TEXT | Время |
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и **Внешнего ключа на аудиозапись у `record_id` нет намеренно.** Приём заводит
capability, и третий смысл развёл бы одно слово по разным вещам. файл **до** самой записи — подкаталог назван её идентификатором, и знать его надо
раньше, — и обязательная связь отвергала бы первую же принятую запись. Владелец
при этом лежит своей колонкой, а не выводится через запись: файл переживает свою
запись, и заведённый шагом до её сохранения остаётся с владельцем и без ссылки.
### `transcribe_jobs` ### `audio_records`
Задача расшифровки и она же очередь. Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на
приложения лежат здесь; содержимое — по ссылкам, отдельными строками.
| Поле | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `id` | TEXT PK | ULID |
| `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой | | `owner_id` | TEXT → `users(id)` | Владелец записи; пустого значения не принимает |
| `source` | select | `api`, `telegram`, `unknown` | | `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
| `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат | | `original_filename` | TEXT | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
| `delay_time` | DATETIME | Не брать задачу раньше этого времени | | `duration_ms` | INTEGER, обязателен | Длительность **принятого**, миллисекунды; ставит приём и всегда |
| `acquisition_id` | TEXT | Кто захватил задачу | | `size_bytes` | INTEGER, обязателен | Размер **принятого**, байты |
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание | | `state` | TEXT | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done` |
| `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа | | `state_entered_at` | TEXT | Время входа в рубеж — сторож застревания |
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud | | `halted_at` | TEXT | Признак остановки; рубеж при ней не стирается |
| `transcription_text` | editor | Результат распознавания | | `halt_reason` | TEXT | `step_failed`, `attempts_exhausted`, `stuck` |
| `error_text` | TEXT | Текст ошибки, машинный | | `error_text` | TEXT | Текст ошибки, машинный |
| `tg_chat_id` | INTEGER | Куда отправить результат | | `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого |
| `tg_reply_message_id` | INTEGER | С каким сообщением связать | | `acquire_expires_at` | TEXT | Срок протухания захвата; приезжает с рубежом |
| `created`, `updated` | DATETIME | Проставляет хранилище | | `delay_time` | TEXT | Не брать запись раньше этого времени |
| `attempts` | INTEGER | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании |
| `original_file_id` | TEXT → `files(id)` | Принятая копия |
| `normalized_file_id` | TEXT → `files(id)` | Копия, приведённая к рабочему формату |
| `transcript_text_id`, `literary_text_id` | TEXT | Тексты записи |
| `structure_id` | TEXT | Структура реплик |
| `recognition_id` | TEXT | Попытка распознавания |
| `created_at`, `updated_at` | TEXT | Время |
Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата. Индексов два. `idx_audio_records_acquire` `(state, halted_at, created_at, id)`:
Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ по нему идёт отбор захвата, и по нему же он берёт запись в определённом порядке.
этот один. `idx_audio_records_owner_page``(owner_id, created_at, id)`: под страницу
списка, сужаемую владельцем и режущуюся полным ключом сортировки.
**Состояния `failed` и `dead` — разные приговоры.** В `failed` задачу переводит Оба индекса заведены **начальным шагом**, а не отложены: применённый шаг схемы не
шаг, рассудивший об этой записи окончательно; в `dead` она уходит без такого переписывается, и добавление индекса стоило бы отдельного шага. Проверено
суждения — мы повторяли и перестали. Ни один шаг конвейера в `dead` не переводит `EXPLAIN QUERY PLAN`: ни отбор захвата, ни страница списка не показывают полного
сам: это делает тот, кто захватил задачу с превышенным счётчиком. сканирования таблицы.
**Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи **Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает
может только владелец панели. Проверено прогоном: анонимный запрос к ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса
`/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`, страница сканировала таблицу целиком и досортировывала результат во временном
`/api/settings` и `/api/crons``401`. дереве, а рост архива с 5 тысяч строк до 200 тысяч растил время одной страницы
владельца в двадцать-тридцать раз — при неизменных сорока его собственных
записях. Цена росла с **чужими** записями, потому что сервис объявлен архивом и
хранит их бессрочно.
**Имя файла и заголовок — разные колонки.** Заголовок несёт название, которое
дал человек либо посчитала языковая модель; имя файла — то, по чему человек
узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное
название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит
извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя
файла на диске и в журнал оно по-прежнему не идёт.
**Длительность и размер лежат и на записи, и на её файле, и равенство между ними
не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый
приёмом один раз; на файле — величины нынешней копии. Уточнение длительности
меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и
«что лежит сейчас». Колонками записи они нужны потому, что показываются в списке,
а список читается без содержимого. Решение владельца от 2026-08-15.
**«Неизвестно» эти колонки не выражают**, и это то же решение владельца: обе
величины ставит приём и ставит всегда — запись с непрочитанными метаданными
отвергается отказом и не заводится вовсе. Обе объявлены обязательными: пустое
значение, которое схема теперь допустить может, завело бы третий смысл, которого
никто не читает.
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
хранилище, и принятого человеком файла не найти было ничем.
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
признака, — и различие между ними перестало быть структурным.
**Сторожей двое.** `attempts` ограничивает повторы внутри шага,
`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не
справлялось ни с одной.
### `record_topics`
| Поле | Тип | Что |
| --- | --- | --- |
| `record_id` | TEXT → `audio_records(id)` | Запись |
| `topic_id` | TEXT → `topics(id)` | Тема |
Первичный ключ — пара целиком. Потолок в пять тем на запись держит **триггер**:
без него часовой разговор даёт два десятка тем, и словарь распухает за неделю.
Число берётся у домена — то же самое, которое сервис объявляет приложению.
### `texts`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это расшифровка |
| `kind` | TEXT | `transcript` или `literary` |
| `contents` | TEXT | Сам текст |
| `created_at`, `updated_at` | TEXT | Время |
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат
файла.
### `structures`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это структура |
| `version` | INTEGER | Версия вида разбора |
| `contents` | TEXT | Реплики со временем, JSON |
| `created_at`, `updated_at` | TEXT | Время |
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
сохранённого ответа изменится раньше, чем архив пересчитают.
### `recognitions`
Попытка распознавания у внешнего провайдера — всё, что зависит от него.
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это попытка |
| `provider`, `model` | TEXT | Кем и какой моделью считано |
| `external_id` | TEXT | Идентификатор операции у провайдера |
| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио |
| `payload_file` | TEXT | Имя файла с сохранённым ответом провайдера |
| `started_at`, `finished_at` | TEXT | Границы операции |
| `created_at`, `updated_at` | TEXT | Время |
**Сохранённый ответ лежит третьим файлом в подкаталоге записи, а не колонкой.**
Шаг опроса читает эту строку раз в несколько секунд, а репозиторий читает строку
целиком: ответ на многочасовую запись, положенный колонкой, ехал бы в память при
каждом опросе. Хранится он потому, что результат операции у провайдера не
переспрашивается. Копией аудио он при этом не считается — их у записи по-прежнему
две, — и адреса, которым его читают снаружи, у сервиса нет вовсе.
### `record_events`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чьё это событие |
| `origin` | TEXT | `pipeline` или `human` |
| `step` | TEXT | Имя шага |
| `outcome` | TEXT | `done`, `failed`, `halted`, `resumed` |
| `outcome_text` | TEXT | Причина, если она есть |
| `duration_ms` | INTEGER | Сколько шаг занял |
| `created_at` | TEXT | Время |
Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
двусмысленным.
Журнал пишется на смену рубежа, на остановку и на возврат в работу — не на
каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить,
что делать дальше. Происхождение `human` пишет сегодня подкоманда оснастки,
возвращающая остановленную запись в работу: другого писателя, кроме конвейера, у
журнала не осталось.
### `topics`
| Поле | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | ULID |
| `owner_id` | TEXT → `users(id)` | Чей это словарь |
| `name` | TEXT | Название темы |
| `created_at`, `updated_at` | TEXT | Время |
Пара «владелец и название» уникальна: словарь тем свой у каждого человека.
Отдельной таблицей, а не набором строк в записи, потому что перечень тем нужен
целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего
сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача,
считающая темы, не платила вторым необратимым шагом схемы.
### Чего в схеме больше нет
**Каталог шагов PocketBase удалён целиком, и на его месте стоит один шаг
начальной схемы** — `202608220002_init.go`. Это разовое снятие инварианта
«применённая миграция не переписывается», решением владельца от 2026-08-22:
стадия проекта — стройка, на сервере данных нет, сервис остановлен, а новая база
ведёт учёт применённого своей таблицей, которой отметки прежнего каталога не
годятся вовсе. Снятие кончается этим шагом.
**Колонок `location` и `source` в новой схеме нет.** Обе писались одним значением
и не читались никем: в `location` уходило `local`, второго значения (`s3`) не
писал ни один шаг; в `source` всякий приём писал `api`, а второе значение
(`telegram`) держалось ссылкой из применённого шага, а не потребителем. Шаги
ушли, и держать их стало нечем. Поле, у которого появится читатель, вернётся
одним новым шагом схемы.
**Колонок `tg_chat_id`, `tg_reply_message_id` и `object_key` нет по той же
причине:** их держал применённый шаг, которого больше не существует.
**Учётная запись с записями не удаляется**, и держит это схема обязательной
связью, а не проверка вызывающего: `audio_records`, `files` и `topics` ссылаются
на `users(id)` без каскада, а соблюдение внешних ключей включено на каждом
соединении обоих пулов. Прежде запрет ставил слой приложения — сборка, забывшая
его позвать, теряла защиту молча, и теряла. Адреса, которым учётную запись
удаляют, у сервиса нет вовсе; способа удалить записи тоже нет, и это осознанный
тупик до задачи про удаление записи.
## Представление данных ## Представление данных
Чем физически лежит запись и что происходит при чтении и записи. Чем физически лежит запись и что происходит при чтении и записи.
- **Расшифровка лежит целиком в поле `transcription_text`** одной строкой. - **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
целиком при каждом чтении задачи и при каждом захвате. колонки шаг читает отдельным чтением.
- **Аудио лежит в раскладке хранилища:** - **Файлы записи лежат подкаталогом на запись:**
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт `data/records/<ULID записи>/<имя>`. Внутри — принятая копия, приведённая копия
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает, и сохранённый ответ провайдера. Так копии одной записи лежат вместе, а запись
потому что умолчание, строящее имя из имени отправителя, не применяется. Ни убирается целиком одним движением; плоский каталог, где копии различаются
файлы, ни объекты в Object Storage не удаляются после завершения задачи: приставкой в имени, обращал бы уборку в перебор по маске. Имя, данное
каталог и бакет растут неограниченно. отправителем, не попадает ни в имя файла, ни в путь к нему. Ни файлы, ни
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла объекты в Object Storage не удаляются после завершения записи: каталог и бакет
не помечено защищённым: право прочитать запись даёт знание её идентификатора, растут неограниченно.
и файл встаёт вровень с опросом готовности задачи. Поэтому имя файла в - **Укладка атомарна:** содержимое пишется во временное имя **в том же
хранилище **в журнал не пишется** — оно последняя часть ссылки. подкаталоге записи** и переименовывается в рабочее только после того, как поток
- **Захват задачи — один запрос с `RETURNING`**, мимо записей коллекции. дочитан до конца без отказа. Строка о файле заводится **после** этого;
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением, содержимое легло, а строка не сохранилась — уложенный файл убирается.
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени - **Файл отдаётся адресом приложения** —
заведения **и по ключу**: время неуникально, и без ключа порядок обработки `GET /app/audiorecords/{id}/file?copy=original|normalized`, — и право пройти по
невоспроизводим. нему даёт узнавание пришедшего и владение записью. Значений на предъявителя
- **Запись результата условна по признаку захвата.** Шаг, чей захват за время сервис не выдаёт вовсе: ни короткого токена файла, ни подписанной ссылки со
работы достался другому, завершается без записи и без ответа отправителю. сроком. Отзыв доступа доходит до файла сразу, а не через срок жизни выданного
- **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`, значения. Имя файла на диске в журнал не пишется и в ответ не идёт.
константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы. - **Учётная запись заводится первым обращением** — поиск по `provider_login` и
Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его вставка идут одной транзакцией на пишущем соединении. Два отказа уникальности
серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты». различаются повторным поиском по ключу: нашёлся — гонка двух первых обращений
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а одним логином, не нашёлся — занятая почта, и запись заводится без неё.
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают - **Захват записи — один запрос `UPDATE … RETURNING`** по пишущему соединению:
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки выбор подходящей записи и пометка её захваченной идут вместе. Порядок выборки —
в Object Storage: отказ SDK несёт полный URL объекта. по времени заведения **и по ключу**: время неуникально, и без ключа порядок
обработки невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку
протухания захвата и отсутствию признака остановки; срок протухания выбирается
по рубежу самой записи прямо в запросе — воркер, ещё не знающий, что вытянет,
подставить его не может.
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
норма — [pipeline](../openspec/specs/pipeline/spec.md). Условие стоит в самом
запросе правки, поэтому между проверкой и записью не остаётся окна.
- **Колонки записи отображаются по имени**: именованные параметры запроса и место
назначения, найденное по имени колонки. У аудиозаписи поля одного типа идут
длинным непрерывным рядом, и позиционный список дал бы сдвиг на одно поле,
который компилируется молча и кладёт идентификатор файла в колонку текста.
Перечень мест, где правится колонка, и серьёзность правила — инвариант
«Колонки записи правятся в трёх местах» в [CLAUDE.md](../CLAUDE.md),
«Инварианты»; сверку держат правила `internal/archrules`.
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
по инварианту проекта не пишется в журнал и не считается в метрику.
- **Отказ базы наружу не выходит дословно.** Отказы чтения и укладки называют
запись её идентификатором и не несут ни имени файла, ни пути к нему: имя —
часть пути к чужому аудио. То же у выгрузки в Object Storage: отказ SDK несёт
полный URL объекта.
- **Обращения к базе идут с собственным контекстом**, а не с контекстом запроса.
Отменять там нечего: операции местные и короткие, а единственное ожидание —
занятая база — задано числом. За отмену платили бы дважды: шаг, прерванный
остановкой сервиса, перестал бы освобождать захват и писать причину остановки —
то есть отмена ломала бы ровно ту уборку, ради которой она и делается. Отмена,
которой сервис распоряжается по-настоящему, доходит до `ffmpeg` и до платного
распознавания.
## Настройки с числовым значением ## Настройки с числовым значением
| Настройка | Значение | Где | Откуда число | | Настройка | Значение | Где | Откуда число |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер | | Предел отказов | 5 | `service/transcribe.go` | обычное умолчание, не замер |
| Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же | | Пауза перед повтором | `2^(отказ1)` с, потолок 5 минут | там же | то же |
| Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас | | Срок захвата, приведение | 8 часов | `entity/stage.go` | потолок записи 6 часов плюс запас |
| Срок захвата, распознавание | 8 часов | там же | то же | | Срок захвата, отправка на распознавание | 8 часов | там же | то же |
| Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды | | Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
| Задержка перед первой проверкой операции | 10 секунд | там же | как было | | Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение |
| Ожидание занятой базы | 5000 миллисекунд | конфиг, `[storage] busy_timeout_ms` | выведено из числа воркеров, а не замерено: пишет сервис короткими операциями, и очередь из трёх воркеров укладывается в него с запасом |
| Соединений в читающем пуле | 4 | конфиг, `[storage] read_connections` | число воркеров плюс запас под запросы приложения; пишущее соединение при этом всегда одно и настройкой не делается |
| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая |
| Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана |
| Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром |
| Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
| Срок жизни неиспользуемого счётчика ограничителя | 10 минут | `controller/http.staleBudgetAge` | карта счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда спрашивающий кладёт по строке на каждый свой адрес |
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
| Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново |
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
| Задержка между проверками операции | 5 секунд | там же | как было | | Задержка между проверками операции | 5 секунд | там же | как было |
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` | как было | | Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — | | Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — | | Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — | | Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | | Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
| Предел длины логина у провайдера | 255 знаков | `entity.MaxProviderLoginLength` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени, пригодного к показу |
| Предел длины имени, пригодного к показу | 255 знаков | `entity.MaxDisplayNameLength` | то же |
| Длина идентификатора | 26 знаков | `ident.Len` | ширина записи ULID |
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у **Адрес спрашивающего ограничитель берёт из `X-Forwarded-For` — и только тогда,
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля когда соединение пришло с адреса из объявленного перечня доверенных.** Без этого
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело счётчик ведётся по адресу пира, а пир с переездом входа на заголовок всегда один
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка —
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
нагрузке объявлена вне модели угроз. ограничивает. Как читается цепочка — спека
[archive](../openspec/specs/archive/spec.md); здесь только числа бюджета.
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ
файлов и объектов нет вовсе. Таймаутов у лежит файлом, а структура текстовой колонкой; жизнь приглашения завести владельца
обращений к Telegram, S3 и SpeechKit тоже нет — ни одного. панели — панели нет. Прежде, вместе с собственным входом, ушли срок жизни сессии,
потолок времени на вход у провайдера и таймаут обмена кода.
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит
по нехватке памяти или `docker kill`, — запись невидима сторожу до истечения
**срока захвата** её рубежа, то есть восьми часов у приведения и отправки.
Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и
остановка «застряла» наступает только после него. Мягкая остановка сюда не
подпадает: она снимает захват сама.
**Потолок размера назван числом там, где иначе действует умолчание**у тела
запроса приёма, и назван дважды: объявленная длина судится заранее, а
необъявленная и солгавшая ловятся на чтении. Умолчания здесь не «без предела», а
величины на два-три порядка меньше нужного. Таймаут чтения запроса снят: шесть
часов записи по медленному каналу переживают любой фиксированный, а стойкость к
целенаправленной нагрузке объявлена вне модели угроз.
Чего среди настроек **нет**: срока хранения файлов и объектов нет вовсе.
Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного.
+56 -37
View File
@@ -1,7 +1,7 @@
# Паспорт проекта # Паспорт проекта
Зачем это и для кого. [architecture.md](architecture.md) отвечает «как Зачем это и для кого. [architecture.md](architecture.md) отвечает «как
устроено», [tasks/ROADMAP.md](../tasks/ROADMAP.md) — «в каком порядке», паспорт — устроено», [tasks/BACKLOG.md](../tasks/BACKLOG.md) — «в каком порядке», паспорт —
«зачем и для кого». «зачем и для кого».
## Цель ## Цель
@@ -21,22 +21,24 @@
| --- | --- | | --- | --- |
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось | | Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи | | Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
| Пользователь Telegram | Отправить боту голосовое сообщение и получить текст ответом. Работает сегодня | | Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня не работает вовсе: домен целиком стоит за обратным прокси, и запрос программы отбивает он, не доходя до сервиса. Токен и правило прокси мимо входа приносит `api-tokens` |
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Работает сегодня, но токенов нет и доступ не разграничен |
**Основной вход — приложение**, бот и HTTP API дополняют его. До 2026-08-11 **Вход у сервиса один — HTTP API**, и приложение строится поверх него. До
основным был бот, и порядок здесь перевёрнут сознательно: диктофонная запись на 2026-08-11 основным входом был Telegram-бот. 2026-08-11 основным объявили
несколько часов через Telegram не проходит вовсе. приложение: диктофонная запись на несколько часов через Telegram не проходит
вовсе. 2026-08-14 бот убран целиком — временно, до задачи, которая свяжет чат с
учётной записью. Вместе с ним из потребителей ушёл пользователь
Telegram.
Цель достигнута, когда: Цель достигнута, когда:
- запись любого распространённого формата принимается без предварительной - запись любого распространённого формата принимается без предварительной
подготовки, включая дорожку из видео; подготовки, включая дорожку из видео;
- запись длиной до шести часов доходит до текста, а не прерывается ошибкой при - запись расчётного потолка — шести часов доходит до текста, а не прерывается
достижении предела; ошибкой при достижении предела (норма — `openspec/specs/storage`);
- сервисом пользуются несколько человек, и записи одного не видны другому; - сервисом пользуются несколько человек, и записи одного не видны другому;
- текст доступен там же, где загружали, — в приложении и в Telegram. Человек - текст доступен там же, где загружали. Человек узнаёт о его готовности, не
узнаёт о его готовности, не держа приложение открытым; держа приложение открытым;
- расшифровка не теряется: к записи возвращаются через месяц и находят её по - расшифровка не теряется: к записи возвращаются через месяц и находят её по
заголовку и темам; заголовку и темам;
- владелец видит расход по каждому пользователю и понимает, во что обходится - владелец видит расход по каждому пользователю и понимает, во что обходится
@@ -54,22 +56,35 @@
- **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за - **Разговор о записи.** Ответы на вопросы по содержанию и поиск по смыслу — за
границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута границей. Заголовок, пересказ и темы **внутри** границы: она сдвинута
2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него». 2026-08-10, и до того запись читалась «мы отдаём текст, а не выводы из него».
Направление — цель [text-insights](../tasks/items/text-insights.md). Считать уровни текста берётся задача
[llm-insights-adapter](../tasks/items/llm-insights-adapter.md).
- **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания, - **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания,
ни языковую модель: и речь, и выводы из текста считает внешний сервис. ни языковую модель: и речь, и выводы из текста считает внешний сервис.
- **Управление учётными записями.** Пользователей заводит и проверяет внешний - **Управление учётными записями.** Пользователей заводит и проверяет внешний
провайдер, свою регистрацию и свои пароли не делаем. Одно исключение появилось провайдер, свою регистрацию и свои пароли не делаем. Своя строка учётной
2026-08-11 вместе с решением про PocketBase: в панель администратора владелец записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
входит своим паролем, потому что подпустить к ней внешнего провайдера имя, названное провайдером, — заводит строку при первом обращении под новым
PocketBase не даёт. именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
сервис не решает никогда. Панель администратора со своим паролем владельца жила
здесь с 2026-08-11 по 2026-08-22 и ушла вместе со встроенным хранилищем.
*Изъятие одно:* при включённом предохранителе `[server] debug`, выключенном по
умолчанию, сервис подставляет запросу те заголовки входа, которые в бою даёт
обратный прокси. Своего входа, регистрации и проверки допуска он от этого не
заводит: подставленное имя проходит то же узнавание, что и пришедшее. Кого
пускать, провайдер решает во всяком прогоне без изъятия; в самом изъятии его не
спрашивают вовсе — сервис называет пришедшего сам. Тем изъятие и держится
выключенным умолчанием, а границу его держит спека
[access](../openspec/specs/access/spec.md).
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не - **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
обрабатываем. обрабатываем.
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый - **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
файл. Своей записи и работы без сети не делаем — граница цели файл. Своей записи и работы без сети не делаем.
[web-access](../tasks/items/web-access.md).
- **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради - **Файловое хранилище общего назначения.** Храним аудио и видео, отданные ради
речи в них. Складом произвольных файлов, папками и общим доступом к чужим речи в них. Складом произвольных файлов и папками сервис не становится. Общего
записям сервис не становится. доступа к чужим записям целью нет, и с 2026-08-14 его нет и на деле: у записи
есть владелец, и чужую по её идентификатору не отдают
([security.md](security.md), «Периметр»). Закрыла это задача
`record-ownership`.
- **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и - **Учёт денег.** Считаем объём, минуты и токены по каждому пользователю и
показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем: показываем их владельцу. Цен, счетов и отказов по исчерпании квоты не делаем:
пользователя, потратившего слишком много, останавливает разговор или отзыв пользователя, потратившего слишком много, останавливает разговор или отзыв
@@ -77,7 +92,9 @@
## Типовые сценарии ## Типовые сценарии
Первые два — основные, и сегодня не работает ни один: приложения нет. Первые два — основные, и сегодня не работает ни один. Приложение с 2026-08-15
есть, но экранов у него пока нет: оно открывается и показывает вошедшего, а
загрузку и список заводят `upload-and-status-screen` и `records-list-screen`.
1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до 1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до
десяти записей разом — диктофонные дорожки и видео, — и закрывает его. десяти записей разом — диктофонные дорожки и видео, — и закрывает его.
@@ -87,19 +104,17 @@
2. **Возвращение к записи.** Через месяц человек открывает список, находит 2. **Возвращение к записи.** Через месяц человек открывает список, находит
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
расшифровку. расшифровку.
3. **Голосовое из Telegram.** Пользователь шлёт боту голосовое сообщение, бот 3. **Загрузка по HTTP.** Программа шлёт `POST /app/audiorecords` со своим
отвечает «обрабатываю», через минуту приходит текст ответом на то же токеном, получает идентификатор записи и читает её карточку
сообщение. Записи, чей текст длиннее предела сообщения Telegram, приходят `GET /app/audiorecords/{id}`, пока не увидит `done`; текст забирает отдельным
несколькими частями. Работает сегодня. адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только тому, кого
4. **Файл через Telegram.** То же для аудиофайла или документа с аудио: бот назвал доверенный источник: неузнанный запрос всеми адресами отклоняется.
отличает их по MIME-типу и расширению. Работает сегодня. Своего способа представиться у программы нет его заводит `api-tokens`.
5. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном, Записи при этом разграничены: видны только записи того, чьим именем пришли.
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не 4. **Отказ на середине.** Конвертация или распознавание не удались — запись
увидит `done` и текст. Работает сегодня, но без токена и без разграничения получает признак остановки с причиной, и карточка записи отдаёт признак и
доступа. причину тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт:
6. **Отказ на середине.** Конвертация или распознавание не удались — задача доставка ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`.
переходит в `failed`, а пользователь получает сообщение о том, что именно не
вышло, и предложение повторить.
## Референсы ## Референсы
@@ -109,7 +124,11 @@
которой пользуемся: она и задаёт потолок по длине записи и формату. которой пользуемся: она и задаёт потолок по длине записи и формату.
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис - **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
перестанет устраивать по цене или по качеству русской речи. перестанет устраивать по цене или по качеству русской речи.
- **PocketBase** — хранилище взамен сегодняшнего SQLite, решено 2026-08-11 **PocketBase** побывала и референсом, и стеком, и ушла из проекта целиком.
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)). Учётные Референсом она быть перестала 2026-08-12, когда задача `pocketbase-storage`
записи оно хранит и получает от Authelia своим провайдером OIDC, но источником перевела её в стек; стеком — 2026-08-22, когда задача
их не становится: заводит и проверяет людей по-прежнему Authelia. `storage-without-pocketbase`
([adr](adr/ADR-2026-08-22-storage-without-pocketbase.md)) убрала её вместе с
панелью владельца и собственным адресным пространством. Хранилище у сервиса своё:
SQLite напрямую и файлы записей своим каталогом. Схема и раскладка —
[database.md](database.md).
+9
View File
@@ -20,8 +20,17 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
## Записи ## Записи
Две записи о PocketBase — [pocketbase.md](pocketbase.md) и
[pocketbase-defaults.md](pocketbase-defaults.md) — описывают библиотеку, ушедшую
из проекта 2026-08-22. Они остаются записями о прошлом, и строкой в каждой это
сказано.
| Дата | Запись | О чём | | Дата | Запись | О чём |
| --- | --- | --- | | --- | --- | --- |
| 2026-08-23 | [Разбор TOML: незнакомый ключ и незнакомая секция не отказ, а тишина](toml-unknown-keys.md) | `err == nil` на опечатке в имени секции, потерянное только в `MetaData.Undecoded()`, безопасное направление отката в BurntSushi/toml v1.5.0 |
| 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё |
| 2026-08-15 | [Раздача приложения: что делают за нас библиотека и сборщик](webapp-serving.md) | Раскодированный путь у маршрутизатора, второй журнал у PocketBase, нулевое время у вшитого файла, зависание установщика без сети |
| 2026-08-13 | [Разбор TOML: какое семейство отказов несёт значения из файла](toml-decode-errors.md) | Значения только в `ParseError.Message`, врущее поле `Line`, отказ значением в BurntSushi/toml v1.5.0 |
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки | | 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 | | 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
| 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково | | 2026-08-11 | [Фреймворк приложения: Svelte, Vue и React на одном экране](spa-framework.md) | Размер собранной статики, цена шага сборки, что у трёх кандидатов одинаково |
+3 -2
View File
@@ -49,8 +49,9 @@
## Захват чинится одним запросом ## Захват чинится одним запросом
Сегодняшний захват — два запроса подряд без транзакции Захват **на момент замера** — два запроса подряд без транзакции; после перехода
([../database.md](../database.md), «Представление данных»). Замер показал, что на PocketBase он свернулся в один с `RETURNING`
[../database.md](../database.md), «Представление данных». Замер показал, что
после перехода на PocketBase он сворачивается в один: движок за после перехода на PocketBase он сворачивается в один: движок за
`modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём есть, и на трёх `modernc.org/sqlite` v1.55.0 — версии 3.53.3, `RETURNING` в нём есть, и на трёх
горутинах разом запись получила **ровно одна**. горутинах разом запись получила **ровно одна**.
+44 -3
View File
@@ -1,17 +1,27 @@
# PocketBase: умолчания, которые ломают штатный сценарий # PocketBase: умолчания, которые ломают штатный сценарий
**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 —
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
Умолчания ниже принадлежат ушедшей библиотеке и ни на что в сервисе не влияют.
Живое из записки переехало в [../database.md](../database.md), «Настройки с
числовым значением», — потолок размера одной записи, потолок тела запроса и
снятый таймаут чтения, — и в
[ADR-2026-08-15-owner-required-by-schema](../adr/ADR-2026-08-15-owner-required-by-schema.md).
Наблюдения, снятые по ходу задачи `pocketbase-storage` уже на своём коде. От Наблюдения, снятые по ходу задачи `pocketbase-storage` уже на своём коде. От
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт [записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
панель**, эта — **что библиотека делает молча**, если её не переубедить. панель**, эта — **что библиотека делает молча**, если её не переубедить.
Все четыре наблюдения нашлись ревью, а не чтением документации: три из них Наблюдения нашлись ревью, а не чтением документации, и все об одном роде промаха:
выглядят как «значение по умолчанию — нет ограничения», а значат обратное. объявление библиотеки выглядит как «ограничения нет» либо «ограничение есть», а
значит обратное.
## Как снималось ## Как снималось
Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге Версия **0.39.10**, та же, что у первой записки. Прогоны — на пустом каталоге
данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые данных во временном каталоге и на поднятом сервере `127.0.0.1:18099`; боевые
данные и ключи не участвовали. Числа ниже сняты 2026-08-11 и 2026-08-12. данные и ключи не участвовали. Числа сняты 2026-08-11 и 2026-08-12, последнее
наблюдение — 2026-08-15.
## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела» ## Нулевой потолок у поля файла значит 5 МиБ, а не «без предела»
@@ -86,6 +96,34 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения прогоном: при первом запуске строка со ссылкой в журнале есть, после заведения
владельца при следующем запуске её нет. владельца при следующем запуске её нет.
## Обязательность связи проверяется у записи, а не у колонки
`Required` у поля связи — правило **проверки записи при сохранении**, а не
ограничение таблицы. Шаг схемы, объявляющий колонку обязательной на базе, где уже
лежат строки с пустым значением, проходит **зелёным** и такие строки оставляет:
```
core/field_relation.go:156 ColumnType отдаёт TEXT DEFAULT '' NOT NULL — от Required не зависит
core/collection_validate.go ни одной проверки, читающей существующие строки
```
Проверено прогоном 2026-08-15 на копии хранилища во временном каталоге: строка с
пустым владельцем заведена до шага, шаг применён тем же кодом, что и на подъёме,
и вывод:
```
STEP 003 (Required=true) поверх ничьей записи: err=<nil>
ПОСЛЕ ШАГА: строка на месте, owner=""
Save остановленной ничьей записи: err=failed to update audio record: owner: cannot be blank.
```
Следствие для нас: оставленная строка становится **незакрываемой**. Захват идёт
сырым запросом мимо проверки и выдаёт её воркеру, а всякое сохранение отказывает —
включая то, которым ставится признак остановки. Искать такие строки надо запросом
до выкладки, а не прогоном самого шага: прогон чистую базу от грязной не
отличает. Цена решения записана в
[adr/ADR-2026-08-15-owner-required-by-schema.md](../adr/ADR-2026-08-15-owner-required-by-schema.md).
## Чего эта записка не узнала ## Чего эта записка не узнала
- **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из - **Во что обходится потолок в 8 ГиБ на диске.** Число выбрано расчётом из
@@ -96,3 +134,6 @@ core/field_file.go:310 if f.MaxSize <= 0 { return DefaultFileFieldMaxSize }
— нет. — нет.
- **Поведение под одновременной правкой панели и конвейера в бою.** Проверено - **Поведение под одновременной правкой панели и конвейера в бою.** Проверено
тестом на одной машине, не живой нагрузкой. тестом на одной машине, не живой нагрузкой.
- **Сколько строк с пустой связью выдерживает смена признака обязательности.**
Проверено на одной строке: суть наблюдения — сам факт отсутствия проверки, а не
её цена на объёме.
+129
View File
@@ -1,5 +1,11 @@
# PocketBase: что даёт панель администратора # PocketBase: что даёт панель администратора
**Записка о прошлом.** PocketBase ушла из проекта целиком 2026-08-22 —
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
Панели у сервиса нет, и ничто из описанного ниже сегодня не работает. Записка
остаётся затем, что ею мерили цену потери: возврат остановленной записи в работу
делает подкоманда `cmd/devtools resume`, а остальное приносят отдельные задачи.
Отвечает на вопрос разведки `pocketbase-admin-fit`: что панель показывает и Отвечает на вопрос разведки `pocketbase-admin-fit`: что панель показывает и
правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы
держать перевод хранилища в планах. держать перевод хранилища в планах.
@@ -90,6 +96,10 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
`modernc.org/sqlite`, а не через `mattn/go-sqlite3`. Требование CGO записано `modernc.org/sqlite`, а не через `mattn/go-sqlite3`. Требование CGO записано
сегодня свойством стека в `../../CLAUDE.md`, и перевод его снимает. сегодня свойством стека в `../../CLAUDE.md`, и перевод его снимает.
*Уточнено 2026-08-12:* перевод состоялся, и требования CGO в стеке больше нет —
[../../CLAUDE.md](../../CLAUDE.md), «Стек»: компилятор C нужен только детектору
гонок в гейте.
Бинарник пробника — 33 954 634 байта против 43 498 904 у сегодняшнего приложения Бинарник пробника — 33 954 634 байта против 43 498 904 у сегодняшнего приложения
(`go build` без флагов). **Числа не сравнимы напрямую:** в пробнике нет ни бота, (`go build` без флагов). **Числа не сравнимы напрямую:** в пробнике нет ни бота,
ни клиента SpeechKit, ни клиента Object Storage. Что даст сборка после перевода, ни клиента SpeechKit, ни клиента Object Storage. Что даст сборка после перевода,
@@ -99,6 +109,91 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало библиотечной сборке тоже: пустое приложение с одним своим обработчиком отвечало
на `/_/` кодом `200`. на `/_/` кодом `200`.
## Вход через OIDC: что выяснилось при реализации
Дописано 2026-08-12 задачей `oidc-login`. Все находки ниже получены одним
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
против настоящего хранилища на временном каталоге — в ходе ревью того change. Живой Authelia в прогонах не
было ни разу: провайдера подменял свой `httptest`-сервер.
**Коллекция `users` приходит открытой.** Системный шаг библиотеки заводит её с
`CreateRule = ""` (создание доступно анониму) и `PasswordAuth.Enabled = true`
(`migrations/1640988000_init.go`, `core/collection_model_auth_options.go`).
Прогон подтвердил: `POST /api/collections/users/records``200`, следом
`auth-with-password``200` с токеном. То есть закрытие API за вход обходится
двумя запросами, пока эта поверхность не закрыта своим шагом схемы.
**Правило создания нельзя закрывать полностью.** `CreateRule = nil` означает «только
суперпользователь», а запись при первом входе заводит **внутренний** запрос
самого обмена, идущий без таких прав (`apis/record_crud.go`: проверка
`!hasSuperuserAuth && collection.CreateRule == nil`). Прогон: с `nil` вход
кончался `401`, учётных записей `0`. Работает правило
`@request.context = "oauth2"` — контекст ставит сам обмен
(`core.RequestInfoContextOAuth2`), а посторонний запрос приходит с контекстом по
умолчанию. Открывать правило пустой строкой при этом нельзя: публичный обмен
принимает поля создаваемой записи от вызывающего.
**Обмен кода наружу не экспортирован.** Пакет `apis` отдаёт ошибки, middleware,
`NewRouter`, `Serve` и обёртки; сам обмен — неэкспортированная функция за
маршрутом `POST /api/collections/{c}/auth-with-oauth2`, принимающая `provider`,
`code`, `codeVerifier`, `redirectURL`. Собственный `/api/oauth2-redirect` служит
другому — он ищет клиента realtime-подписки по параметру `state`, то есть
обслуживает всплывающее окно JS-клиента, а не серверный вход.
**`apis.NewRouter` не идемпотентна: собирать её нужно один раз и держать, а не
создавать заново при каждом вызове.**
Она зовёт `bindRealtimeEvents` и `bindUIExtensions`, а те вешают девять
обработчиков **на приложение** и без поля `Id`; `hook.Bind` такому генерирует
новый идентификатор и **добавляет**. Замер: пять вызовов подряд подняли
`OnModelAfterUpdateSuccess` с 4 до 14, а 3000 вызовов — время сотни сохранений
записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. Освобождения нет, только
перезапуск.
**Связывание учётной записи идёт по `sub`, а не найдя — по почте.** Обмен ищет
запись в `_externalAuths` по `providerId`, и лишь затем `FindAuthRecordByEmail`
(`apis/record_auth_with_oauth2.go`). Отсюда цена открытой регистрации: запись,
заведённая посторонним на чужой адрес почты, достаётся первому же настоящему
входу с этим адресом.
**Защищённое поле файла судится двумя вещами сразу** — коротким токеном файла из
строки запроса **и** правилом просмотра коллекции (`apis/file.go`). Незаданное
правило означает «только суперпользователь», поэтому одной пометки `Protected`
мало: прогон показал `404` анониму, вошедшему кукой, вошедшему заголовком и
вошедшему с законно полученным токеном файла — пока правило не назначено.
**Сессия по умолчанию продлеваема бессрочно.** Токен несёт поле
`refreshable=true`, и `POST /api/collections/{c}/auth-refresh` меняет его на
новый с новым сроком. Прогон: три продления подряд, каждое `200`, `exp` растёт.
Настройки «выдавать непродлеваемую сессию» у коллекции нет — закрывается только
слоем приложения поверх маршрута.
**Подпись сессии считается от секрета коллекции и ключа записи**, обе величины в
базе (`core/record_query.go`, `FindAuthRecordByToken`). Отсюда два следствия:
сессия переживает перезапуск сервиса сама, а смена ключа записи
(`Record.RefreshTokenKey()`) обесценивает все её выданные сессии разом.
**Куки библиотека не читает вовсе** — сессию берёт только заголовком
`Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
**Учётную запись обмен ищет двумя способами подряд, а связь с провайдером
уникальна.** Сперва — по неизменяемому признаку провайдера, а не найдя — по
адресу почты (`apis/record_auth_with_oauth2.go`, ветка `case authUser.Email !=
""``FindAuthRecordByEmail`). Найденной записи он пытается добавить связь, а на
ней стоит уникальный индекс
`idx_externalAuths_record_provider (collectionRef, recordRef, provider)`. Отсюда
исход, обратный ожидаемому: два разных признака провайдера с **одной** почтой не
сливаются в одного владельца молча — второй вход отвергается, обмен отдаёт `400`,
сервис — `401` со строкой `Failed to exchange provider code`, а настоящая причина
остаётся в журнале хранилища строкой `failed to save linked rel: … Value must be
unique`. Дописано 2026-08-15 задачей про заглушку OIDC; получено прогоном против
временного каталога — чтение исходников давало ту же цепочку, но противоположную
развязку.
**Журнал запросов пишет строку запроса целиком.** `activityLogger` на корневом
роутере кладёт `RequestURI` полем `url` в таблицу `_logs`, ретеншен по умолчанию
`MaxDays: 5`. Значит всё, что пришло параметром адреса, оседает там на пять
суток; проект умолчание не переопределяет.
## Что отвергнуто и почему ## Что отвергнуто и почему
- **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто: - **Держать файлы на диске как сейчас, а в базе — путь строкой.** Отвергнуто:
@@ -111,3 +206,37 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
способов: способов:
вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное вместе с панелью отказ выбрасывал бы уход CGO и встроенное резервное
копирование, которых у сервиса-архива нет никаких. копирование, которых у сервиса-архива нет никаких.
## Разграничение по владельцу: что выяснилось при реализации
Дописано 2026-08-14 задачей `record-ownership`. Все находки ниже получены одним
способом: чтением исходников `pocketbase@v0.39.10` из кеша модулей и прогонами
против настоящего хранилища на временном каталоге — в ходе ревью того change.
**Связь с выключенным каскадом не удерживает целостность при удалении.**
`core/record_model.go`, `deleteRefRecords`: при `CascadeDelete = false` и
необязательном поле хранилище **вынимает** идентификатор из поля связи и
сохраняет запись через `SaveNoValidate`. То есть «не уносить записи следом» и
«сохранить у них владельца» — разные вещи, и связь даёт только первое.
**Наружу проходит только ошибка роутера.** `apis/record_crud.go` заворачивает
отказ хука в `firstApiError(err, e.BadRequestError("Failed to delete record. Make
sure that the record is not part of a required relation reference.", err))`, а
`firstApiError` берёт первый аргумент, только если он `*router.ApiError`. Обычная
ошибка из хука до ответа не доезжает вовсе, и спрашивающий получает библиотечную
подсказку про обязательную связь — в нашем случае указывающую не на ту связь.
**`apis/file.go` выдаёт токен файла на предъявителя, а не на файл.** О файле при
выдаче он не спрашивает. Владельца судит переход по ссылке: правило просмотра
коллекции проверяет защищённое поле файла по учётной записи **из токена**. Значит
чужой токен получить можно всегда, а скачать по нему чужой файл — нет.
**Проверка сессии с именем коллекции отвечает `403`, а не `401`.**
`apis.RequireAuth("users")` пускает только запись названной коллекции; предъявитель
из другой — например, владелец панели — узнан, но не годится, и код отказа это
различает.
**Связь в SQLite лежит пустой строкой, а не `NULL`.** `RelationField.ColumnType`
даёт `TEXT DEFAULT '' NOT NULL`; сырой запрос и чтение через запись коллекции
совпадают побайтово. «Умолчания у колонки нет» верно по замыслу — пустое значение
не совпадает ни с кем, — но не буквально на уровне схемы.
+2 -1
View File
@@ -25,7 +25,8 @@ Nuxt, Next — не рассматривали: конвенция
правил, и в сборке он у всех троих совпал до байта (883 Б), что и подтверждает правил, и в сборке он у всех троих совпал до байта (883 Б), что и подтверждает
одинаковость экрана. одинаковость экрана.
- **Четыре маршрута** — тот же экран плюс три заглушки и переходы между ними: - **Четыре маршрута** — тот же экран плюс три заглушки и переходы между ними:
столько экранов у цели [web-access](../../tasks/items/web-access.md). Роутеры столько экранов предполагала задача `spa-skeleton`, сделанная 2026-08-15.
Роутеры
`svelte-spa-router` 5.1.1, `vue-router` 5.2.0 и 4.6.4, `react-router` 8.3.0. `svelte-spa-router` 5.1.1, `vue-router` 5.2.0 и 4.6.4, `react-router` 8.3.0.
- **Размеры** — `stat -c%s` и `gzip -9c | wc -c` по файлам `dist/`. Числа Vite в - **Размеры** — `stat -c%s` и `gzip -9c | wc -c` по файлам `dist/`. Числа Vite в
своём выводе печатает по другому уровню сжатия, поэтому в таблицах ниже стоят своём выводе печатает по другому уровню сжатия, поэтому в таблицах ниже стоят
+127
View File
@@ -0,0 +1,127 @@
# Хранилище: PocketBase против голого SQLite с каталогом файлов
Отвечает на вопрос владельца от 2026-08-22: не окажется ли голый SQLite с
каталогом аудиозаписей гибче встроенной PocketBase. Записи в каталоге задач у
вопроса нет — он поднят по ходу работы, и разведка идёт от него, а не от
постановки.
Мерилось **дерево репозитория**, а не внешний сервис: вопрос о том, что
библиотека уже держит в этом коде и чем за это плачено. Замеров на живом потоке
нет.
## Как снималось
Дата — 2026-08-22, коммит `a8fb479`, дерево чисто. Всё считано в самом
репозитории, боевые данные не участвовали.
- **Пакеты в сборке:** `go list -deps ./... | wc -l`**584**; из них с
`pocketbase` в пути — `go list -deps ./... | grep -c pocketbase`**40**.
- **Кто тянет тяжёлый модуль:** `go mod why -m <модуль>` по семи модулям.
- **Объём кода:** `find <каталог> -name '*.go' [! -name '*_test.go'] | xargs wc -l`.
- **Протечка библиотеки за адаптер:** `grep -l "pocketbase/" internal/controller/http/*.go`.
- **Узлы для варианта с монтированием** — чтением кэша модулей:
`router.Router.BuildMux()` отдаёт `http.Handler`
(`tools/router/router.go:61`), а `apis.Serve` присваивает `e.Server.Handler`
(`apis/serve.go:223`). Прототипа на этих двух узлах не собирал — проверено
только их существование.
## PocketBase здесь — фреймворк приложения, а не хранилище
Разрез «хранилище против хранилища» вопроса не покрывает: библиотека держит
шесть ролей сразу, и только две из них про хранение.
| Роль | Где | Чем заменяется |
| --- | --- | --- |
| SQLite без CGO, пул записи одним соединением | `pbrepo.New` | `modernc.org/sqlite` напрямую, свои WAL, `busy_timeout` и единственный писатель |
| Схема и шаги миграций | `internal/adapter/repo/pocketbase/migrations`, 1039 строк | свой раннер либо `goose`, который тут уже был |
| Файлы записей на диске и отдача `/api/files/…` по токену | `file_repo.go`, `FileTokenPath` | каталог и свой обработчик отдачи |
| Маршрутизатор, цепочка слоёв с приоритетами, ограничитель частоты | `internal/controller/http` целиком | `net/http` и счётчик по ключу |
| Учётная запись значением `e.Auth` | `identity.go` | строка своей таблицы |
| Панель `/_/` | покупалась ради неё | ничем |
**Библиотека вышла за адаптер.** Из пяти не-тестовых файлов
`internal/controller/http` её импортируют **все пять**, из шести тестовых —
**все шесть**: 1497 строк кода контроллера и 3075 строк его проверок написаны
на `*core.RequestEvent`. Адаптер хранилища — ещё 1787 строк без шагов схемы.
## Три довода прежнего решения: один умер 2026-08-22
[ADR-2026-08-11-pocketbase-storage-with-admin-panel](../adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
покупал библиотеку за три вещи разом, и отвергал половинчатые пути тем, что
порознь ни одна перевода не оправдывает.
1. **Правка записей в панели** — работает. Единственная роль, которой сегодня
нет замены.
2. **Файлы видны в панели, и копия накрывает их вместе с базой** — работает; тот
же ADR оговаривает, что копии сервер делает своими средствами.
3. **Вход через её провайдер OIDC****отпал**. Цитата, которой ADR отверг
вариант «взять библиотеку только хранилищем»:
> Панель показывает свою коллекцию пользователей и ничего больше. Отсюда
> следствие для целевого входа: **пользователи Authelia в панели не появятся,
> если вход делает само приложение**.
Вход делает само приложение с 2026-08-22
([ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md)):
пришедшего называет заголовок прокси, а учётную запись заводит наш
`EnsureUser`. Пользователи в коллекции есть **потому, что их пишет наш код**,
а не провайдер библиотеки. Довод, которым отвергнут отвергнутый вариант,
перестал быть верным — и вместе с ним отпало основание держать вход в
библиотеке.
## Что оплачено и не работает
- **Захват записи идёт сырым запросом мимо записей коллекции**
(`record_repo.go`, `FindAndAcquire`): `app.DB().NewQuery` с `UPDATE … RETURNING`.
На самом горячем месте абстракция не помогает, а её ограничения действуют —
хуки коллекции не срабатывают, время изменения проставляет наш запрос, времена
сравниваются строками побайтово.
- **Налог соседства двух периметров в одном процессе и на одном порту.**
Появился секрет, которого не было, — пароль суперпользователя. Панель
опубликована в интернет, и её барьер обходится подменой знака: `/%5f/`
попадает в ту же группу, что `/_/`, а правило прокси написано на литерал
([../security.md](../security.md)); дефект не закрыт. Узнавание нельзя
повесить на всю поверхность хранилища, иначе узнанный перепишет себе
`provider_login` и заберёт чужой архив — область слоя сужена, и причина стоит
абзацем в `identity.go`.
- **Пространство `/api/` нельзя закрыть на прокси**, потому что за файлами
ходит туда браузер пользователя. Своя отдача файла снимает это ограничение:
закрыть можно всё пространство хранилища разом.
## Чем платит уход
- **Панель теряется целиком.** Правка записи, возврат остановленной в работу,
просмотр очереди фильтром, прослушивание файла — всё это сегодня живёт только
там.
- **Пишем сами** шаги схемы, отдачу файла, ограничитель частоты и настройку
базы. Последняя — не формальность: у `modernc.org/sqlite` запись идёт
единственным соединением, и библиотека держит это за нас двумя пулами.
- **Раскладка каталога данных** названа необратимой в `../../CLAUDE.md`.
На стройке цена нулевая: на сервере пусто, переносить нечего.
## Что уходит из сборки, а что остаётся
`go mod why -m` показал, что шесть модулей достижимы **только** через
PocketBase: `disintegration/imaging` (через `tools/filesystem`),
`domodwyer/mailyak/v3` и `golang-jwt/jwt/v5` (через `core`),
`ganigeorgiev/fexpr` (через `apis`), `spf13/cobra` (набор команд),
`go-sql-driver/mysql` (через `pocketbase/dbx`). `modernc.org/sqlite` остаётся:
он нужен и без библиотеки, и требование обходиться без CGO с ним сохраняется.
## Варианты и что выбрано
| Вариант | Что делает | Чем платит |
| --- | --- | --- |
| Оставить как есть | ноль работы | долг растёт с каждой задачей на `RequestEvent` |
| **Уйти целиком** | SQLite напрямую, каталог файлов, свои шаги схемы и маршруты | панель теряется сразу, работа одной порцией |
| Уйти в два шага | сначала снять с периметра HTTP, панель оставить; потом с хранилища | панель жива в промежутке, работа та же, но двумя порциями |
| Локализовать протечку | библиотека не выходит за `internal/adapter/repo` | решение откладывается, панель остаётся |
**Решением владельца от 2026-08-22 взят уход целиком, и панель не заменяется
ничем**: пока стройка не кончилась, остановленную запись возвращают в работу
запросом к базе. Решение записано в
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
Обстоятельство, которое решило дело: на сервере данных нет и сервис остановлен,
поэтому смена стоит только кода. Дешевле она не станет никогда — каталог
`internal/controller/http` прирастает кодом на чужих типах с каждой задачей.
+55
View File
@@ -0,0 +1,55 @@
# Разбор TOML: какое семейство отказов несёт значения из файла
Отвечает на вопрос, возникший по ходу задачи `telegram-enabled-flag`: можно ли
пересказывать отказ библиотеки разбора в журнал, если в файле настроек лежат
секреты. Наблюдение понадобилось потому, что ревью дизайна назвало этот путь
утечкой, а чинить его без разреза пришлось бы выбрасыванием всего текста отказа —
то есть платой разборчивостью на каждой опечатке.
## Как снималось
Не замером, а **чтением исходников** зависимости, зафиксированной в `go.mod`:
`github.com/BurntSushi/toml` версии **v1.5.0**. Смотрел `error.go`, `parse.go`,
`decode.go`, `meta.go`, `lex.go` в кэше модулей. Дополнительно прогонял
`toml.Decode` на правдоподобных опечатках — в каталоге вне репозитория, чтобы не
править код проекта.
## Что выяснилось
- **Значения из файла несёт ровно одно семейство отказов — `toml.ParseError`.**
Его поле `Message` собирается из разбираемого куска: `Invalid float value: %q`
(`parse.go:341`), `invalid duration: %q`, `%v is out of range`, `Invalid
integer %q`. Туда же лексер отдаёт свои отказы через `panicItemf`
(`parse.go:134`).
- **Прочие отказы декодера значений не содержат вовсе.** Их строит `md.e`
(`decode.go:577`) и `md.badtype` — из имён ключей, имён типов (`%T` через
`fmtType`) и длин. Обойдены все места: `decode.go:282,288,297,329,348,385,388,399,428,437,467,487,518,552,561`.
- **`LastKey` секрета нести не может.** Текущий ключ присваивается только после
`itemKeyEnd`, то есть после `=` (`parse.go:200`), а лексер ключа до `=` не
доходит (`lex.go:481-501`). Посторонняя строка со значением ключом не станет.
- **Поле `Line` у `ParseError` врёт, а `Position.Line` — нет.** `panicErr` и
`panicItemf` кладут в устаревшее поле `Line` значение `it.pos.Len`, то есть
**длину**, а не номер строки (`parse.go:97,106`). Брать надо `Position.Line`.
- **`ParseError` возвращается значением, не указателем** (`decode.go:564`,
`parse.go` целиком), поэтому `errors.As` берёт целью `toml.ParseError`, а не
`*toml.ParseError`. `Unwrap` у типа нет.
- **Ветка без последнего ключа достижима обычной опечаткой.** Незакрытая скобка
секции даёт `LastKey=""`:
```
вход "[telegram\nenabled = true\n"
→ LastKey="" err=toml: line 2: expected '.' or ']' to end table name, but got '\n' instead
```
## Что из этого следует для кода
Разрез по семейству отказа: `ParseError` пересобирается своими словами — путь,
строка, столбец, последний ключ, — а его `Message` не берётся; прочие отказы
проходят как есть. Так инвариант «Секрет не покидает конфиг» держится, а
несовпадение типов по-прежнему называет ключ и типы.
**Наблюдение привязано к версии.** Версия, переложившая значение в другое
семейство или сменившая возврат на указатель, вернёт утечку молча. Держат это
проверки поломанного файла настроек в `internal/config/config_test.go`; при
подъёме версии библиотеки их отказ читается как сигнал перечитать эту записку, а
не как случайный шум.
+67
View File
@@ -0,0 +1,67 @@
# Разбор TOML: незнакомый ключ и незнакомая секция не отказ, а тишина
Отвечает на вопрос, возникший по ходу задачи `config-test-headers-login`: что
делает декодер настроек с ключом и секцией, которых структура не знает, и виден
ли этот случай хоть чем-нибудь. Наблюдение понадобилось потому, что ревью нашло
опечатку в имени новой секции `[auth.test_headers]`, проходящую молча, и без
разреза нельзя было сказать, где кончается предмет задачи и начинается свойство
самой библиотеки.
Соседняя записка о той же библиотеке — [toml-decode-errors.md](toml-decode-errors.md)
— разбирает семейства **отказов**; здесь предмет обратный: случай, отказа не
дающий.
## Как снималось
Прогонами на зависимости, зафиксированной в `go.mod`:
`github.com/BurntSushi/toml` версии **v1.5.0**. Оба уровня снял триаж ревью
2026-08-23, отчёт —
[triage-2026-08-23.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/review/triage-2026-08-23.md),
находка 2 и факт, подтверждённый разбором прохода `operations`. Временные файлы
прогонов удалены, бинарник поднимался в каталог вне репозитория, не в `data/`.
- **Модульный.** Вход
`[server]\ndebug = true\n[auth]\n[auth.test_headrs]\n"Remote-User" = "dev"`
опечатка в имени секции.
- **Сквозной.** Настоящий бинарник на конфиге с той же опечаткой, порт 18099,
каталог данных вне репозитория; проба `curl /app/me`.
## Что выяснилось
- **Незнакомая секция и незнакомый ключ отказа не дают: `decode err=<nil>`.**
Разбор проходит целиком, поля структуры остаются нулевыми, и отличить «в файле
этого нет» от «в файле это написано с опечаткой» по результату разбора нельзя.
В прогоне: `Server.Debug=true len(TestHeaders)=0`.
- **Потерянное называет только `MetaData.Undecoded()`.** Он возвращает перечень
путей, которых структура не знала: `[auth.test_headrs
auth.test_headrs.Remote-User]`. Значение это в проекте не читает никто — ни
загрузка настроек, ни проверки старта.
- **Контроль показывает, что дело в уровне, а не в разборе вообще.** Ту же
опечатку **внутри** известной секции (`Remote-Usr` вместо `Remote-User`)
ловит проверка старта — `auth: секция [auth.test_headers] называет
заголовок, которого сервис не читает: Remote-Usr`, — потому что судит её код
проекта, а не библиотека. Ошибка в имени самой секции до этого кода не
доходит.
- **Сквозной прогон следа не оставляет вовсе.** Бинарник поднимается без
предупреждения, `curl /app/me` отвечает `401`, а в журнале стоит только
`INFO "Incoming request" … http.status_code=401`.
- **Отсюда направление отката бинарника безопасно.** Прежний образ, получивший
конфиг с ключами, которых его структура ещё не знает, эти ключи игнорирует и
поднимается. Свойство держится ровно на тишине выше: перечень
`MetaData.Undecoded()` никто не судит.
## Что из этого следует для кода
Свойство сегодня используется, а не терпится: правило выкладки «конфиг после
образа» опирается именно на него, и его дом — [../architecture.md](../architecture.md),
«Эксплуатация». Здесь записано, чем свойство обеспечено и как проверено, а не
надо ли его менять.
Отсюда же цена любой будущей проверки `MetaData.Undecoded()`: непонятый ключ,
роняющий старт, закрывает опечатки во всех секциях разом — и тем же движением
снимает безопасность отката, потому что прежний образ перестанет поднимать
конфиг новее себя. Разменивать одно на другое — отдельное решение владельца,
а не попутная правка.
**Наблюдение привязано к версии.** Версия, начавшая судить незнакомые ключи
сама, сменит оба следствия разом — молчаливую опечатку и безопасный откат.
+69
View File
@@ -0,0 +1,69 @@
# Раздача приложения: что делают за нас библиотека и сборщик
Отвечает на вопросы, возникшие по ходу задачи `spa-skeleton`, — какие свойства
раздачи приходят не из нашего кода, а из стандартной библиотеки, из PocketBase и
из инструментов приложения. Наблюдения понадобились потому, что ревью нашло три
места, где записанное намерение расходилось с тем, что на деле делает чужой код.
## Как снималось
Прогонами на живом бинарнике (свой конфиг с выдуманными ключами, свой каталог
данных вне репозитория) и чтением исходников зависимостей, зафиксированных в
`go.mod`: `github.com/pocketbase/pocketbase` версии **v0.39.10** и стандартной
библиотеки Go. Отдельно — прогоны установщика и сборщика приложения в контейнере.
Числа ниже сняты 2026-08-15 на этом прогоне, а не взяты из чужих записок.
## Что выяснилось
- **Маршрутизатор стандартной библиотеки сравнивает сегменты пути после
раскодирования.** Поэтому `/%5f/` попадает туда же, куда `/_/`, а `/%68ealth`
— туда же, куда `/health`: ответы совпадают байт в байт. Исходная форма
остаётся в `URL.RawPath`, и решение, принимаемое **вне** сервиса по сырому пути
— правилом обратного прокси, — такой формы не видит. Цена записана в
[security.md](../security.md), «Периметр»: барьер перед панелью владельца
обходится подменой одного знака.
- **PocketBase пишет каждый запрос в свою таблицу журнала**, а не только в вывод
контейнера: слой `activityLogger` подключён ко всем маршрутам и кладёт путь
целиком
(до 3000 знаков), адрес отправителя, источник перехода и клиент. Умолчания —
хранить пять суток, адрес записывать. Готовая раздача статики
(`apis.Static`) первой же строкой ставит признак «успех не записывать»; своя
раздача этого признака не наследует, и его надо ставить руками. Отсюда правило
в [review.md](../review.md): журналов **два**, и говорить надо про оба.
- **Вшитая файловая система не несёт времени правки.** `embed.FS` отдаёт нулевое
время у любого файла, поэтому отдача файла стандартной библиотекой никогда не
отвечает подтверждением «не менялось» — всякая проверка приходит полным телом.
Заголовок, обещающий дешёвую проверку, без метки ответа обещает то, чего код не
делает.
- **Сборщик приложения чистит выходной каталог перед каждой сборкой.** Метка,
положенная рядом с собранным ради того, чтобы каталог существовал в git,
уезжает первым же прогоном. Живёт она только этажом выше выходного каталога.
- **Проверка типов однофайловых компонентов не работает с седьмой линией
TypeScript.** `vue-tsc` версии 3.3.10 зовёт у компилятора точку входа, которой
новый компилятор не отдаёт, и сборка падает на этапе проверки типов. Рабочая
пара — пятая линия TypeScript.
- **Установщик пакетов без сети не отказывает, а виснет.** Он уходит в повторы с
нарастающей паузой **на каждом пакете**, и набор проверок вместо кода отказа
просто стоит. Пределы у отдельных обращений положения не спасают: их сумма и
даёт зависание. Помогает короткое обращение-проба перед установкой.
- **Вес приложения в бинарнике равен весу собранного.** Замер: две сборки, с
собранным приложением и с пустым каталогом, разница — 86 072 байта, то есть
ровно `index.html` плюс единственный ресурс. Собранное приложение на четыре
экрана в разведке `spa-framework` весило того же порядка.
## Чего эта записка не узнала
- **Во что ступень сборки обходится образу по времени.** Прогон до конца не
доходит: из контейнеров этой машины нет исходящей сети при рабочем разрешении
имён. По весу вопрос закрыт иначе — ступень в рабочий слой не копируется, и
финальный образ от неё не растёт вовсе.
- **Как поведёт себя раздача под настоящим потоком.** Ограничителя частоты на
корневом маршруте нет, а профиля нагрузки у проекта нет тоже.
- **Что делает настоящий браузер** с этими заголовками: проверено кодами ответов
и заголовками, а не браузером.
+681 -88
View File
@@ -2,11 +2,47 @@
## Как настроен конвейер ## Как настроен конвейер
Конвейер ревью прогонялся один раз — 2026-08-11, на изменении Артефакты прогонов лежат в `openspec/changes/archive/<id>/review/`; имя файла
`fix-http-handler-tests`; его триаж лежит в менялось по ходу — `triage.md`, `report.md`, `design-review.md`,
`openspec/changes/archive/2026-08-11-fix-http-handler-tests/review/triage.md`. `code-review.md`, `triage-<дата>.md`. Самый ранний — `fix-http-handler-tests`
Разделы ниже заполнены наперёд по коду и правятся по итогам прогонов: «Типовые 2026-08-11; самый поздний здесь не называется: строка протухала бы с каждым
ложноположительные» первым прогоном уже пользовались. прогоном, и смотреть его надо в самом архиве.
Конвейер прогонялся и на работе, шедшей без своего изменения openspec; артефакта
в архиве у таких прогонов нет, и урожай их виден только записями журнала ниже.
Разделы ниже заведены наперёд по коду 2026-08-11 и с тех пор правятся урожаем
прогонов.
**Проход, поднявший сервис, обязан его остановить.** Живой прогон стал доступен
2026-08-13 (см. «Недоступно проверке»), и первый же им воспользовался: враждебный
проход поднял сервис на своём порту и оставил работать. Следующий прогон занять
порт не смог, а его запросы молча ушли к чужому процессу — то есть замеры
относились к прежней сборке, и по ним едва не был объявлен исход. Отсюда два
правила, оба прозой и без механизации: **поднял — останови за собой**, а
**меряющий убеждается, что отвечает его собственная сборка** (порт занят им,
новое поведение видно в выводе). Признак дешёвый: если ожидаемого нового поля,
метрики или строки нет вовсе — вероятнее всего, отвечает не твой процесс.
**Ни один проход не сообщает свой потолок, и это надо читать как границу
покрытия.** Прогон `telegram-enabled-flag` 2026-08-13: у прохода есть потолок
находок, и устав велит объявлять строкой, сколько осталось за срезом и какого
рода. Ни один из четырёх проходов такой строки не дал, и заметил это только
триаж. Пока так, «находок больше нет» в отчёте прохода неотличимо от «больше не
поместилось». Выше прочих риск у прохода, вбирающего темы разом: у него одна
квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем;
остаётся сверка триажа.
Пробел повторился на прогоне `config-test-headers-login` 2026-08-23, и это уже
не единичный случай: строки о потолке не дал ни один из шести проходов, а
заметил это снова только триаж. Состав прогона при этом был самым широким из
тогда доступных, — и разница с прошлым разом ровно в числе проходов,
промолчавших одинаково. Читать пробел надо как границу покрытия каждого прогона,
а не как свойство одного из них.
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых
узлах».
### Типовые узлы ### Типовые узлы
@@ -23,14 +59,34 @@
- повтор шага на той же задаче не создаёт лишних файлов и записей; - повтор шага на той же задаче не создаёт лишних файлов и записей;
- отвечает пользователю ровно один раз. - отвечает пользователю ровно один раз.
**Транспорт** (`internal/controller/tg`, `internal/controller/http`): **Транспорт** (`internal/controller/http`):
- проверяет право отправителя до всякой работы; - проверяет право отправителя до всякой работы;
- не логирует ошибку, которую уже залогировал доменный слой; - не логирует ошибку, которую уже залогировал доменный слой;
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст; - переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
- закрывает то, что открыл, на всех ветках выхода. - закрывает то, что открыл, на всех ветках выхода.
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`, `adapter/telegram`): **Раздача собранного приложения и шаг его сборки** (`controller/http/webapp.go`,
шаг `front`):
- путь, принадлежащий корню сервиса, разметку не отдаёт никогда, а перечень
корней порождает регистрацию маршрутов, а не описывает её;
- несовпавший ресурс под каталогом сборщика отвечает `404`, а не разметкой с
кодом `200`;
- раздача ставит долгий неотзываемый срок хранения **только** файлу из каталога
сборщика: отозвать его у браузера сервису нечем;
- отсутствие сборки громкое — код ответа, страница и строка журнала; «сборки
нет» отличается от «файла нет»;
- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он
**отказывает, а не висит**;
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала.
Журнал у сервиса с 2026-08-22 **один** — свой, в вывод контейнера: второй
ушёл вместе со встроенным хранилищем, которое клало путь целиком вместе с
адресом отправителя. Правило при этом расширилось, а не сузилось: путь не
пишется дословно ни под каким корнем, включая корень приложения.
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
- имеет таймаут и не виснет, когда внешний сервис не отвечает; - имеет таймаут и не виснет, когда внешний сервис не отвечает;
- не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта; - не кладёт секрет в URL и не даёт ему утечь через ошибку транспорта;
@@ -38,12 +94,20 @@
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в - вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
успех молча. успех молча.
**Репозиторий SQLite** (`adapter/repo/sqlite`): **Репозиторий хранилища** (`internal/adapter/repo/sqlite`; шаги схемы —
подпакетом `migrations`):
- список колонок совпадает во всех четырёх запросах файла; - список колонок совпадает во всех трёх местах — `writeOwnedByPipeline` вместе с
- `NULL` в колонке разбирается в указатель, а не роняет `Scan`; `writeRecord`, `readRecordColumns` и `rowToAudioRecord` — и в шаге схемы
- захват задачи не выдаёт одну строку двум вызывающим; (инвариант [CLAUDE.md](../CLAUDE.md), «Инварианты»). Колонки называются
- ошибка драйвера транслируется в доменную у источника. **именами**: именованный параметр запроса и место назначения по имени, а не
позиция в списке;
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
держатель захвата, и держатель узнаётся значением признака;
- репозиторий кладёт время тем же видом, каким его кладут остальные, и берёт его
из единой точки ([database.md](database.md), «Представление данных»);
- отказ хранилища не выходит наружу дословно: он несёт ключ файла и путь к нему
целиком.
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`, **Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
`adapter/metaviewer/ffmpeg`): `adapter/metaviewer/ffmpeg`):
@@ -58,25 +122,26 @@
- изменённое место покрыто хоть одним **проходящим** тестом. Тест, который - изменённое место покрыто хоть одним **проходящим** тестом. Тест, который
никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём никогда не был зелёным, обнуляет сигнал всего пакета: настоящий отказ в нём
становится неотличим от привычного шума (журнал, запись 2026-08-10); становится неотличим от привычного шума (журнал, запись 2026-08-10).
- проверка **способна упасть**. Утверждение, разбирающее ответ в ту же
структуру, чьи теги и составляют проверяемый контракт, меняется вместе с ним Свойств о годности самих проверок здесь больше нет — ни мутации теста, ни
и никогда не ловит поломку; такое судят по сырому виду ответа. Признак ищется мутации оракула критерия приёмки, ни требования сценария к норме. Запрет и его
мутацией: сломай проверяемое свойство и убедись, что тест краснеет (журнал, границы — [CLAUDE.md](../CLAUDE.md), «Запреты».
запись 2026-08-11);
- **то же и об оракуле критерия приёмки, не только о тесте.** Критерий, чей
единственный оракул — молчание линтера, годится ровно тогда, когда линтер
краснеет на **всех** негодных реализациях; проверяется той же мутацией.
Прецедент: «отказ `Close` не теряется молча» принимался молчанием `errcheck`,
а тот пропускал `_ = conn.Close()` — реализацию, теряющую отказ целиком
(журнал, запись 2026-08-11 про недостижимую норму; закрыто
[решением](adr/ADR-2026-08-11-errcheck-check-blank.md));
- **требование без сценария не имеет оракула** и потому не может быть нарушено
заметно. Норма, которую нечем уронить, расходится с кодом молча — и расходится
тем вернее, чем убедительнее написана (журнал, запись 2026-08-11).
### Типовые ложноположительные ### Типовые ложноположительные
- **«Хранилище молча сливает две учётные записи с одной почтой в одного
владельца».** Для версии v0.39.10 неверно, и неверна именно развязка. Первая
половина цепочки настоящая: обмен ищет запись по признаку провайдера, а не
найдя — по адресу почты, и приходит к чужой записи. Но повесить на неё второй
признак он не может — уникальный индекс
`idx_externalAuths_record_provider (collectionRef, recordRef, provider)` связь
отвергает, обмен отдаёт `400`, а сервис — `401` со строкой
`Failed to exchange provider code`. Отказ **громкий**, тихого слияния владельцев
не происходит, и ложно-зелёной проверки разграничения такой дефект не даёт.
Проверено прогоном 2026-08-15 (задача про заглушку OIDC); найдено чтением
исходников библиотеки, опровергнуто запуском — то есть цена гипотезы, добытой
без прогона, здесь и измерена.
- **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач - **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач
в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не
логирует его и не считает в метрику. Норма записана требованием логирует его и не считает в метрику. Норма записана требованием
@@ -87,28 +152,63 @@
приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей приведение типа на этом месте — настоящий дефект, закрытый 2026-08-11 задачей
`errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать `errors-as-instead-of-typecast`. Появилось снова — это регрессия, и выбрасывать
её как известную нельзя. её как известную нельзя.
- **«Захват задачи не в транзакции — гонка двух воркеров».** По построению её - **«Захват записи не в транзакции — гонка двух воркеров».** ~~По построению её
нет: три воркера читают три разных состояния, и одну строку они не делят. нет: три воркера читают три разных состояния, и одну строку они не делят.~~
Механика захвата и её слабые места — [database.md](database.md), **Отменено 2026-08-14 задачей `record-centric-model`:** построение снято. Пул
«Представление данных». Находка становится настоящей ровно тогда, когда одинаковых воркеров конкурирует за один и тот же набор записей, и второй
появится второй экземпляр процесса или второй воркер на то же состояние. воркер на тот же рубеж теперь есть всегда, когда их больше одного. Находка о
гонке захвата стала настоящей и выбрасывается только по существу — механика
захвата и её слабые места в [database.md](database.md), «Представление
данных». Строка оставлена отменённой, а не удалена: прогон, помнящий прежнюю
редакцию, иначе выбросил бы настоящую находку как известную.
- **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в - **«Файлы и объекты не удаляются, диск растёт».** Факт верный и записан в
[database.md](database.md); срок хранения не задан сознательно, задачи на него нет. [database.md](database.md); срок хранения не задан сознательно, задачи на него нет.
Новой находкой это не считается, пока не измерен рост. Новой находкой это не считается, пока не измерен рост.
- **«HTTP API открыт без аутентификации».** Известно и записано первой строкой - **«Запись без владельца не достаётся никому».** Строка отменена **дважды**, и
[security.md](security.md). Находкой считается только новая поверхность, обе отмены оставлены намеренно: прогон, помнящий любую из прежних редакций,
выставленная наружу, а не повторение этого факта. иначе выбросил бы настоящую находку как известную.
До задачи `record-ownership` здесь стояло «вошедший видит чужие записи — не
дефект и не новость»: разграничения не было сознательно. Первая отмена
2026-08-14 завела разграничение и объявила не дефектом уже другое — запись без
владельца, принятую ботом.
Вторая отмена того же дня, задачей `remove-telegram-intake`, сняла и это:
колонка владельца пустого значения больше не принимает, ничьих записей у
сервиса не бывает вовсе. **Запись без владельца сегодня — настоящая находка**,
а не известное исключение.
### Вопросы по темам ### Вопросы по темам
Форма: `<тема>: <вопрос> (<провенанс>)`. Форма: `<тема>: <вопрос> (<откуда>)`.
- `operations`: пережил ли шаг конвейера отмену контекста на середине — воркеры - `operations`: не завёл ли инструмент разработчика второй дом тому, что уже есть
получают `ctx`, но ни один шаг его внутрь не передаёт (чтение `worker.go` и в проверках. Прецедент: подставных провайдера OIDC в репозитории было два —
`transcribe.go`, 2026-08-10). `cmd/oidcstub` и `fakeProvider` в проверках входа, — с теми же адресами и той
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у же посылкой, и они уже разошлись в мелочи (`token_type` «bearer» против
одного из них таймаута нет (чтение `tg.go`, `s3.go`, `speechkit.go`, «Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте встал
2026-08-10). `cmd/devtools proxy`, а 2026-08-23 задачей `config-test-headers-login` убран и
он: заголовки входа подставляет сам сервис под предохранителем
`[server] debug`. Проверки ставят заголовок сами и подставного собеседника не
держат вовсе. Тем же вопросом судится подставной распознаватель. **Пробел
закрыт той же задачей:** норма о подставных собеседниках записана в
[architecture.md](architecture.md), «Принципы» — пункт «Подставной собеседник
в боевом бинарнике объявлен своим ключом»; здесь она не пересказывается.
Вопрос при этом остаётся вопросом:
норма называет, где собеседнику жить, а не сколько домов у него уже завелось
(ревью задачи про заглушку OIDC, 2026-08-15).
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
внешнего собеседника и это держат правила `noctx` и `contextcheck`
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
собеседник»), а исход прерванного шага нормой по-прежнему не описан
(`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
`transcribe.go`, 2026-08-13; прежняя запись от 2026-08-10 устарела вместе с
дефектом «остановка хоронила запись»).
- `operations`: появился ли таймаут у обращения к S3 и SpeechKit — ни у одного
из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `s3.go`,
`speechkit.go`, 2026-08-13).
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и - `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
возвращает её воркеру, который логирует снова (чтение `transcribe.go`, возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
2026-08-10). 2026-08-10).
@@ -121,54 +221,100 @@
- `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница - `security`: не уходит ли значение, пришедшее снаружи, меткой метрики — страница
метрик отдаётся без проверки отправителя, и метка это поверхность пошире метрик отдаётся без проверки отправителя, и метка это поверхность пошире
журнала (журнал, запись 2026-08-11 про хвост имени). журнала (журнал, запись 2026-08-11 про хвост имени).
- `architecture`: не появился ли второй путь приёма мимо - `architecture`: не появился ли второй путь приёма мимо `createRecord` — сегодня
`createTranscribeJob` — сегодня через него идут оба входа он единственный, которым запись попадает в хранилище
([architecture.md](architecture.md), «Единые точки проекта»). ([architecture.md](architecture.md), «Единые точки проекта»).
- `architecture`: не поехало ли поведение в `architecture.md` вместо спеки — - `architecture`: не поехало ли поведение в `architecture.md` вместо спеки —
заведены две capability (`openspec/specs/intake` и `openspec/specs/pipeline`), заведённые capability описывают поведение не целиком, и остаток живёт в обзоре
и каждая описана частично. Поведение прочих узлов живёт в обзоре под маркерами под маркерами долга, а соблазн дописать туда ещё — самый большой.
долга, а соблазн дописать туда ещё — самый большой. - `conventions`: новая колонка правится в обоих местах репозитория, а новый
- `conventions`: новая колонка правится во всех четырёх местах репозитория рубеж — одним дескриптором
(CLAUDE.md, «Инварианты»). (CLAUDE.md, «Инварианты»).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним тестом — сегодня - `autotests`: судит ли проверка формы ответа по **настоящему запросу**, а не по
тестов два файла, и оба мимо конвейера. прямому вызову отображателя ошибки. Вызов напрямую формой ответа не является и
остаётся зелёным, когда отказ рождается слоем ниже обработчика (запись журнала
2026-08-15 про единую форму отказа).
- `operations`: есть ли у новой выборки свой индекс. Единственный индекс записи
заведён под захват воркера — по рубежу и признаку остановки, — и выборке,
сужаемой владельцем, он не помогает ничем: замер 2026-08-15 показал полное
сканирование таблицы и рост времени страницы вместе с **чужими** записями.
- `security`: не схлопнулись ли внутрипроцессные запросы в один счётчик
ограничителя частоты. Запрос, собранный руками, приходит без адреса, а
вырожденное значение библиотека отдаёт не пустой строкой, и её собственный
страж «пустой ключ пропускаем» такое значение не ловит (запись 2026-08-15).
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано»;
числа файлов здесь не называем — оно протухает с каждой задачей.
- `autotests`: судит ли проверка ответа по готовому ответу, а не по изменяемому
состоянию обработчика — **только там, где ответ идёт мимо recorder**, через
свой `http.ResponseWriter`. Обращение к живой карте recorder'а с
2026-08-12 роняет гейт правилом линтера
([conventions/go-linters.md](conventions/go-linters.md), «Механизировано»), и
спрашивать о нём не нужно.
- `security`: не открылась ли снова поверхность, которую приносит хранилище, —
собственная регистрация, вход по паролю, одноразовый код, восстановление
доступа, продление сессии. Всё это приходит включённым и закрывается нами
(задача `oidc-login` 2026-08-12).
- `security`: не появился ли второй способ получить сессию к тому же человеку —
заголовок вместо куки назван осознанно, прочие способы обязаны быть закрыты.
- `operations`: доходит ли отзыв доступа у провайдера до сервиса и за какой срок —
после входа сервис к провайдеру не обращается, и канал здесь один
(ADR-2026-08-12-session-without-refresh).
- `architecture`: не зовётся ли на каждый запрос то, что меняет состояние
приложения, — сборка роутера хранилища оказалась именно такой.
### Триггеры метки ### Когда звать глубокое ревью
Проектная конкретизация правила выбора метки. Умолчание — `medium`. Проектная конкретизация признаков, по которым зовут `av-dev:code-deep-review`.
Что за признаками следует и каким составом идёт прогон, решает сам скилл ревью;
здесь только места этого проекта.
**Крупное здесь** (поднимает до `large`, ось объёма): **Смотрим целиком** (область кода, а не дифф задачи):
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и - **вход и разграничение доступа** — звенья `internal/controller/http`
колонку разом; (узнавание, требование учётной записи, ограничитель частоты, подстановка
- замена хранилища или переход на PocketBase — любой её кусок; заголовков отладочного запуска) вместе со спекой
- смена модели очереди: захват, повторы и воркеры разом; [access](../openspec/specs/access/spec.md). Возвращаются сюда чаще, чем куда бы
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом; то ни было ещё, и журнал дефектов ниже это показывает: закрытая поверхность, в
- изменение, трогающее оба входа сразу — Telegram и HTTP. которую не мог войти никто; проверка, читавшая живую карту заголовков вместо
ответа; анонимный запрос, навсегда замедлявший запись; путь анонима, уехавший в
журнал; ключ бюджета ограничителя, который выбирал тот, кого ограничивают. С
2026-08-22 барьер держит заголовок от прокси, изъятие у него одно — отладочный
запуск ([security.md](security.md), «Периметр»), — а настоящей Authelia в
прогоне нет (см. «Недоступно проверке»);
- **пакет хранилища** `internal/adapter/repo/sqlite` — здесь живёт инвариант
«Колонки записи правятся в трёх местах» ([CLAUDE.md](../CLAUDE.md),
«Инварианты», major): места, цена забытого и то, чем держится сверка, названы
там. Смотрится целиком потому, что компилятор не видит ни одного из мест;
- **конвейер расшифровки** `internal/service` вместе с дескриптором рубежа
`internal/entity/stage.go` — здесь живёт инвариант «Рубеж объявляется одним
дескриптором» ([CLAUDE.md](../CLAUDE.md), «Инварианты», major), и цена
забытого рубежа названа там. Сюда же дефекты о потере уже полученного: пустой
второй ответ распознавателя, стиравший сохранённую расшифровку, и остановка
сервиса, хоронившая конвертируемую запись;
- **распознаватель** `internal/adapter/recognizer/yandex` — единственное место,
чья ошибка стоит денег ([CLAUDE.md](../CLAUDE.md), «Запреты», «Yandex Cloud за
деньги»). Живым прогоном оно не проверяется вовсе (см. «Недоступно
проверке»), и разбор остаётся единственным способом судить о нём.
**Незнакомое здесь** (поднимает до `large`, ось формы решения): **Необратимое здесь.** Перечень необратимого один и лежит в
[CLAUDE.md](../CLAUDE.md), «Работа»; здесь — только места кода, которых его
пункты касаются, и правило прохода: находка в таком месте уходит человеку
развилкой, а не чинится молча.
- вход через OIDC и разграничение доступа: как связаны пользователь Telegram и - применённый шаг схемы — `internal/adapter/repo/sqlite/migrations`;
пользователь приложения, до начала работы назвать нельзя; - формат файла на диске и раскладка каталога данных —
- всё, что делается на выбранном фреймворке впервые: форма решения нащупывается `internal/adapter/repo/sqlite`, `store.go`;
по ходу, пока конвенция веб-UI пуста; - публичный контракт HTTP API — `internal/controller/http`;
- установка на телефон: service worker перехватывает запросы, и что он кэширует, - имя ключа конфига — `internal/config`;
до работы назвать нельзя; - действие с боевыми данными и с Yandex Cloud, ротация секрета —
- работа с записями в несколько часов: потолки внешних сервисов не замерены, `internal/adapter/recognizer/yandex`.
форма решения зависит от замера;
- приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой;
- всё, что требует записи в `research/` прежде, чем начать.
**Мелкое здесь** (опускает до `small`): Строка, уже ушедшая в журнал контейнера или меткой метрики, из перечня не
берётся: её необратимость записана инвариантами о секрете и о содержимом записи
- правка текста, который видит пользователь Telegram; ([CLAUDE.md](../CLAUDE.md), «Инварианты», оба critical), и правка кода помогает
- новая метрика в `internal/metrics`; там только следующей записи.
- правка `config.dist.toml` и умолчаний `defaultConfig()` без нового поля;
- правка документов канона.
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
API и имя не откатываются обратной правкой после мерджа — какими бы маленькими
ни были, они не `small`.
### Недоступно проверке ### Недоступно проверке
@@ -179,7 +325,16 @@ API и имя не откатываются обратной правкой по
- `operations`: реальный профиль нагрузки. Проект работает на единицах записей в - `operations`: реальный профиль нагрузки. Проект работает на единицах записей в
день, и утверждения о росте остаются условиями, а не замерами; день, и утверждения о росте остаются условиями, а не замерами;
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата - `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
отдан внешней программе, и она вне нашей границы. отдан внешней программе, и она вне нашей границы;
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
сервиса. Ни того ни другого в прогоне нет, а с 2026-08-22 от прокси зависит
**весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать
пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server`
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
- `security`: поведение браузера с куками. Своих кук сервис не ставит с
2026-08-22, а вместе со встроенным хранилищем ушли и те, что ставила его
панель. Класс опустел, и строка стоит здесь затем, чтобы возврат кук читался
как возврат недоступного проверке, а не как обычная работа.
**Перестали проверять сознательно:** **Перестали проверять сознательно:**
@@ -187,15 +342,445 @@ API и имя не откатываются обратной правкой по
2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают 2026-08-11 — правда, звали так, что он всегда отказывал, — а теперь получают
длительность от подставного источника. Своего теста у длительность от подставного источника. Своего теста у
`adapter/metaviewer/ffmpeg` нет; решение и его цена — в `adapter/metaviewer/ffmpeg` нет; решение и его цена — в
[adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md). [adr/ADR-2026-08-11-stub-adapters-in-tests.md](adr/ADR-2026-08-11-stub-adapters-in-tests.md);
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
можно: он встаёт своим единственным входом на выдуманных непустых ключах
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
осмотр HTTP, журнала, метрик и остановки — доступен любой задаче; панели среди
предметов осмотра нет с 2026-08-22.
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
дня), а 2026-08-14 признак включения ушёл вместе с самим входом.
**Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
остановку; нельзя — расшифровку и заливку.
**Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22.** Прежде
сессию в прогоне выдать было нечем; теперь заголовок ставит сам сервис по
секции `[auth.test_headers]` под предохранителем `[server] debug` — прежде
`cmd/devtools proxy`, убранный 2026-08-23, — и живьём проверяются узнавание,
заведение учётной записи первым обращением, отказ с недоверенного адреса и
отказ старта на пустом перечне.
Настоящая Authelia по-прежнему недоступна — её правило на домен живёт в
контуре (см. «Не проверит ни один проход»).
## Журнал дефектов ## Журнал дефектов
Верхняя запись найдена конвейером ревью на первом же его прогоне, вторая — Записи новые сверху. `[пойман ревью]` — дефект нашёл прогон конвейера,
прогоном гейта при заведении канона 2026-08-10, две нижние восстановлены по `[пойман сканером]` — тест-сканер `internal/archrules`, `[проскочил]` — дефект
истории git тогда же. Три нижние помечены `проскочил`: ревью тогда не было, и уехал в код, и поймать его тогда было некому. Две нижние записи восстановлены по
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не
выдумывать его задним числом нельзя. оракул, и выдумывать оракул задним числом нельзя.
## 2026-08-23 — путь, выбранный анонимом, уезжал в журнал под корнем приложения [пойман ревью]
- **Где:** `internal/controller/http/journal.go`, `JournalRoute`; задача
`storage-without-pocketbase`
- **Симптом:** неузнанный писал в журнал владельца свой текст произвольной длины.
Путь под корнем приложения уходил в строку дословно — в том числе при ответе
`401`, потому что слой журнала стоит снаружи ограничителя частоты
- **Причина:** правило «путь спрашивающего в журнал не идёт» было записано только
для запроса, отданного приложению. Путь вида `/app/<текст>` принадлежит
сервису, под то правило не подпадал и уезжал целиком, хотя множеством значений
под корнем распоряжается тот же аноним
- **Чем воспроизведён:** прогон враждебного прохода — путь в 1 044 480 знаков дал
прирост журнала в 1 044 632 байта; одно соединение за 1,003 с дало 122 запроса
и 121,5 МиБ журнала; 120 отказов ограничителя оставили 240 строк
- **Почему не поймали раньше:** правило записали по месту, где его впервые
понадобилось применить, а не по признаку «значением распоряжается спрашивающий».
Зазор был ровно шириной в корень приложения
- **Что меняем:** `JournalRoute` обобщает всё, что накрыто корнем приложения, а
длину отдаёт полем `http.path_length`; дословно пишутся только адреса из
закрытого перечня. Правило в [conventions/logging.md](conventions/logging.md)
переписано на все корни разом — оно стоит теперь у строки о всяком входящем
запросе, а не у строки о раздаче приложения
## 2026-08-23 — ключ бюджета ограничителя выбирал тот, кого ограничивают [пойман ревью]
- **Где:** `internal/controller/http/rate_limit.go`, `clientAddress`; задача
`storage-without-pocketbase`
- **Симптом:** ограничитель пропустил 1200 запросов одного спрашивающего при
бюджете 120 за окно. Заодно карта счётчиков росла линейно от числа выдуманных
адресов
- **Причина:** адрес брался из **левого** значения `X-Forwarded-For`, а прокси
заголовок дописывает, а не заменяет. Левым значением распоряжается сам
спрашивающий, значит он же выбирает и ключ карты — и меняет его на каждом
запросе
- **Чем воспроизведён:** прогон враждебного прохода — 1200 пропущенных запросов
при бюджете 120; 200 000 ключей в карте дали прирост кучи в 19 810 376 байт
- **Почему не поймали раньше:** слой писался заново вместе с транспортом, а
свойство «ключ бюджета не выбирает тот, кого ограничивают» не стояло ни в
конвенции, ни в типовом узле — его держала прежде чужая библиотека
- **Что меняем:** цепочка читается справа налево, доверенные адреса
отбрасываются, ключом становится первый недоверенный, а заголовок читается
всеми строками, а не одной. Требование к контуру этим снято: дописывающий
прокси правилом покрыт — [security.md](security.md), «Периметр»
## 2026-08-23 — инвариант о колонках записи потерял предмет [пойман ревью]
- **Где:** [CLAUDE.md](../CLAUDE.md), «Инварианты»;
`internal/adapter/repo/sqlite/record_mapping.go`, `internal/archrules`
- **Симптом:** инвариант называл поимённо `applyOwnedByPipeline`, `applyToRecord`
и `recordToAudioRecord` — функций с такими именами в коде уже не было. Сослаться
на инвариант как на оракул стало нельзя
- **Причина:** сторож и отображение переписаны под новую форму хранилища, а текст
инварианта остался от прежней. Мест при этом стало три: что спрошено
(`readRecordColumns`), куда лягут (`recordRow`) и что доедет до сущности
(`rowToAudioRecord`), — а сверялось правилом одно
- **Чем воспроизведён:** `grep` по трём прежним именам — пусто; `grep` по
`rowToAudioRecord` в `internal/archrules` — пусто
- **Почему не поймали раньше:** инвариант проверяется правилом, а имена в его
тексте — ничем. Текст и сторож разошлись молча
- **Что меняем:** инвариант назван действующими именами и действительным числом
мест; правило `internal/archrules` расширено на `rowToAudioRecord` — перечень
колонок чтения сверяется с перечнем присвоений в сущность
## 2026-08-15 — короткая форма рецепта входа не работала, а проверяли длинную [пойман ревью]
- **Где:** `cmd/oidcstub` — подставной провайдер OIDC для локального входа;
доккоммент пакета, подсказка флага `-sub` и проза `config.example.toml`
- **Симптом:** рецепт «второй вошедший получается сменой `-sub`» записан в трёх
местах и в короткой форме не работал вовсе. Заглушка отдавала обоим `sub` одну
и ту же почту умолчанием, вход отвечал `401`, а причина оставалась строкой в
журнале хранилища
- **Причина:** обмен ищет учётную запись сперва по признаку провайдера, а не
найдя — по адресу почты. Второй `sub` при общей почте приходил к первой записи,
а признак провайдера на записи уникален — `idx_externalAuths_record_provider`
и связь отвергалась. Умолчание почты стояло своим значением вместо выведенного
из `sub`
- **Почему не поймали раньше:** рецепт проверяли **длинной** формой, где почта
задана флагом явно. Короткую не гонял никто, хотя записана она первой и берут
читатели именно её
- **Что меняем:** проверять ту форму рецепта, которая записана **короче всех**.
Оракул — прогон именно её: два входа подряд разными `-sub` без прочих флагов,
затем счёт записей в коллекции пользователей. Само умолчание почты теперь
выводится из `-sub`
## 2026-08-15 — своя раздача статики потеряла отказ от записи успеха [пойман ревью]
- **Где:** `internal/controller/http/webapp.go`, регистрация корневого маршрута;
задача `spa-skeleton`
- **Симптом:** каждый успешный ответ разметкой и ресурсом клал в журнал
хранилища выбранный анонимом путь вместе с его адресом и держал строку пять
суток. При этом строка `docs/review.md`, добавленная той же задачей,
утверждала, что путь анонима в журнал не идёт
- **Причина:** готовая раздача статики библиотеки первой же строкой ставит
признак «успех не записывать». Своя написана мимо неё — и не зря, подстановка
разметки у готовой не отличает отсутствующий ресурс от неизвестного пути, — но
признак при переписывании не перенесён.
Журналов у сервиса два, а сделанная защита закрыла один
- **Почему не поймали раньше:** свойство было записано **утверждением**, а
проверялось только против журнала контейнера. Второй журнал живёт в базе, и ни
один тест туда не смотрел
- **Что меняем:** утверждение о журнале называет оба журнала поимённо. Оракул —
чтение таблицы журнала после прогона: три успешных запроса не оставляют строк,
два отказа оставляют
## 2026-08-15 — единая форма отказа не покрывала то, что рождается не в обработчике [пойман ревью]
- **Где:** `internal/controller/http/errors.go`, слой `OneErrorForm`; задача
`json-api-for-spa`
- **Симптом:** три отказа под корнем приложения — превышение
потолка тела, ограничитель частоты и неизвестный путь — уходили телом
библиотеки, без машиночитаемого кода и без предела числом. То есть форм отказа
на адресах приложения было две, а не одна, — ровно то, ради чего задача и
заводилась
- **Причина:** отображение доменной ошибки заведено верно, но покрывает лишь то,
что вернул **обработчик**. Предел тела и ограничитель частоты рождают отказ
слоями ниже, а «ничего не совпало» — вовсе маршрутом корневой группы, к
которому слои нашей группы не привязаны. Комментарий у слоя при этом перечислял
все три случая как закрытые
- **Почему не поймали раньше:** оракулом служил комментарий, а не прогон.
Приёмочный тест звал отображатель **напрямую** ошибкой, которую сам же и
сочинил, — запроса он не слал и потому оставался зелёным независимо от того,
что происходит при настоящем HTTP-запросе. Ветвь `too_large` при этом не имела ни одного
производителя в рабочем коде
- **Что меняем:** проверка, стерегущая форму ответа, обязана слать **настоящий
запрос**; вызов отображателя напрямую формой ответа не является. Добавлено
вопросом в раздел ниже
## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью]
- **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put`
и `StructureRepository.Put`; путь до них — `poll``storeOutcome` в
`internal/service/transcribe.go`. Кода задачи `remove-telegram-intake` дефект не
касался: она этот путь не трогала
- **Симптом:** поток от SpeechKit, закрывшийся на первом же ответе, отказом не
считается — наружу уходит пустой результат без отказа. Замена содержимого шла
безусловно, и повторный опрос той же операции клал пустое поверх сохранённой
расшифровки. Шаг при этом объявлял запись готовой: рубеж двигался, опрос
готовности отдавал `done` без текста
- **Причина:** соседний хранитель того же результата — сырой ответ провайдера —
от пустого значения защищён условием `len(raw) > 0` с самого заведения, а текст
и структура реплик такого условия не имели. Разное правило у двух хранителей
одного результата
- **Чем воспроизведён:** падающий тест враждебного прохода, переснятый триажем, —
`expected: "Личный разговор." actual: ""`. Оракул закреплён в дереве:
`internal/service/recognition_test.go`, `TestEmptySecondAnswerKeepsArchivedText`;
он же проверяет, что до второго ответа дело действительно дошло
- **Почему не поймали раньше:** повторный опрос одной операции — не редкость, но
и не штатный путь: он наступает, когда держатель захвата умер, сохранение рубежа
отказало либо человек снял остановку в панели. Ни один прогон до этого не строил
такого входа, а от чтения кода защита у соседа выглядела общей
- **Что меняем:** правило «пустое не кладётся поверх сохранённого» записано
нормой в спеку `storage` и держится **хранилищем**, а не шагом: шагов, кладущих
текст, больше одного, и правило у одного из них у остальных читалось бы как
снятое. Дефект существовал до той правки, чинился решением владельца от 2026-08-14 в
задаче, которая его нашла
## 2026-08-15 — пустая расшифровка перестала быть заметной вместе с убранным входом [пойман ревью]
- **Где:** `internal/service/transcribe.go`, шаг завершения; документы
`docs/conventions/logging.md` и `docs/architecture.md`
- **Симптом:** запись с пустым распознаванием доходила до конечного рубежа и от
успешной не отличалась ничем — ни строкой журнала, ни ответом опроса
- **Причина:** единственным следом этого случая был текст, уходивший отправителю
в чат («на записи нет текста»). Задача убрала доставку целиком, и след исчез
вместе с ней — при том, что конвенция журнала называет пустой текст
распознавания поимённым примером уровня «может стать проблемой», а обзор
архитектуры обещал заглушку
- **Чем воспроизведён:** `internal/service/recognition_test.go`,
`TestEmptyRecognitionIsNamedInJournal` — подставной распознаватель отдаёт
готовую операцию с пустым результатом, проверка судит уровень строки и
идентификатор записи
- **Почему не поймали раньше:** удаление сняло **последнего потребителя** видимого
признака, а не сам признак; такое не видно ни компилятору, ни грепу по
удаляемому имени. Нашёл проход конвенций, сверив таблицу уровней журнала с тем,
что осталось в коде
- **Что меняем:** шаг опроса пишет строку уровня «может стать проблемой» с
идентификатором записи; строка обзора архитектуры переписана на фактическое
поведение. Класс общий: **удаляя канал, проверь, не был ли он единственным
потребителем сигнала** — сигнал переживает канал только там, где его переносят
руками
## 2026-08-13 — сторож инварианта про секрет искал подстроку, которой не бывает [пойман ревью]
- **Где:** `internal/config/config_test.go`, проверка «значение ключа доступа не
попадает в отказ» задачи `telegram-enabled-flag`. Дефект в самой проверке, кода
сервиса он не касался
- **Симптом:** проверка была зелёной и утверждала, что отказ `TelegramConfig.Validate()`
не несёт значения ключа доступа. Приёмочный критерий задачи считался закрытым ею
- **Причина:** двойная, и каждая половина достаточна. Утверждение искало
подстроку `enabled = true при`, а в сообщении стоит `при enabled = true`
порядок слов обратный, и такой подстроки не бывает ни при каком входе. Глубже:
`Validate()` отказывает **только** на пустом ключе, то есть значения, которым
можно проговориться, на этом пути не существует вовсе. Комментарий при этом
утверждал «Ключ непуст», а в теле стояло `BotToken: ""` — описан был не тот
вход, который задан
- **Чем воспроизведён:** триаж скопировал дерево во временный каталог и заменил
тело `Validate()` на утекающее — `fmt.Errorf("... bot_token=%q ...", c.BotToken)`.
Проверка осталась зелёной
- **Почему не поймали раньше:** проверка написана в той же задаче и той же рукой,
что и код; гейт зелёный, а зелёная проверка неотличима от работающей. Поймали
два прохода независимо — разбор кода и сверка требований
- **Что меняем:** проверка переписана честно и переименована: половина требования
«сообщение не несёт значения» на этом пути **вакуумна**, и это названо прямо, а
настоящий сторож той же нормы указан по имени — он живёт там, где непустой ключ
в отказ попасть действительно может, в проверках отказа разбора файла настроек.
Класс всплывает **третий раз** (2026-08-11 «проверка приёма не могла упасть»,
2026-08-12 «проверка не могла упасть: читала живую карту заголовков»), и в этот
раз он другой природы: прежние два ловились правилом линтера про источник
утверждения, а этот — про **вход**: у сторожа утечки вход обязан содержать
значение, которое может утечь, иначе сторож пуст независимо от формы
утверждения. Механизации у этого нет и, похоже, быть не может: «может ли здесь
вообще утечь» — суждение, а не форма. Остаётся проходу ревью
## 2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью]
- **Где:** `internal/service/transcribe.go`, шаг конвертации — дефект завела та
же правка, что проложила контекст до `ffmpeg`
- **Симптом:** на прод не уехал, поймали до коммита. Выглядел бы так: обычная
выкладка посреди конвертации переводит здоровую запись в терминальное
`failed`, отправителю уходит «сбой конвертации файла», а вернуть задачу может
только владелец правкой в панели. Окно — часы: конвертация шестичасовой записи
идёт дольше часа по построению
- **Причина:** контекст дошёл до внешнего процесса, а различать его отмену шаг
не научили. Убитый по контексту `ffmpeg` отдаёт `signal: killed` — от
настоящего отказа (`exit status N`) эта ошибка неотличима ни типом, ни
`errors.Is`: различает только `ctx.Err()`. Шаг звал `failJob` на любой отказ
`Convert`. Хуже: `failJob` возвращает `nil`, поэтому воркер считал прогон
успешным, и метрика владельца — та, которой он замечает отказы, — не
шевелилась
- **Чем воспроизведён:** проверкой `TestShutdownDuringConversionKeepsJobRetryable`
с подставным конвертером, ведущим себя как убитый процесс: отдаёт отказ, не
несущий `context.Canceled`. Мутация снята — без развилки проверка краснеет
- **Почему не поймали раньше:** правка выглядела механической, «линтер потребовал
контекст». Цена оказалась в семантике очереди, а не в сигнатурах: отмена стала
значить разное на соседних шагах одного конвейера. Ни один линтер такого не
видит — это заметили три прохода ревью независимо, и все три построили путь
- **Что меняем:** прерванный шаг приговора не выносит — задача остаётся на
повтор, попытку не тратит (счётчик, выросший при захвате, возвращают назад) и
отправителю о несуществующем сбое не сообщает. Воркер не считает остановку
отказом и не пишет о ней владельцу. Задача не забирается вовсе, если нас уже
остановили. Остаток объявлен: норма отмены в спеке `pipeline` не описана, и
открытая задача `context-cancel-in-pipeline` этим закрыта не целиком
## 2026-08-13 — отказ скачивания уносил токен бота в журнал [проскочил]
- **Где:** `internal/controller/tg/tg.go`, скачивание записи по ссылке
`file.Link(c.bot.Token)`
- **Симптом:** не наблюдался, потому что журнал за этим местом никто не читал
построчно. Первый же сбой сети на скачивании писал в журнал
`Failed to download audio file` вместе с полным адресом запроса, а в адресе
Telegram держит токен бота (`…/bot<TOKEN>/…`). Инвариант «секрет не покидает
конфиг» помечен critical и необратим: утёкший токен отзывают руками
- **Причина:** `http.Get` возвращает `*url.Error`, и тот встраивает адрес
целиком. Отказ уходил в `fmt.Errorf("failed to download file: %w", err)`, а
оттуда — в `logger.Error` соседней строкой
- **Чем воспроизведён:** чтением цепочки от `http.Get` до вызова `logger.Error`
в трёх обработчиках; на живом боте не проверялся — боевым токеном запускаться
запрещено
- **Почему не поймали раньше:** правило было записано прозой и ровно про этот
случай — [conventions/logging.md](conventions/logging.md), «Ошибка
HTTP-транспорта несёт URL». Хуже: там же стояло объявленное *Расхождение* с
оценкой «сегодня она не логируется — то есть утечки нет», и оценка была
неверной. Строка лога существовала всё это время, но проза о ней не знала, а
машина прозу не проверяет
- **Что меняем:** чистку перенесли с места употребления на **границу клиента**
`internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в
первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного
опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена
больше не получает: клиента ему отдают готовым. Расхождение в конвенции
закрыто, оценка в [security.md](security.md) исправлена
- **Чем закрыт от возврата:** проверками `internal/adapter/telegram/bot_test.go`
— четыре пути (`getFile`, `sendMessage`, конструктор, логгер библиотеки)
судятся по тексту отказа и строке журнала. Мутация снята: со снятой чисткой
три из них краснеют, печатая токен. Правило остаётся прозой (линтер не отличит
ссылку с секретом от ссылки без него), но у прозы теперь есть оракул
- **Как нашли:** первый путь — попутно, при разборе находок `noctx`: тот
потребовал переписать `http.Get` на запрос с контекстом, и цепочку пришлось
прочитать целиком. Остальные четыре — конвейером ревью в тот же день; правка,
закрывшая один путь, объявила класс закрытым в двух документах, и это едва не
осталось так
## 2026-08-13 — конец потока распознавания узнавался по тексту сообщения [пойман сканером]
- **Где:** `internal/adapter/recognizer/yandex/speechkit.go`, чтение потока
результата распознавания
- **Симптом:** сегодня не наблюдался — путь рабочий, пока библиотека отдаёт конец
потока значением `io.EOF`. Отказ с текстом «EOF» был бы принят за конец потока,
и расшифровка вернулась бы усечённой: пользователь получил бы половину записи
как готовый результат
- **Причина:** конец потока узнавался сравнением `err.Error() == "EOF"`. Текст
сообщения — не признак: его носит и чужая ошибка, а сменит его библиотека —
условие перестанет срабатывать вовсе, и оба исхода молчаливы
- **Чем воспроизведён:** не воспроизводился на живом сервисе — прогон на реальных
ключах запрещён. Найден тестом-сканером `internal/archrules` при его заведении
- **Почему не поймали раньше:** `errorlint` видит `err == ErrX` и приведение типа,
но матчинг по тексту не видит; прозой это правило записано не было, и ревью его
не спрашивало
- **Что меняем:** узнавание переведено на `errors.Is(err, io.EOF)`; класс закрыт
тестом-сканером (docs/conventions/go-linters.md, «Ошибки и отказы»)
## 2026-08-13 — правило гейта обходилось одной лишней строкой [пойман ревью]
- **Где:** `.golangci.yml`, правило `forbidigo` о суждении по живой карте
заголовков — заведено в тот же день задачей `response-assertions-judge-result`
- **Симптом:** правило ловило только прямую цепочку `w.Header().Get`. Присваивание
в переменную (`h := w.Header()`), чтение по индексу карты, обход `range` и поле
`HeaderMap` проходили гейт зелёными — то есть класс, стоивший трёх зелёных
гейтов, возвращался четвёртый раз, и уже без человеческой страховки: документы
успели снять его с прохода ревью
- **Причина:** `forbidigo` по умолчанию судит по печатному тексту вызова, а не по
типу значения. Правило, записанное текстом, отсекает одну форму записи, а не
свойство
- **Чем воспроизведён:** прогоном линтера на файле проверок с шестью формами
чтения живой карты: помечена была одна
- **Почему не поймали раньше:** правило проверили ровно тем нарушением, против
которого писали. Мутация была, но одна — нужна была по одной на каждую форму
- **Что меняем:** правило судит по типу приёмника (`analyze-types`,
`httptest.ResponseRecorder.Header` и `.HeaderMap`) и ловит все шесть форм;
проверено мутацией по каждой. Урок записи: запрет по имени, обходимый лишней
строкой, свойства не держит — такому свойству нужен тест-сканер. Строка об этом
стояла в `docs/conventions/go-linters.md`, разделе «Лестница механизации»;
раздел снят 2026-08-13, урок остался здесь
## 2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью]
- **Где:** шаг схемы `202608120001` задачи `oidc-login`, правило создания записи
в коллекции пользователей
- **Симптом:** `users.CreateRule = nil` закрывало создание записи для всех, кроме
владельца панели. Запись при первом входе заводит внутренний запрос самого
обмена, идущий без таких прав, — значит после выкладки вход не сработал бы ни
у кого, включая владельца, а приём и опрос уже были закрыты. Сервис остался бы
доступен только через Telegram, и чинилось бы это руками в панели
- **Причина:** закрывали ровно то, ради чего задача затевалась, — самостоятельную
регистрацию, которую хранилище приносит открытой. Глухое `nil` выглядит самым
надёжным её закрытием и отвергает заодно единственный законный путь заведения
записи. Различить их можно: обмен помечает свой запрос контекстом `oauth2`
- **Чем воспроизведён:** тестом против настоящего хранилища с подставным
провайдером: возврат от провайдера отвечал `401`, обращений к токен-эндпоинту
`1`, учётных записей после входа `0`. Причина изолирована тем же прогоном —
с открытым правилом возврат давал `302` и запись появлялась
- **Почему не поймали раньше:** все проверки задачи заводили учётную запись
прямым сохранением, мимо входа, и потому шли по коду, который в бою не
исполняется. Гейт был зелёным. Поймали два прохода независимо — разбор кода по
исходникам библиотеки и враждебный проход падающим тестом
- **Что меняем:** правило сузили до контекста обмена
(`@request.context = "oauth2"`), а в набор проверок добавили вход целиком через
подставного провайдера — от увода до куки сессии. Проверка, заводящая запись
мимо входа, больше не считается покрытием входа
## 2026-08-12 — проверка не могла упасть: читала живую карту заголовков вместо ответа [пойман ревью]
- **Где:** `internal/controller/http/auth_test.go`, проверка уборки носителя
состояния входа; сам дефект — в `auth.go`, уборка стояла в `defer`
- **Симптом:** носитель состояния и проверочного кода не убирался ни на успешном
возврате, ни на отказном, и жил свои десять минут. Одноразовость возврата
держалась ровно на этой уборке, то есть тоже не работала. Проверка при этом
была зелёной и утверждала обратное
- **Причина:** двойная. В коде — `defer` исполняется после того, как ответ уже
начали писать, а заголовки к этому моменту зафиксированы снимком, и позднейшая
правка их карты до браузера не доезжает. В проверке — `httptest` устроен
зеркально: `Header()` отдаёт живую карту, а снимок лежит отдельно и читается
через `Result()`. Проверка смотрела в живую карту и видела то, чего клиент не
получит
- **Чем воспроизведён:** отдельной программой вне проекта: на настоящем сервере
ответ приходил с пустым `Set-Cookie`, а тот же обработчик под `httptest`
показывал куку в `Header()` и не показывал в `Result()`
- **Почему не поймали раньше:** оракул был ложным по построению, и никакая
регрессия его не разбудила бы. Гейт зелёный. Поймали два прохода — сверка
требований и разбор кода, — оба воспроизведением, а не чтением
- **Что меняем:** уборка перенесена до записи ответа; все проверки этого файла
судят по `Result()`. Класс всплывает **третий раз** (2026-08-10 «тесты
http-обработчика ни разу не были зелёными», 2026-08-11 «проверка приёма не
могла упасть»), поэтому он же ушёл в конвенции правилом: проверка ответа
судит по готовому ответу, а не по изменяемому состоянию обработчика.
Механизировано 2026-08-12 задачей `response-assertions-judge-result`
`forbidigo` в `.golangci.yml` роняет гейт на чтении живой карты заголовков в
файле проверок. Правило судит по **типу приёмника**, а не по тексту вызова, и
потому ловит любую форму чтения живой карты — цепочкой, через переменную, по
индексу, обходом, полем `HeaderMap`. Текстовый запрет ловил только прямую
цепочку и обходился одной лишней строкой — это назвал прогон ревью этой же
задачи. Проходу ревью остаётся проверка, идущая мимо recorder, через свой
`http.ResponseWriter`
## 2026-08-12 — каждый анонимный запрос навсегда замедлял запись в хранилище [пойман ревью]
- **Где:** `internal/controller/http/auth.go`, обмен кода собирал роутер
хранилища на каждый вызов
- **Симптом:** сборка роутера вешает девять обработчиков на само приложение и
без идентификатора, поэтому повторная не заменяет прежние, а добавляет.
Обработчики исполняются на каждой записи в хранилище, а конвейер пишет задачу на
каждом шаге. Освобождения нет — только перезапуск. Раскачивалось анонимно:
атакующий ставит себе куку состояния сам, и сверка сравнивает две его же
величины, а обмен исполняется раньше обращения к провайдеру
- **Причина:** функция сборки выглядит чистой — она возвращает роутер, и по имени
не видно, что она правит приложение. Решение звать собственный адрес хранилища
внутри процесса сделало эту сборку частью горячего пути
- **Чем воспроизведён:** замером на настоящем приложении: пять вызовов подряд
подняли число обработчиков одного события с 4 до 14; 3000 анонимных возвратов
довели сотню сохранений записи с 3.86 мс до 59.8 мс и кучу на 5013 КиБ. При
недоступном провайдере утечка сохранялась
- **Почему не поймали раньше:** ни один шаг гейта не смотрит на побочные эффекты
вызова библиотеки, а замер требует прогона. Поймали три прохода — архитектурный
зондом, враждебный падающим тестом, сверка требований чтением
- **Что меняем:** роутер собирается один раз и живёт полем обработчика; в набор
проверок добавлена та, что считает длину очереди обработчиков после двадцати
входов
## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил] ## 2026-08-12 — образ не собирался, и этого не увидел никто [проскочил]
@@ -219,6 +804,14 @@ API и имя не откатываются обратной правкой по
директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем директивой `go` в `go.mod`. Строкой сравнения, без docker. Заведено урожаем
ревью. ревью.
**Закрыто** задачей `go-1-26-upgrade` 2026-08-12: шаг `go-version` в `task gate`
(`scripts/check-go-version.sh`). Сверяются четыре места, а не два, — `go.mod`,
`Dockerfile`, `CLAUDE.md`, `README.md`: в этом дефекте трое из четырёх врали
согласованно, и парная сверка не увидела бы документ, разошедшийся с
согласованным кодом. Нормативного дома у шага не осталось: спека `toolchain`
упразднена 2026-08-13, тогда же снесены и его двадцать сценариев — норма живёт
комментариями в самом скрипте.
## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью] ## 2026-08-11 — норма требовала от сервиса недостижимого [пойман ревью]
- **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац - **Где:** дельта-спека `pipeline` задачи `errors-as-instead-of-typecast`, абзац
+309 -98
View File
@@ -2,14 +2,40 @@
## Периметр ## Периметр
**Сервис открыт наружу: HTTP-порт опубликован в интернет через обратный прокси, и **Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
аутентификации не делает ни прокси, ни само приложение.** Находки строятся против обратный прокси, а приём записи, чтение её карточки и текста и файл записи
этого — сегодняшнего — периметра. требуют, чтобы пришедшего назвала Authelia.** С 2026-08-22, задачей
`trusted-header-login`, называет она его **заголовком, который ставит обратный
прокси**: своего входа у сервиса не осталось — ни адреса к провайдеру, ни
возврата, ни куки, ни выхода. Прежде сервис вёл вход сам (`oidc-login`
2026-08-12) и потом семь суток верил выданной куке; теперь Authelia судит
**каждый** запрос, и отзыв доступа действует со следующего.
Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, отдельный Без узнавания открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton`
вход для программ по личным токенам, два уровня доступа — пользователь видит **само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не
свои записи, владелец сервиса ещё и страницу расхода. Он **не** развёрнут; принадлежащий ни одному корню сервиса. Причина внешняя: заголовок ставит прокси,
описанное ниже разграничение доступа относится только к Telegram. и человек, которого прокси не назвал, до приложения дошёл бы только мимо него —
а закрытая разметка выглядела бы поломкой сервиса, а не отказом входа. Данных
открытость не касается — всякий адрес под корнем приложения узнанного
по-прежнему требует. Находки строятся против этого — сегодняшнего — периметра.
**Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**,
а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у
собирающего, уезжает в бинарник и раздаётся. Под каталогом ресурсов оно ещё и
отдаётся с годовым сроком хранения и пометкой «неизменяемо» — отозвать выданное
браузеру сервису нечем.
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14**
задачей `record-ownership`: и чтение записи, и файл записи сужены владельцем
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
доступа — страницы расхода для владельца сервиса.
Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения
не принимает, и держит это схема хранилища. Прежде такие записи заводил вход
Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран
вместе с этим исключением.
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи **Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
@@ -17,20 +43,103 @@
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
уходит содержимое записи» и «Что вне модели». уходит содержимое записи» и «Что вне модели».
**Третий сдвиг — панель администратора.** Решением от 2026-08-11 **Третьего сдвига — панели администратора — больше нет, и это снятие.** Решением
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)) хранилищем от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту
становится PocketBase, и вместе с ним на том же порту появляется панель по появлялась панель `/_/`: доступ ко всем записям, всем файлам и всем пользователям
адресу `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом. разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22,
Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в задачей `storage-without-pocketbase`, встроенное хранилище убрано целиком:
панель через Authelia не пускает — у неё свой пароль суперпользователя. панели не существует, второго периметра на порту сервиса не осталось, и правилу
**Закрывает панель контур, а не приложение:** решением владельца от 2026-08-11 прокси нечего закрывать.
адрес `/_/` закрывает Authelia на обратном прокси, пропуская только группу
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
она вне модели («Что вне модели», строка про контур).
Отсюда главное следствие, из которого читается всё остальное: **`POST /api/audio` **Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал
доступен кому угодно из интернета**. Отправитель не назван, не ограничен по числу сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и
запросов и не ограничен по размеру файла. `/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь
клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи
`spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего:
оба попадают под общее правило неизвестного пути и отдают разметку приложения.
Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают
байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса.
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
секрет в настройки коллекции пользователей, и чтение файла базы становилось
равносильно чтению секрета. 2026-08-22 секрета не стало вовсе: обменивать код не
на что, и изъятие из инварианта «Секрет не покидает конфиг» снято вместе с ним.
**Вместо него — новый и главный: барьер держится на том, что прокси ставит
заголовок сам.** Сервис верит `Remote-User`, пришедшему с адреса из объявленного
перечня, а перечень этот и есть адрес прокси. Прокси, настроенный **добавлять**
заголовок вместо замены, оставит рядом со своим значением присланное анонимом —
и аноним войдёт под любым именем. Часть этой беды сервис закрывает сам:
запрос с двумя значениями `Remote-User` не узнаёт никого. **Закрыт при этом
только логин.** `Remote-Name` и `Remote-Email` берутся первым значением, то есть
присланным анонимом, и адрес почты, занятый им, закрепляется за чужой учётной
записью навсегда: колонка уникальна, а найденную запись узнавание не
переписывает. Прогон ревью 2026-08-23 построил этот путь и прогнал его;
правило решено распространить на всю тройку отдельной задачей. Остальное
проверить отсюда нечем: правило живёт в `files/caddyproxy/Caddyfile.template`
репозитория `pet-project-server`, и **требование к нему такое — заголовки
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
человек.
**Изъятие из барьера одно — отладочный запуск, и заведено оно 2026-08-23**
задачей `config-test-headers-login`. При включённом предохранителе
`[server] debug` заголовки входа ставит не прокси, а сам сервис значениями из
секции `[auth.test_headers]`: на машине разработчика прокси нет, а браузер
заголовков не ставит. Узнавание при этом остаётся тем же и подставленного
заголовка от пришедшего не отличает — отлаживается боевая ветка. Нормирует
изъятие спека [access](../openspec/specs/access/spec.md), решение о подстановке
самим сервисом —
[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md).
Держится оно тремя вещами, и других нет: умолчание предохранителя —
«выключено»; заполненная имитация при выключенном предохранителе роняет старт с
именем ключа; боевой конфиг рендерится шаблоном Ansible, а не копируется с
машины разработчика. Подставленный заголовок проходит тот же барьер доверенного
адреса, что и пришедший, и судит адрес та же функция — но барьером отладочному
входу это не служит: перечень доверенных адресов включению предохранителя не
мешает.
**Боевая поломка машиной не исключена, и это названо прямо.** Сервис, поднятый в
бою с включённым предохранителем и заполненной имитацией, поднимется на любом
перечне доверенных адресов и назовёт своим именем всякого, чей запрос пришёл
через обратный прокси, — то есть всякого, кто пришёл обычным путём. Адресного
предохранителя у изъятия нет: требование петлевого перечня рассматривалось и
снято — [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md).
**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
Как именно читается цепочка, нормирует спека
[archive](../openspec/specs/archive/spec.md), «Адреса приложения живут своим
пространством». Отсюда периметровое следствие: прокси, дописывающий
`X-Forwarded-For` к присланному, этим правилом покрыт, и требования
«перезаписывать, а не дописывать» у сервиса к нему нет — в отличие от `Remote-*`.
Барьером узнавания заголовок при этом не служит: кто пришёл, решает адрес самого
соединения.
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
с такого адреса, называет себя кем угодно. Перечень поэтому обязан покрывать
адрес прокси, а не весь частный диапазон: сеть докера целиком означает «любой
контейнер на хосте», включая чужие. Образец конфига называет узкий пример
именно поэтому.
**Пятый сдвиг — логин у провайдера переиспользуем.** Ключ учётной записи —
`Remote-User`, то есть логин человека у Authelia. Логин можно выдать заново
после ухода прежнего владельца, и тогда новый человек при первом же обращении
попадает в **существующую** запись и получает весь её архив — самое
чувствительное, что у сервиса есть. Сервис этого не различает и различить не
может: неизменяемого признака заголовок не приносит. Не допускать
переиспользования — работа провайдера, и это принятая цена, записанная в
[access](../openspec/specs/access/spec.md). Обратная сторона той же цены:
переименование заводит **новую** запись, а прежняя остаётся с архивом, который
нечем ни слить, ни убрать.
Отсюда главное следствие, из которого читается всё остальное: **`POST
/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
же запросов ограничено только частотой**. Вошедший тратит наши деньги на
распознавание столько, сколько захочет: ограничитель частоты под корнем
приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму
по-прежнему нет — её заводит `per-user-size-quota`.
## Недоверенный вход ## Недоверенный вход
@@ -38,11 +147,12 @@
| Вход | Канал | Кто может слать | | Вход | Канал | Кто может слать |
| --- | --- | --- | | --- | --- | --- |
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой из интернета | | **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения. Слать тройку может ещё и сам сервис — при включённом предохранителе `[server] debug`, значением из настроек; изъятие целиком описано в «Периметре» выше |
| Идентификатор задачи | `GET /api/status/:id` | Любой из интернета | | Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой узнанный; неузнанному — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
| Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка | | Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой узнанный; неузнанному — `401`, одинаковый для заведённой и незаведённой записи |
| Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель | | Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой узнанный; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов | | Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой узнанный; значение вне закрытого перечня даёт `400` |
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи | | Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
Что добавится вместе с целевым периметром — каждый вход появляется своей Что добавится вместе с целевым периметром — каждый вход появляется своей
@@ -51,7 +161,6 @@
| Вход | Канал | Кто может слать | Чья задача | | Вход | Канал | Кто может слать | Чья задача |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` | | Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
| Данные учётной записи: идентификатор, почта, группы | Ответ Authelia по OIDC | Провайдер, а через него — то, что записано в учётной записи | `oidc-login` |
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` | | Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` |
| Вычитанный текст | Ответ той же модели | То же | `literary-text-level` | | Вычитанный текст | Ответ той же модели | То же | `literary-text-level` |
| Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` | | Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` |
@@ -63,9 +172,10 @@
## Куда уходит содержимое записи ## Куда уходит содержимое записи
Сегодня запись и её текст покидают наш сервер тремя путями: файл уезжает в Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
Yandex Object Storage, оттуда его читает SpeechKit, а текст возвращается в Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
Telegram отправителю. 2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
приложения.
Целевой периметр добавляет три пути, каждый — своей задачей: Целевой периметр добавляет три пути, каждый — своей задачей:
@@ -85,27 +195,40 @@ Telegram отправителю.
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
путь не предполагался. путь не предполагался.
- **Путь на диске** выбирает хранилище: - **Путь на диске** выбирает сервис: `data/records/<ULID записи>/<имя>`. Обе
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис** части задаёт он сам — подкаталог назван идентификатором записи, имя файла это
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени `<ULID><расширение>`, — и имя, данное отправителем, не попадает ни в одну из
отправителя, не применяется: имя отправителя в хранилище не попадает. них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
каталогов, но это единственное, что стоит между входом и именем файла. каталогов, но это единственное, что стоит между входом и именем файла. Длина
расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками
роняет заведение временного файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с - **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет. расширением. Бакет один на все записи, префикса по пользователю нет. С
- **Ссылка на файл**`/api/files/<коллекция>/<запись>/<имя>`. Поле файла не 2026-08-14 копия там файлом записи не считается: она существует лишь потому,
помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется** распознавания.
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку - **Сохранённый ответ провайдера** лежит третьим файлом в том же подкаталоге
целиком и работала бы бессрочно. В журнал идёт расширение своим полем. записи, под именем, которое задаёт сервис. Содержимое там — **полный текст
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное, речи**, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса,
что защищает `GET /api/status/:id`. которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят журнал, ни в метку метрики, ни в ответ.
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`, - **Адрес файла**`GET /app/audiorecords/{id}/file?copy=original|normalized`.
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то Право пройти по нему даёт **узнавание пришедшего и владение записью**, и
есть доступны они только владельцу панели; проверено прогоном — записи отдают судится оно там же, где отдаётся файл. Значений на предъявителя сервис не
`403`, служебные разделы `401`. выдаёт вовсе: короткий токен файла ушёл 2026-08-22 вместе со встроенным
хранилищем, и отзыв доступа доходит до файла сразу, а не через срок жизни
выданного значения. Запрет при этом остаётся: **имя файла на диске в журнал не
пишется** — строка журнала стала бы бессрочным ключом к чужой записи. В журнал
идёт расширение своим полем.
- **Идентификатор записи** — ULID, 26 знаков, выдаёт приложение. Он же
единственное, что защищает карточку записи, её текст и её файл сверх владения.
- **Чужой поверхности на порту сервиса нет.** Адреса `/api/collections/...`,
`/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/` ушли
вместе со встроенным хранилищем 2026-08-22. Отвечает сервис только своими
адресами, а всё прочее идёт общим правилом неизвестного пути — норму держит
[webapp](../openspec/specs/webapp/spec.md). Что содержимое записи закрыто
везде, где лежит, нормирует [storage](../openspec/specs/storage/spec.md).
Целевой периметр добавляет сюда три вещи, и все три — от новых задач: Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
@@ -120,54 +243,110 @@ Telegram отправителю.
## Что разграничивает доступ ## Что разграничивает доступ
- **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой - **HTTP API** — заголовок `Remote-User`, пришедший с адреса из объявленного
автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя перечня доверенных. Адрес берётся у самого соединения, а не из пересылаемого
с фамилией), а не с числовым идентификатором. Имя пользователя Telegram заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
меняется владельцем в любой момент: список привязан к изменяемому значению. переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
- **HTTP API** — ничего. Ни ключа, ни сессии, ни ограничения по адресу. отзыв доступа у Authelia действует со следующего обращения.
- **Метрики и здоровье**`GET /metrics` и `GET /health` открыты вместе с Собственных токенов сервис не принимает вовсе: значения, предъявленного
остальным. запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое
значение било заголовок — им работал владелец панели; панели нет, и правило
приоритета осталось бы правилом без предмета.
**Область узнавания — корень приложения**, и выводится она из объявленного
адресного пространства сервиса: слои одеты на корень целиком, вторым списком
адресов область не описывается. Проба здоровья, метрики и ресурсы приложения
под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к
базе, а первый такой запрос с новым именем — записи в неё.
- **Учётная запись** — заводится первым обращением с новым логином и находится
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе:
своих экранов профиля он не заводит, а поверхности хранилища, правившей запись
библиотечным правилом, не осталось.
- **Файл записи** — узнавание пришедшего и владение записью, судимые в самом
обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места,
где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос,
сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла.
- **Кто допущен****решает Authelia, а не сервис.** Своей проверки группы
приложение не делает: кого пускать, определяет правило провайдера на этого
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
его не проверить. Клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
2026-08-12.
- **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю,
одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и
продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать
больше нечего, и адресов этих не существует.
- **Метрики и здоровье**`GET /metrics` и `GET /health` открыты неузнанному:
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
учётной записи на них не заводит. Наружу их закрывает правило обратного
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
эти адреса не несут.
- **Приложение** — его разметка и ресурсы открыты неузнанному, и ограничителя
частоты на них нет: правило заведено под корень приложения, а раздача стоит
вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы
для всех и собраны до всякого запроса. По ответу нельзя узнать, узнан ли
кто-то, — узнанному и неузнанному отдаётся одно и то же.
Владения записью в модели данных нет: у задачи нет пользователя. Пока API Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
анонимен, знание UUID задачи и есть право её читать. есть владелец. Знание идентификатора задачи правом её читать больше не является
— читает её тот, кто её принёс.
Целевой периметр заводит четыре механизма вместо одного белого списка: Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
них уже стоит:
| Механизм | Что даёт | Чья задача | | Механизм | Что даёт | Чья задача |
| --- | --- | --- | | --- | --- | --- |
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты | `oidc-login` | | Заголовок от Authelia через прокси | Право открыть приложение и его эндпоинты **сделано 2026-08-22**; прежде то же давала сессия OIDC, с 2026-08-12 | `trusted-header-login` |
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` | | Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено»**сделано 2026-08-14** | `record-ownership` |
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` | | Личный токен | Права своего владельца программе, которой прокси заголовка не ставит | `api-tokens` |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` | | Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
Белый список Telegram при этом перестаёт быть отдельным механизмом: право
писать боту выводится из учётной записи (`telegram-account-link`).
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой». модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
Откуда он берётся — из группы OIDC или из конфигурации — не решено Откуда он берётся — из группы OIDC или из конфигурации — не решено
(`admin-stats-screen`). (`admin-stats-screen`).
**Панель администратора в эту таблицу не входит и разграничению не подчиняется.** **Панели администратора в этой таблице нет, и это снятие, а не пропуск.** До
Суперпользователь PocketBase видит все записи, все файлы и всех пользователей 2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и
мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia. всех пользователей мимо любого из механизмов разграничения, а пускал его свой
Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя: пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у
обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение сервиса осталось одно — владение записью.
по списку адресов (`superuserIPs`), и оно же запирает владельца, если список
задан неверно: сброса в наборе команд нет. Владелец сервиса взамен получил одно действие и один инструмент: подкоманда
`cmd/devtools resume` возвращает остановленную запись в работу. Она ходит **в тот
же каталог данных**, то есть требует доступа к файлам сервера, а не к сети:
поверхности, открытой в интернет, у неё нет вовсе.
## Что чувствительнее чего ## Что чувствительнее чего
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка; 1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
это самое чувствительное, что здесь есть. это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним. колонкой, а шестью таблицами: сама запись (заголовок и краткое описание),
3. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage. `texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
`recognitions` (попытка распознавания; **сохранённый ответ провайдера —
полный текст речи — лежит файлом в подкаталоге записи**),
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается
наравне с записью — норму держит спека `storage`.
2. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету. Утечка оплачивается деньгами и доступом к бакету.
4. **Белый список пользователей** — сам по себе перечень имён. Секрета клиента OIDC в этом списке больше нет: 2026-08-22 он ушёл из конфига и
из базы вместе с собственным входом.
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита. кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
**Строки `.env` в `.gitignore` и в `.dockerignore` стоят без читателя, и снимать
их поэтому нельзя.** Читателя сняли 2026-08-23 вместе с зависимостью
`godotenv`: наш рабочий код окружение не читает, и файл, положенный рядом с
бинарником, ничего не меняет. Барьеры остались против другого — против того,
чтобы секрет завёлся в этом файле руками и уехал из него теми же двумя путями,
какими уехал бы из конфига: в git и в контекст сборки образа. Второй барьер
нужен отдельно от первого: `Dockerfile` копирует корень целиком (`COPY . .`), а
`.gitignore` docker не читает — чужой `.env` лёг бы слоем образа. Инвариант,
ради которого барьеры стоят, — «Секрет не покидает конфиг» из
[../CLAUDE.md](../CLAUDE.md).
Целевой периметр добавляет к списку пять записей, и первая из них — новый вид Целевой периметр добавляет к списку пять записей, и первая из них — новый вид
секрета, которого сегодня в проекте нет вовсе: секрета, которого сегодня в проекте нет вовсе:
@@ -183,14 +362,10 @@ Telegram отправителю.
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит, 5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
но говорит, кто и когда пользовался сервисом и сколько; страница расхода но говорит, кто и когда пользовался сервисом и сколько; страница расхода
открыта только владельцу. открыта только владельцу.
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с
пользователей разом, то есть стоит вровень с самым чувствительным из списка самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище,
выше. Второй секрет после токенов пользователей, который лежит **не в пропал, и ключа под него в конфигурации не заводится по той простой причине, что
конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец заводить нечего.
по приглашению, которое сервис печатает в журнал при первом запуске. У
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
читателю навсегда.
Тексты расшифровок в логи не пишутся — логируется длина текста и Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
@@ -213,18 +388,31 @@ Telegram отправителю.
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем. Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила `mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
никогда. Ряды, собранные до выкладки, перестают пополняться: панель, отобранная никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный
по старому значению, покажет пустоту, и это не поломка. по старому значению, покажет пустоту, и это не поломка.
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
приведения хвост читал бы кто угодно из интернета, а множеством значений метки приведения хвост читал бы кто угодно из интернета, а множеством значений метки
распоряжался бы анонимный отправитель. распоряжался бы анонимный отправитель.
Приём из Telegram имени, данного человеком, до сервиса не доводит: оттуда Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки клиента — закрыты задачами `no-user-filename-in-log` и
типа файла не идёт. `local-run-without-telegram-token` 2026-08-13 и потеряли предмет 2026-08-14
вместе с убранным входом: ни клиента, ни токена у сервиса больше нет. Разбор
случая остался в [review.md](review.md) — он про класс, а не про Telegram.
Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL Путь, который остался, закрыт задачей `telegram-enabled-flag` 2026-08-13, и он
нигде не логируется. **шире всякого одного ключа**: до неё утечь мог любой секрет конфига. Отказ разбора файла
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка
секретного ключа с оборванной кавычкой — типовая поломка криво собранного
шаблона выкладки — уносила ключ в журнал контейнера целиком. Теперь такой отказ
пересобирается своими словами: путь, строка, столбец и последний ключ, без текста
библиотеки; прочие отказы декодера собраны из имён ключей и типов и потому
проходят как есть. Нашло это ревью дизайна, чинилось решением владельца в той же
работе. Правило — [conventions/config.md](conventions/config.md), «Секреты»;
оракулы — `internal/config/config_test.go`, проверки поломанного файла настроек.
Остаточный риск назван там же: разрез опирается на то, какое семейство отказов
несёт значения **в нынешней версии** библиотеки.
## Что вне модели ## Что вне модели
@@ -232,6 +420,16 @@ Telegram отправителю.
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и - **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
Ansible — не наша граница. Ansible — не наша граница.
- **Машина разработчика и то, что он на ней поднимает.** На место контура встаёт
сам сервис: при включённом предохранителе `[server] debug` он подставляет
заголовки входа значениями из конфига. Прежде эту роль играли отдельные
процессы — `cmd/oidcstub` с 2026-08-15 по 2026-08-22 и подкоманда
`cmd/devtools proxy` с 2026-08-22 по 2026-08-23; ни того, ни другой в
репозитории больше нет. Периметра выкладки отладочный запуск не касается,
пока предохранитель выключен, а выключен он по умолчанию; кто включил его у
себя в чужой сети, отвечает за это сам. В оснастке `cmd/devtools` осталась
одна подкоманда — `resume`, — и в образ она не едет: ступень собирает
`./cmd/transcriber` поимённо.
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому - **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
доверяем полностью. доверяем полностью.
- **Достоверность расшифровки.** Подмена или искажение текста на стороне - **Достоверность расшифровки.** Подмена или искажение текста на стороне
@@ -239,12 +437,15 @@ Telegram отправителю.
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по - **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся. размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
- **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно - **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно
(паспорт, 2026-08-11), шестичасовая запись весит единицы гигабайт — оценка, а не (паспорт, 2026-08-11). Шестичасовая запись весит единицы гигабайт — оценка, а
замер: `research/` пуст, потолок длины стоит открытым вопросом не замер: распределения длин у сервиса нет, а самая длинная проверенная запись
`architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать — 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)).
(цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот
Authelia. Рост каталога данных при этом ничем не наблюдается — нет — это граница домена, [passport.md](passport.md), «Учёт денег»; расход
открытый вопрос `architecture.md`. считают `usage-accounting` и `admin-stats-screen`. Для модели угроз отсюда
следует одно: ни числом запросов, ни размером записи вошедший не ограничен, и
защищаться от исчерпания диска мы не пытаемся. Рост каталога данных при этом
ничем не наблюдается — открытый вопрос `architecture.md`.
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель - **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
оплачиваются по факту; потолка на пользователя нет по тому же решению. оплачиваются по факту; потолка на пользователя нет по тому же решению.
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан - **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
@@ -253,6 +454,16 @@ Telegram отправителю.
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
бессрочно, и тем же днём заведена задача `delete-record`: своя запись бессрочно, и тем же днём заведена задача `delete-record`: своя запись
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста. убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
Пока она не сделана, единственный способ убрать запись — руками в базе и в Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
каталоге на сервере. Учёт расхода удалению не подлежит по решению человека: строки потребления текста не содержат.
деньги потрачены, а строки потребления текста не содержат.
**Руками запись сегодня убирается только запросом к базе, и порядок в нём
несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее
чего»), связи приложений с записью обязательны и каскада не имеют, поэтому
удаление самой строки отвергается базой, пока живы приложения. Порядок такой:
сперва строки приложений — журнал событий, попытка распознавания, структура,
тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом
убираются **одним движением**: подкаталог записи под её идентификатором. Тот,
кто убрал только файлы, стирает аудио и **оставляет полный текст речи**
расшифровку, разбивку по репликам и сохранённый ответ провайдера. До
`delete-record` это единственный способ, и он ручной целиком.
+27 -44
View File
@@ -1,81 +1,64 @@
module git.vakhrushev.me/av/transcriber module git.vakhrushev.me/av/transcriber
go 1.25.0 go 1.26.6
require ( require (
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0
github.com/aws/aws-sdk-go-v2 v1.37.2 github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.30.3 github.com/aws/aws-sdk-go-v2/config v1.30.3
github.com/aws/aws-sdk-go-v2/credentials v1.18.3 github.com/aws/aws-sdk-go-v2/credentials v1.18.3
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/aws/smithy-go v1.27.7
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/pressly/goose/v3 v3.27.3
github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.39.10
github.com/prometheus/client_golang v1.23.0 github.com/prometheus/client_golang v1.23.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.11.1
github.com/yandex-cloud/go-genproto v0.17.0 github.com/yandex-cloud/go-genproto v0.17.0
google.golang.org/grpc v1.74.2 google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.57.0
) )
require ( require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 // indirect
github.com/aws/smithy-go v1.27.7 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.19.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
github.com/ganigeorgiev/fexpr v0.6.0 // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/sethvargo/go-retry v0.4.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect go.uber.org/multierr v1.11.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/image v0.44.0 // indirect
golang.org/x/net v0.57.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect
google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.1 // indirect modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.55.0 // indirect
) )
+75 -133
View File
@@ -1,14 +1,9 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg=
github.com/aws/aws-sdk-go-v2/config v1.30.3 h1:utupeVnE3bmB221W08P0Moz1lDI3OwYa2fBtUhl7TCc= github.com/aws/aws-sdk-go-v2/config v1.30.3 h1:utupeVnE3bmB221W08P0Moz1lDI3OwYa2fBtUhl7TCc=
github.com/aws/aws-sdk-go-v2/config v1.30.3/go.mod h1:NDGwOEBdpyZwLPlQkpKIO7frf18BW8PaCmAM9iUxQmI= github.com/aws/aws-sdk-go-v2/config v1.30.3/go.mod h1:NDGwOEBdpyZwLPlQkpKIO7frf18BW8PaCmAM9iUxQmI=
github.com/aws/aws-sdk-go-v2/credentials v1.18.3 h1:ptfyXmv+ooxzFwyuBth0yqABcjVIkjDL0iTYZBSbum8= github.com/aws/aws-sdk-go-v2/credentials v1.18.3 h1:ptfyXmv+ooxzFwyuBth0yqABcjVIkjDL0iTYZBSbum8=
@@ -17,191 +12,138 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2 h1:nRniHAvjFJGUCl04F3WaAj7
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2/go.mod h1:eJDFKAMHHUvv4a0Zfa7bQb//wFNUXGrbFpYRCHe2kD0= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.2/go.mod h1:eJDFKAMHHUvv4a0Zfa7bQb//wFNUXGrbFpYRCHe2kD0=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 h1:Nb2pUE30lySKPGdkiIJ1SZgHsjiebOiRNI7R9NA1WtM= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3 h1:Nb2pUE30lySKPGdkiIJ1SZgHsjiebOiRNI7R9NA1WtM=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3/go.mod h1:BO5EKulvhBF1NXwui8lfnuDPBQQU5807yvWASZ/5n6k= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.3/go.mod h1:BO5EKulvhBF1NXwui8lfnuDPBQQU5807yvWASZ/5n6k=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2 h1:sBpc8Ph6CpfZsEdkz/8bfg8WhKlWMCms5iWj6W/AW2U= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.2/go.mod h1:Z2lDojZB+92Wo6EKiZZmJid9pPrDJW2NNIXSlaEfVlU= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2 h1:blV3dY6WbxIVOFggfYIo2E1Q2lZoy5imS7nKgu5m6Tc= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.2/go.mod h1:cBWNeLBjHJRSmXAxdS7mwiMUEgx6zup4wQ9J+/PcsRQ= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2 h1:oxmDEO14NBZJbK/M8y3brhMFEIGN4j8a6Aq8eY0sqlo= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.2/go.mod h1:4hH+8QCrk1uRWDPsVfsNDUup3taAjO8Dnx63au7smAU= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2 h1:0hBNFAPwecERLzkhhBY+lQKUMpXSKVv4Sxovikrioms= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.2/go.mod h1:Vcnh4KyR4imrrjGN7A2kP2v9y6EPudqoPKXtnmBliPU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0 h1:utPhv4ECQzJIUbtx7vMN4A8uZxlQ5tSt1H1toPI41h8= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.86.0/go.mod h1:1/eZYtTWazDgVl96LmGdGktHFi7prAcGCrJ9JGvBITU= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 h1:j7/jTOjWeJDolPwZ/J4yZ7dUsxsWZEsxNwH5O7F8eEA= github.com/aws/aws-sdk-go-v2/service/sso v1.27.0 h1:j7/jTOjWeJDolPwZ/J4yZ7dUsxsWZEsxNwH5O7F8eEA=
github.com/aws/aws-sdk-go-v2/service/sso v1.27.0/go.mod h1:M0xdEPQtgpNT7kdAX4/vOAPkFj60hSQRb7TvW9B0iug= github.com/aws/aws-sdk-go-v2/service/sso v1.27.0/go.mod h1:M0xdEPQtgpNT7kdAX4/vOAPkFj60hSQRb7TvW9B0iug=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 h1:ywQF2N4VjqX+Psw+jLjMmUL2g1RDHlvri3NxHA08MGI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0 h1:ywQF2N4VjqX+Psw+jLjMmUL2g1RDHlvri3NxHA08MGI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0/go.mod h1:Z+qv5Q6b7sWiclvbJyPSOT1BRVU9wfSUPaqQzZ1Xg3E= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.32.0/go.mod h1:Z+qv5Q6b7sWiclvbJyPSOT1BRVU9wfSUPaqQzZ1Xg3E=
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo6uk95plBFKoXt2M= github.com/aws/aws-sdk-go-v2/service/sts v1.36.0 h1:bRP/a9llXSSgDPk7Rqn5GD/DQCGo6uk95plBFKoXt2M=
github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58= github.com/aws/aws-sdk-go-v2/service/sts v1.36.0/go.mod h1:tgBsFzxwl65BWkuJ/x2EUs59bD4SfYKgikvFDJi1S58=
github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw=
github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE= github.com/aws/smithy-go v1.27.7 h1:Zgj5z4LfcDYoQIVk+n/yGdTkP/2y6ZT5vYxe0fp7bqE=
github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.27.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
github.com/pocketbase/pocketbase v0.39.10 h1:2j8TDJRuo3aAC8Y8F9WFux0SwYcxeDCgEYQxxdWkwGE=
github.com/pocketbase/pocketbase v0.39.10/go.mod h1:tSX3anHQ7Ul6dPV9WhlEc6No1DtklGF69iwnVNW3BEE=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM= github.com/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM=
github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo= github.com/yandex-cloud/go-genproto v0.17.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0=
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
@@ -212,8 +154,8 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -222,8 +164,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+5 -3
View File
@@ -1,6 +1,7 @@
package ffmpeg package ffmpeg
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -15,7 +16,7 @@ func NewFfmpegConverter() *FfmpegConverter {
return &FfmpegConverter{} return &FfmpegConverter{}
} }
func (c *FfmpegConverter) Convert(src, dest string) error { func (c *FfmpegConverter) Convert(ctx context.Context, src, dest string) error {
// Проверяем существование исходного файла // Проверяем существование исходного файла
if _, err := os.Stat(src); os.IsNotExist(err) { if _, err := os.Stat(src); os.IsNotExist(err) {
return fmt.Errorf("input file does not exist: %s", src) return fmt.Errorf("input file does not exist: %s", src)
@@ -26,8 +27,9 @@ func (c *FfmpegConverter) Convert(src, dest string) error {
return fmt.Errorf("ffmpeg not found in PATH: %w", err) return fmt.Errorf("ffmpeg not found in PATH: %w", err)
} }
// Создаем команду ffmpeg для конвертации в OGG // Команда заводится с контекстом: отменённый контекст убивает процесс, а не
cmd := exec.Command(ffmpegExecutable, // оставляет его дожёвывать чужую запись после остановки воркера.
cmd := exec.CommandContext(ctx, ffmpegExecutable,
"-i", src, // входной файл "-i", src, // входной файл
"-c:a", "libvorbis", // кодек Vorbis для OGG "-c:a", "libvorbis", // кодек Vorbis для OGG
"-q:a", "4", // качество аудио (0-10, где 4 - хорошее качество) "-q:a", "4", // качество аудио (0-10, где 4 - хорошее качество)
+5 -3
View File
@@ -1,6 +1,7 @@
package ffmpeg package ffmpeg
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@@ -26,7 +27,7 @@ func NewFfmpegMetaViewer() *FfmpegMetaViewer {
return &FfmpegMetaViewer{} return &FfmpegMetaViewer{}
} }
func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) { func (m *FfmpegMetaViewer) GetInfo(ctx context.Context, src string) (*contract.AudioInfo, error) {
// Проверяем существование исходного файла // Проверяем существование исходного файла
if _, err := os.Stat(src); os.IsNotExist(err) { if _, err := os.Stat(src); os.IsNotExist(err) {
return nil, fmt.Errorf("input file does not exist: %s", src) return nil, fmt.Errorf("input file does not exist: %s", src)
@@ -37,8 +38,9 @@ func (m *FfmpegMetaViewer) GetInfo(src string) (*contract.AudioInfo, error) {
return nil, fmt.Errorf("ffprobe not found in PATH: %w", err) return nil, fmt.Errorf("ffprobe not found in PATH: %w", err)
} }
// Создаем команду ffprobe для получения метаданных // Команда заводится с контекстом: отправитель, закрывший соединение, не
cmd := exec.Command(ffprobeExecutable, // оставляет за собой чтение метаданных чужого файла.
cmd := exec.CommandContext(ctx, ffprobeExecutable,
"-v", "quiet", // тихий режим (без лишнего вывода) "-v", "quiet", // тихий режим (без лишнего вывода)
"-print_format", "json", // вывод в формате JSON "-print_format", "json", // вывод в формате JSON
"-show_format", // показать информацию о формате "-show_format", // показать информацию о формате
+65 -7
View File
@@ -1,22 +1,80 @@
package recognizer package recognizer
import ( import (
"context"
"encoding/json"
"errors"
"io" "io"
"git.vakhrushev.me/av/transcriber/internal/entity"
"github.com/google/uuid" "github.com/google/uuid"
"git.vakhrushev.me/av/transcriber/internal/entity"
) )
// MemoryAudioRecognizer — подставной распознаватель для местного запуска и
// проверок. Прогон на реальных ключах ради проверки кода запрещён: распознавание
// и хранение в Object Storage оплачиваются по факту.
//
// Сырой ответ он отдаёт своего вида, но настоящего: тем же путём, что и живой
// адаптер, — сохранённые байты разбираются обратно в реплики, и структура
// строится без единого обращения наружу.
type MemoryAudioRecognizer struct{} type MemoryAudioRecognizer struct{}
func (r *MemoryAudioRecognizer) Recognize(file io.Reader, fileName string) (operationID string, err error) { const memoryProvider = "memory"
func (r *MemoryAudioRecognizer) Provider() string { return memoryProvider }
func (r *MemoryAudioRecognizer) Model() string { return "memory" }
func (r *MemoryAudioRecognizer) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) {
return "memory://" + objectKey, nil
}
func (r *MemoryAudioRecognizer) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
return false, nil
}
func (r *MemoryAudioRecognizer) Submit(ctx context.Context, sourceURI string) (string, error) {
return uuid.NewString(), nil return uuid.NewString(), nil
} }
func (r *MemoryAudioRecognizer) GetRecognitionText(operationID string) (string, error) { func (r *MemoryAudioRecognizer) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
return "Foo bar, Baz.", nil
}
func (r *MemoryAudioRecognizer) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
return entity.NewCompletedResult(), nil return entity.NewCompletedResult(), nil
} }
func (r *MemoryAudioRecognizer) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
replicas := []entity.Replica{
{StartMs: 0, EndMs: 1000, Text: "Foo bar,"},
{StartMs: 1000, EndMs: 2000, Text: "Baz."},
}
raw, err := json.Marshal(replicas)
if err != nil {
return nil, errors.New("failed to encode memory payload")
}
return &entity.RecognitionOutcome{
Replicas: replicas,
PlainText: "Foo bar, Baz.",
Raw: raw,
}, nil
}
func (r *MemoryAudioRecognizer) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
var replicas []entity.Replica
if err := json.Unmarshal(raw, &replicas); err != nil {
return nil, errors.New("failed to decode memory payload")
}
var plain []byte
for _, replica := range replicas {
if len(plain) > 0 {
plain = append(plain, ' ')
}
plain = append(plain, replica.Text...)
}
return &entity.RecognitionOutcome{
Replicas: replicas,
PlainText: string(plain),
Raw: raw,
}, nil
}
@@ -0,0 +1,138 @@
package yandex
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
"google.golang.org/protobuf/proto"
)
// Сохранённый ответ провайдера — единственное, из чего пересчитывается архив:
// результат операции у SpeechKit не переспрашивается, и повторное распознавание
// стоит денег. Поэтому проверки ниже судят не «разбор чего-то вернул», а
// сохранность самого ответа.
// response собирает ответ потока с одной репликой.
func response(text string, start, end int64) *stt.StreamingResponse {
return &stt.StreamingResponse{
Event: &stt.StreamingResponse_FinalRefinement{
FinalRefinement: &stt.FinalRefinement{
Type: &stt.FinalRefinement_NormalizedText{
NormalizedText: &stt.AlternativeUpdate{
Alternatives: []*stt.Alternative{
{Text: text, StartTimeMs: start, EndTimeMs: end},
},
},
},
},
},
}
}
// Сохранённое читается обратно тем же: реплики со временем и плоский текст.
func TestPayloadSurvivesRoundTrip(t *testing.T) {
responses := []*stt.StreamingResponse{
response("Первая реплика.", 0, 900),
response("Вторая реплика.", 900, 1800),
}
raw, err := encodeResponses(responses)
require.NoError(t, err)
require.NotEmpty(t, raw)
decoded, err := decodeResponses(raw)
require.NoError(t, err)
require.Len(t, decoded, 2)
outcome := outcomeFromResponses(decoded)
require.Len(t, outcome.Replicas, 2)
assert.Equal(t, "Первая реплика.", outcome.Replicas[0].Text)
assert.Equal(t, int64(0), outcome.Replicas[0].StartMs)
assert.Equal(t, int64(900), outcome.Replicas[0].EndMs)
assert.Equal(t, "Вторая реплика.", outcome.Replicas[1].Text)
assert.Equal(t, "Первая реплика. Вторая реплика.", outcome.PlainText)
}
// Ради этого свойства сохранение и сделано двоичным. Провайдер добавляет поля
// без предупреждения, и текстовое представление, собранное по нашей
// скомпилированной схеме, выбросило бы их молча — а пересчитать архив было бы
// уже не из чего: операция не переспрашивается.
func TestUnknownProviderFieldSurvivesStorage(t *testing.T) {
original := response("Реплика.", 0, 500)
// Так выглядит поле, которого наша схема не знает: провайдер прислал его,
// разбор положил в неизвестные.
unknown := protoimplUnknown(t)
original.ProtoReflect().SetUnknown(unknown)
require.NotEmpty(t, original.ProtoReflect().GetUnknown(), "неизвестное поле поставлено")
raw, err := encodeResponses([]*stt.StreamingResponse{original})
require.NoError(t, err)
decoded, err := decodeResponses(raw)
require.NoError(t, err)
require.Len(t, decoded, 1)
assert.Equal(t, []byte(unknown), []byte(decoded[0].ProtoReflect().GetUnknown()),
"неизвестное провайдерское поле пережило запись и чтение")
// И известное при этом на месте.
outcome := outcomeFromResponses(decoded)
require.Len(t, outcome.Replicas, 1)
assert.Equal(t, "Реплика.", outcome.Replicas[0].Text)
}
// Обрезанное вложение узнаётся отказом, а не половиной расшифровки: половина
// текста, выданная за целую, тише и хуже отказа.
func TestTruncatedPayloadIsRefused(t *testing.T) {
raw, err := encodeResponses([]*stt.StreamingResponse{response("Реплика.", 0, 500)})
require.NoError(t, err)
require.Greater(t, len(raw), 2)
_, err = decodeResponses(raw[:len(raw)-2])
assert.Error(t, err, "обрезанное вложение не разбирается молча")
}
// Пустой поток даёт пустой результат, а не отказ: «на записи нет текста» —
// законный исход распознавания.
func TestEmptyStreamGivesEmptyOutcome(t *testing.T) {
raw, err := encodeResponses(nil)
require.NoError(t, err)
decoded, err := decodeResponses(raw)
require.NoError(t, err)
assert.Empty(t, decoded)
outcome := outcomeFromResponses(decoded)
assert.Empty(t, outcome.Replicas)
assert.Empty(t, outcome.PlainText)
}
// Ответ без разбора текста реплик не даёт и разбор не роняет: провайдер шлёт по
// потоку и служебные события.
func TestResponseWithoutTextIsSkipped(t *testing.T) {
responses := []*stt.StreamingResponse{
{Event: &stt.StreamingResponse_FinalRefinement{FinalRefinement: &stt.FinalRefinement{}}},
response("Реплика.", 0, 500),
response("", 500, 600),
}
outcome := outcomeFromResponses(responses)
require.Len(t, outcome.Replicas, 1, "пустые и служебные события репликами не становятся")
assert.Equal(t, "Реплика.", outcome.PlainText)
}
// protoimplUnknown собирает байты неизвестного поля: номер поля, которого в
// нашей схеме нет, с целочисленным значением.
func protoimplUnknown(t *testing.T) []byte {
t.Helper()
// Поле 4095, тип varint, значение 7 — заведомо за пределами схемы ответа.
raw, err := proto.Marshal(&stt.StreamingResponse{})
require.NoError(t, err)
require.Empty(t, raw)
return []byte{0xF8, 0xFF, 0x3F, 0x07}
}
@@ -1,12 +1,18 @@
package yandex package yandex
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"time"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
// ProviderName — имя провайдера, под которым сохраняется попытка распознавания.
// По нему видно, чем считана запись, когда провайдеров станет больше одного.
const ProviderName = "yandex-speechkit"
type YandexAudioRecognizerConfig struct { type YandexAudioRecognizerConfig struct {
// s3 // s3
Region string Region string
@@ -54,29 +60,74 @@ func (s *YandexAudioRecognizerService) Close() error {
return s.sttService.Close() return s.sttService.Close()
} }
func (s *YandexAudioRecognizerService) Recognize(file io.Reader, fileName string) (string, error) { func (s *YandexAudioRecognizerService) Provider() string { return ProviderName }
err := s.s3Sevice.uploadFile(file, fileName) func (s *YandexAudioRecognizerService) Model() string { return RecognitionModel }
if err != nil {
// startRecognitionTimeout — сколько ждём принятия операции, когда нас уже
// остановили. Число меньше жёсткого предела остановки: иначе процесс убьют
// прежде, чем ответ дойдёт, и защита ничего не даст.
const startRecognitionTimeout = 10 * time.Second
// Upload кладёт аудио туда, откуда провайдер его прочитает.
//
// Отменяется штатно: заливка дорога по времени, а повтор её бесплатен — объект
// ложится под тем же ключом.
func (s *YandexAudioRecognizerService) Upload(ctx context.Context, file io.Reader, objectKey string) (string, error) {
if err := s.s3Sevice.uploadFile(ctx, file, objectKey); err != nil {
return "", err return "", err
} }
return s.s3Sevice.fileUrl(objectKey), nil
}
uri := s.s3Sevice.fileUrl(fileName) // ObjectExists отвечает, лежит ли объект нужного размера.
//
// Сверка идёт по присутствию и длине, а не по отпечатку содержимого: признак
// целостности у составного объекта не равен отпечатку, и сверка хешем дала бы
// расхождение на всякой большой записи.
func (s *YandexAudioRecognizerService) ObjectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
return s.s3Sevice.objectExists(ctx, objectKey, size)
}
opId, err := s.sttService.recognizeFileFromS3(uri) // Submit заводит операцию распознавания. Оплачивается наружу, поэтому от отмены
// защищён: окно короткое и дорогое — SpeechKit может операцию принять и начать
// считать деньги, а ответ до нас не доедет, и повтор оплатит ту же запись второй
// раз. Свой предел вызову оставлен, чтобы остановка не ждала вечно.
func (s *YandexAudioRecognizerService) Submit(ctx context.Context, sourceURI string) (string, error) {
startCtx, cancel := protectFromCancel(ctx, startRecognitionTimeout)
defer cancel()
return s.sttService.recognizeFileFromS3(startCtx, sourceURI)
}
// protectFromCancel отвязывает вызов от отмены родителя, оставляя ему значения
// родителя и собственный предел по времени. Употребляется там, где обрыв стоит
// дороже ожидания: у платной операции, чей результат нельзя переспросить.
func protectFromCancel(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), timeout)
}
// Fetch забирает готовый результат и отдаёт его доменным: реплики со временем,
// плоский текст и байты ответа на хранение. Формата провайдера наружу не выходит
// ничего — ни один шаг конвейера не знает, каким потоком тот отвечает.
func (s *YandexAudioRecognizerService) Fetch(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
return s.sttService.fetchRecognition(ctx, operationID)
}
// Parse строит доменный результат из **сохранённого** ответа, не обращаясь к
// провайдеру. По нему архив пересчитывается без единого рубля.
func (s *YandexAudioRecognizerService) Parse(raw []byte) (*entity.RecognitionOutcome, error) {
responses, err := decodeResponses(raw)
if err != nil { if err != nil {
return "", err return nil, err
} }
outcome := outcomeFromResponses(responses)
return opId, nil outcome.Raw = raw
return outcome, nil
} }
func (s *YandexAudioRecognizerService) GetRecognitionText(operationID string) (string, error) { func (s *YandexAudioRecognizerService) CheckStatus(ctx context.Context, operationID string) (*entity.RecognitionResult, error) {
return s.sttService.getRecognitionText(operationID) operation, err := s.sttService.checkOperationStatus(ctx, operationID)
}
func (s *YandexAudioRecognizerService) CheckRecognitionStatus(operationID string) (*entity.RecognitionResult, error) {
operation, err := s.sttService.checkOperationStatus(operationID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -0,0 +1,38 @@
package yandex
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Принятие операции распознавания защищено от отмены: остановка сервиса не
// должна обрывать вызов, который уже мог начать стоить денег и чей результат
// нельзя переспросить. Проверяется само средство защиты — проводка к нему
// оракула не имеет: клиент SpeechKit подставить нечем, а прогон на реальных
// ключах запрещён (CLAUDE.md, «Запреты»).
func TestProtectedContextSurvivesParentCancel(t *testing.T) {
parent, cancel := context.WithCancel(t.Context())
protected, release := protectFromCancel(parent, time.Minute)
defer release()
cancel()
require.Error(t, parent.Err(), "родитель отменён — иначе проверка судит не то")
assert.NoError(t, protected.Err(), "защищённый вызов пережил отмену родителя")
}
// Защита не бессрочна: у вызова свой предел, иначе остановка ждала бы вечно.
func TestProtectedContextKeepsItsOwnDeadline(t *testing.T) {
protected, release := protectFromCancel(t.Context(), time.Minute)
defer release()
deadline, ok := protected.Deadline()
require.True(t, ok, "у защищённого вызова обязан быть свой предел")
assert.WithinDuration(t, time.Now().Add(time.Minute), deadline, 5*time.Second)
}
+34 -2
View File
@@ -12,6 +12,7 @@ import (
"github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go" "github.com/aws/smithy-go"
) )
@@ -67,8 +68,8 @@ func newYandexS3Service(cfg s3Config) (*yandexS3Service, error) {
}, nil }, nil
} }
func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error { func (s *yandexS3Service) uploadFile(ctx context.Context, file io.Reader, fileName string) error {
_, err := s.uploader.Upload(context.Background(), &s3.PutObjectInput{ _, err := s.uploader.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucketName), Bucket: aws.String(s.bucketName),
Key: aws.String(fileName), Key: aws.String(fileName),
Body: file, Body: file,
@@ -89,6 +90,37 @@ func (s *yandexS3Service) uploadFile(file io.Reader, fileName string) error {
return nil return nil
} }
// objectExists отвечает, лежит ли объект нужного размера.
//
// По нему шаг решает, повторять ли заливку: повтор её бесплатен, но дорог по
// времени на многочасовой записи. Сверка идёт по присутствию и длине, а не по
// отпечатку содержимого: признак целостности у составного объекта не равен
// отпечатку, и сверка хешем расходилась бы на всякой большой записи.
func (s *yandexS3Service) objectExists(ctx context.Context, objectKey string, size int64) (bool, error) {
out, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(objectKey),
})
if err != nil {
var notFound *s3types.NotFound
if errors.As(err, &notFound) {
return false, nil
}
// Отказ SDK несёт полный URL объекта, а он — ключ к чужому аудио: наружу
// идёт класс отказа и только он.
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
if apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey" {
return false, nil
}
return false, fmt.Errorf("failed to head object in S3: %s", apiErr.ErrorCode())
}
return false, errors.New("failed to head object in S3")
}
return out.ContentLength != nil && *out.ContentLength == size, nil
}
func (s *yandexS3Service) fileUrl(fileName string) string { func (s *yandexS3Service) fileUrl(fileName string) string {
endpoint := strings.TrimRight(s.endpoint, "/") endpoint := strings.TrimRight(s.endpoint, "/")
return fmt.Sprintf("%s/%s/%s", endpoint, s.bucketName, fileName) return fmt.Sprintf("%s/%s/%s", endpoint, s.bucketName, fileName)
+119 -26
View File
@@ -2,16 +2,20 @@ package yandex
import ( import (
"context" "context"
"encoding/binary"
"errors" "errors"
"fmt" "fmt"
"strings" "io"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/protobuf/proto"
stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3" stt "github.com/yandex-cloud/go-genproto/yandex/cloud/ai/stt/v3"
"github.com/yandex-cloud/go-genproto/yandex/cloud/operation" "github.com/yandex-cloud/go-genproto/yandex/cloud/operation"
"git.vakhrushev.me/av/transcriber/internal/entity"
) )
const ( const (
@@ -93,9 +97,7 @@ func (s *speechKitService) Close() error {
} }
// recognizeFileFromS3 запускает асинхронное распознавание файла из S3 // recognizeFileFromS3 запускает асинхронное распознавание файла из S3
func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) { func (s *speechKitService) recognizeFileFromS3(ctx context.Context, s3URI string) (string, error) {
ctx := context.Background()
// Добавляем авторизацию и folder_id в контекст // Добавляем авторизацию и folder_id в контекст
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey) ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID) ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
@@ -135,11 +137,13 @@ func (s *speechKitService) recognizeFileFromS3(s3URI string) (string, error) {
return op.Id, nil return op.Id, nil
} }
// GetRecognitionResult получает результат распознавания по ID операции // fetchRecognition забирает результат операции целиком и отдаёт его доменным,
func (s *speechKitService) getRecognitionText(operationID string) (string, error) { // вместе с сырым ответом на хранение.
ctx := context.Background() //
// Ответ сохраняется потому, что **результат операции у провайдера не
// Добавляем авторизацию и folder_id в контекст // переспрашивается**: связь реплики с говорящим сервис строить пока не умеет, и
// когда научится, архив пересчитается из сохранённого без единого рубля.
func (s *speechKitService) fetchRecognition(ctx context.Context, operationID string) (*entity.RecognitionOutcome, error) {
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey) ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID) ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
@@ -149,36 +153,36 @@ func (s *speechKitService) getRecognitionText(operationID string) (string, error
stream, err := s.sttClient.GetRecognition(ctx, req) stream, err := s.sttClient.GetRecognition(ctx, req)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get recognition stream: %w", err) return nil, fmt.Errorf("failed to get recognition stream: %w", err)
} }
var sb strings.Builder var responses []*stt.StreamingResponse
for { for {
resp, err := stream.Recv() resp, err := stream.Recv()
if err != nil { if err != nil {
if err.Error() == "EOF" { // Конец потока библиотека отдаёт ровно `io.EOF`. Прежде он узнавался
// сравнением текста сообщения: так же выглядел бы и настоящий отказ
// с текстом «EOF», и распознавание молча вернуло бы половину текста.
if errors.Is(err, io.EOF) {
break break
} }
return "", fmt.Errorf("failed to receive recognition response: %w", err) return nil, fmt.Errorf("failed to receive recognition response: %w", err)
}
if refinement := resp.GetFinalRefinement(); refinement != nil {
if text := refinement.GetNormalizedText(); text != nil {
for _, alt := range text.Alternatives {
sb.WriteString(alt.Text)
sb.WriteString(" ")
}
}
} }
responses = append(responses, resp)
} }
return sb.String(), nil raw, err := encodeResponses(responses)
if err != nil {
return nil, err
}
outcome := outcomeFromResponses(responses)
outcome.Raw = raw
return outcome, nil
} }
// checkOperationStatus проверяет статус операции распознавания // checkOperationStatus проверяет статус операции распознавания
func (s *speechKitService) checkOperationStatus(operationID string) (*operation.Operation, error) { func (s *speechKitService) checkOperationStatus(ctx context.Context, operationID string) (*operation.Operation, error) {
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey) ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Api-Key "+s.apiKey)
ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID) ctx = metadata.AppendToOutgoingContext(ctx, "x-folder-id", s.folderID)
@@ -192,3 +196,92 @@ func (s *speechKitService) checkOperationStatus(operationID string) (*operation.
return op, nil return op, nil
} }
// encodeResponses укладывает ответ провайдера целиком, в том виде, в каком он
// пришёл: сообщения потока подряд, каждое со своей длиной впереди.
//
// Форма **двоичная**, а не текстовая, и это несущее решение. Текстовое
// представление собирается по нашей скомпилированной схеме и молча выбрасывает
// поля, которых в ней нет, — а провайдер добавляет их без предупреждения.
// Двоичная форма неизвестные поля переносит: они переживают запись и чтение и
// станут читаемыми, когда мы обновим схему. Ради этого архив и заводился —
// результат операции у провайдера не переспрашивается, и повторное
// распознавание стоит денег.
//
// Цена названа прямо: сохранённое не читается глазами и не разбирается ничем,
// кроме нашего же кода.
func encodeResponses(responses []*stt.StreamingResponse) ([]byte, error) {
var raw []byte
for _, resp := range responses {
encoded, err := proto.Marshal(resp)
if err != nil {
// Текст расшифровки наружу не выходит даже отказом: сообщение
// провайдера несёт её целиком.
return nil, errors.New("failed to encode provider response")
}
raw = binary.AppendUvarint(raw, uint64(len(encoded)))
raw = append(raw, encoded...)
}
return raw, nil
}
// decodeResponses читает сохранённый ответ провайдера обратно.
func decodeResponses(raw []byte) ([]*stt.StreamingResponse, error) {
var responses []*stt.StreamingResponse
for len(raw) > 0 {
size, read := binary.Uvarint(raw)
if read <= 0 || uint64(len(raw)-read) < size {
return nil, errors.New("stored provider payload is truncated")
}
raw = raw[read:]
var resp stt.StreamingResponse
if err := proto.Unmarshal(raw[:size], &resp); err != nil {
return nil, errors.New("failed to decode provider response")
}
responses = append(responses, &resp)
raw = raw[size:]
}
return responses, nil
}
// outcomeFromResponses строит доменный результат: реплики со временем и плоский
// текст. Формата провайдера отсюда наружу не выходит ничего.
//
// Говорящие не размечаются: связь реплики с разбором говорящего у провайдера не
// выяснена. Структура при этом строится из сохранённого ответа, поэтому разметка
// станет возможной без повторной оплаты.
func outcomeFromResponses(responses []*stt.StreamingResponse) *entity.RecognitionOutcome {
outcome := &entity.RecognitionOutcome{}
var plain []byte
for _, resp := range responses {
refinement := resp.GetFinalRefinement()
if refinement == nil {
continue
}
text := refinement.GetNormalizedText()
if text == nil {
continue
}
for _, alt := range text.GetAlternatives() {
if alt.GetText() == "" {
continue
}
outcome.Replicas = append(outcome.Replicas, entity.Replica{
StartMs: alt.GetStartTimeMs(),
EndMs: alt.GetEndTimeMs(),
Text: alt.GetText(),
})
if len(plain) > 0 {
plain = append(plain, ' ')
}
plain = append(plain, alt.GetText()...)
}
}
outcome.PlainText = string(plain)
return outcome
}
-56
View File
@@ -1,56 +0,0 @@
// Package pocketbase — хранилище задач и файлов поверх встроенной PocketBase.
//
// Приложение поднимается библиотекой, а не её набором команд: разбор флагов и
// мягкая остановка остаются нашими, а ключ `-c config.toml` — объявленный
// контракт запуска.
package pocketbase
import (
"fmt"
pb "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
// Имена коллекций. Они же — часть пути к файлу в раскладке хранилища и часть
// адреса ссылки на него, поэтому меняются только новым шагом схемы.
const (
FilesCollection = "files"
JobsCollection = "transcribe_jobs"
)
// New создаёт приложение хранилища на заданном каталоге данных и приводит его в
// рабочее состояние: открывает базу, читает настройки и накатывает непринятые
// шаги схемы.
//
// Схема накатывается **здесь**, а не оставляется серверу, хотя тот и гоняет
// непринятые шаги сам. Причина в порядке: воркеры стартуют раньше сервера, и на
// чистом каталоге их первые опросы приходились бы на несуществующую таблицу —
// отказ в журнале и в счётчике на каждую секунду до конца накатки.
func New(dataDir string) (*pb.PocketBase, error) {
app := pb.NewWithConfig(pb.Config{
DefaultDataDir: dataDir,
HideStartBanner: true,
})
if err := app.Bootstrap(); err != nil {
return nil, fmt.Errorf("failed to bootstrap storage: %w", err)
}
if err := app.RunAllMigrations(); err != nil {
return nil, fmt.Errorf("failed to apply storage schema: %w", err)
}
return app, nil
}
// MustFindCollection достаёт коллекцию по имени. Отсутствие коллекции здесь —
// не отказ окружения, а несделанный шаг схемы: сервис до этой точки не доходит,
// потому что Serve накатывает схему прежде, чем поднять сервер.
func findCollection(app core.App, name string) (*core.Collection, error) {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
return nil, fmt.Errorf("failed to find collection %s: %w", name, err)
}
return collection, nil
}
@@ -1,269 +0,0 @@
package pocketbase
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// workFile — рабочая копия файла на диске. Живёт во временном каталоге
// системы, а не в каталоге данных: последний смонтирован на сервере, и
// временному там не место.
type workFile struct {
path string
}
func (w *workFile) Path() string { return w.path }
func (w *workFile) Size() (int64, error) {
info, err := os.Stat(w.path)
if err != nil {
return 0, fmt.Errorf("failed to stat work file: %w", err)
}
return info.Size(), nil
}
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
// до его создания, и повторный Close тоже законен.
func (w *workFile) Close() error {
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove work file: %w", err)
}
return nil
}
type FileRepository struct {
app core.App
}
func NewFileRepository(app core.App) *FileRepository {
return &FileRepository{app: app}
}
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
func newWorkFile(ext string) (*workFile, error) {
f, err := os.CreateTemp("", "transcriber-*"+ext)
if err != nil {
return nil, fmt.Errorf("failed to create work file: %w", err)
}
path := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("failed to close work file: %w", err)
}
return &workFile{path: path}, nil
}
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
return newWorkFile(ext)
}
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
work, err := newWorkFile(ext)
if err != nil {
return nil, err
}
if err := writeTo(work.path, content); err != nil {
// Отказ уборки не подменяет отказ записи, но и не теряется.
return nil, errors.Join(err, work.Close())
}
return work, nil
}
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
record, err := repo.app.FindRecordById(FilesCollection, fileID)
if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
}
name := firstFileName(record)
if name == "" {
return nil, fmt.Errorf("file %s has no content in storage", fileID)
}
work, err := newWorkFile(filepath.Ext(name))
if err != nil {
return nil, err
}
src, err := repo.openStored(record, name)
if err != nil {
return nil, errors.Join(err, work.Close())
}
defer src.Close()
if err := writeTo(work.path, src); err != nil {
return nil, errors.Join(err, work.Close())
}
return work, nil
}
// CreateLocal кладёт рабочую копию в хранилище. Имя задаём мы: умолчание
// библиотеки строит его из имени, данного отправителем, а имя отправителя в
// хранилище не попадает — путь к файлу читается в журнале, и инвариант
// приватности этого не допускает. Свой суффикс хранилище допишет само.
func (repo *FileRepository) CreateLocal(name string, work contract.WorkFile) (*entity.File, error) {
collection, err := findCollection(repo.app, FilesCollection)
if err != nil {
return nil, err
}
stored, err := filesystem.NewFileFromPath(work.Path())
if err != nil {
return nil, fmt.Errorf("failed to read work file: %w", err)
}
stored.Name = name
record := core.NewRecord(collection)
record.Set("file", stored)
record.Set("location", entity.LocationLocal)
record.Set("size", stored.Size)
if err := repo.app.Save(record); err != nil {
// Отказ укладки называет имя файла — то самое, из которого строится
// ссылка на скачивание. В цепочку оно не идёт по той же причине, что и
// ключ при чтении.
return nil, errors.New("failed to store file")
}
return recordToFile(record), nil
}
func (repo *FileRepository) CreateRemote(objectKey string, size int64) (*entity.File, error) {
collection, err := findCollection(repo.app, FilesCollection)
if err != nil {
return nil, err
}
record := core.NewRecord(collection)
record.Set("location", entity.LocationS3)
record.Set("object_key", objectKey)
record.Set("size", size)
if err := repo.app.Save(record); err != nil {
return nil, fmt.Errorf("failed to store remote file record: %w", err)
}
return recordToFile(record), nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
record, err := repo.app.FindRecordById(FilesCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
return recordToFile(record), nil
}
func (repo *FileRepository) Open(fileID string) (io.ReadCloser, error) {
record, err := repo.app.FindRecordById(FilesCollection, fileID)
if err != nil {
return nil, fmt.Errorf("failed to find file %s: %w", fileID, err)
}
name := firstFileName(record)
if name == "" {
return nil, fmt.Errorf("file %s has no content in storage", fileID)
}
return repo.openStored(record, name)
}
// openStored открывает содержимое файла в хранилище потоком.
func (repo *FileRepository) openStored(record *core.Record, name string) (io.ReadCloser, error) {
fsys, err := repo.app.NewFilesystem()
if err != nil {
return nil, fmt.Errorf("failed to open storage filesystem: %w", err)
}
reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + name)
if err != nil {
// Отказ хранилища несёт ключ файла целиком, а ключ — последняя часть
// ссылки `/api/files/...`, по которой запись скачивают. Наружу отдаётся
// идентификатор записи, и только он: цепочка `%w` уехала бы в журнал и
// стала бы там бессрочным ключом к чужому аудио.
return nil, errors.Join(
fmt.Errorf("failed to read stored file of record %s", record.Id),
fsys.Close(),
)
}
return &storedReader{reader: reader, fsys: fsys}, nil
}
// storedReader держит открытой файловую систему хранилища на всё время чтения:
// закрытая раньше времени, она обрывает поток на середине записи.
type storedReader struct {
reader io.ReadCloser
fsys io.Closer
}
func (r *storedReader) Read(p []byte) (int, error) { return r.reader.Read(p) }
func (r *storedReader) Close() error {
readerErr := r.reader.Close()
fsysErr := r.fsys.Close()
switch {
case readerErr != nil && fsysErr != nil:
return errors.New("failed to close stored file and its filesystem")
case readerErr != nil:
return errors.New("failed to close stored file")
default:
return fsysErr
}
}
// writeTo переливает содержимое в файл потоком. В память запись целиком не
// читается: расчётный потолок — шесть часов.
func writeTo(path string, content io.Reader) error {
dst, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to open work file: %w", err)
}
if _, err := io.Copy(dst, content); err != nil {
_ = dst.Close()
return fmt.Errorf("failed to write work file: %w", err)
}
if err := dst.Close(); err != nil {
return fmt.Errorf("failed to close work file: %w", err)
}
return nil
}
func firstFileName(record *core.Record) string {
names := record.GetStringSlice("file")
if len(names) == 0 {
return ""
}
return names[0]
}
func recordToFile(record *core.Record) *entity.File {
name := firstFileName(record)
if name == "" {
name = record.GetString("object_key")
}
return &entity.File{
Id: record.Id,
Location: record.GetString("location"),
FileName: name,
Size: int64(record.GetInt("size")),
CreatedAt: record.GetDateTime("created").Time(),
}
}
@@ -1,38 +0,0 @@
package pocketbase
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Потолок размера у поля файла задан числом, а не нулём: нулём библиотека читает
// собственное умолчание в 5 МиБ, и на нём отвергалось бы всё длиннее примерно
// пяти минут — то есть штатная запись сервиса. Проверка судит запись, которая
// заведомо больше этого умолчания: обновление библиотеки, вернувшее умолчание,
// иначе прошло бы молча.
func TestCreateLocal_AcceptsRecordLargerThanLibraryDefault(t *testing.T) {
app := newTestApp(t)
repo := NewFileRepository(app)
const libraryDefault = 5 << 20
// Ровно на байт больше умолчания: проверка судит границу, а не пропускную
// способность — лишние мегабайты стоили бы секунд на каждом прогоне.
work, err := repo.Stage(".mp3", strings.NewReader(strings.Repeat("a", libraryDefault+1)))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
size, err := work.Size()
require.NoError(t, err)
require.Greater(t, size, int64(libraryDefault), "запись заведомо больше умолчания библиотеки")
file, err := repo.CreateLocal("big.mp3", work)
require.NoError(t, err, "запись длиннее умолчания библиотеки ложится в хранилище")
assert.Equal(t, size, file.Size)
assert.Greater(t, entity.MaxRecordSize, size, "объявленный потолок выше проверяемого размера")
}
@@ -1,203 +0,0 @@
package pocketbase
import (
"database/sql"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Отображение задачи в запись коллекции и обратно живёт одним местом. Прежде
// список колонок был переписан четырежды — в каждом запросе своего слоя, — и
// расхождение проявлялось как потерянное при сохранении поле.
// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается
// конвейер. Поля, которые он не меняет никогда — куда отвечать отправителю и
// каким входом пришла запись, — не трогаются вовсе.
//
// Разрез нужен потому, что шаг держит задачу снимком с момента захвата и до
// своего сохранения, а это до восьми часов. Всё, что владелец правил в панели за
// это время, безусловная запись снимка стёрла бы молча: ни строки в журнале, ни
// отказа в панели — владелец видел бы успешное сохранение и был бы уверен, что
// правка на месте.
func applyOwnedByPipeline(record *core.Record, job *entity.TranscribeJob) {
record.Set("state", job.State)
record.Set("file", derefString(job.FileID))
record.Set("error_text", derefString(job.ErrorText))
record.Set("acquisition_id", derefString(job.AcquisitionID))
record.Set("acquire_time", dateOrEmpty(job.AcquireTime))
record.Set("delay_time", dateOrEmpty(job.DelayTime))
record.Set("attempts", job.Attempts)
record.Set("recognition_op_id", derefString(job.RecognitionOpID))
record.Set("transcription_text", derefString(job.TranscriptionText))
}
// applyToRecord кладёт задачу в запись целиком — это заведение, и спорить за
// поля здесь не с кем.
func applyToRecord(record *core.Record, job *entity.TranscribeJob) {
applyOwnedByPipeline(record, job)
record.Set("source", job.Source)
record.Set("tg_chat_id", derefInt64(job.TgChatId))
record.Set("tg_reply_message_id", derefInt(job.TgReplyMessageId))
}
func recordToJob(record *core.Record) *entity.TranscribeJob {
return &entity.TranscribeJob{
Id: record.Id,
State: record.GetString("state"),
Source: record.GetString("source"),
FileID: nilIfEmpty(record.GetString("file")),
ErrorText: nilIfEmpty(record.GetString("error_text")),
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
AcquireTime: timeOrNil(record.GetDateTime("acquire_time")),
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
Attempts: record.GetInt("attempts"),
RecognitionOpID: nilIfEmpty(record.GetString("recognition_op_id")),
TranscriptionText: nilIfEmpty(record.GetString("transcription_text")),
TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))),
TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")),
CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(),
}
}
// acquiredRow — задача, прочитанная сырым запросом захвата. Колонки читаются
// именно так, потому что запрос идёт мимо записей коллекции; связь с их
// перечнем держит константа acquireColumns и тест захвата, читающий задачу
// целиком.
type acquiredRow struct {
Id string `db:"id"`
State string `db:"state"`
Source string `db:"source"`
FileID sql.NullString `db:"file"`
ErrorText sql.NullString `db:"error_text"`
AcquisitionID sql.NullString `db:"acquisition_id"`
AcquireTime sql.NullString `db:"acquire_time"`
DelayTime sql.NullString `db:"delay_time"`
Attempts int `db:"attempts"`
RecognitionOpID sql.NullString `db:"recognition_op_id"`
TranscriptionText sql.NullString `db:"transcription_text"`
TgChatId sql.NullInt64 `db:"tg_chat_id"`
TgReplyMessageId sql.NullInt64 `db:"tg_reply_message_id"`
Created sql.NullString `db:"created"`
Updated sql.NullString `db:"updated"`
}
func (r *acquiredRow) toJob() *entity.TranscribeJob {
job := &entity.TranscribeJob{
Id: r.Id,
State: r.State,
Source: r.Source,
FileID: nullToPtr(r.FileID),
ErrorText: nullToPtr(r.ErrorText),
AcquisitionID: nullToPtr(r.AcquisitionID),
AcquireTime: parseTimeOrNil(r.AcquireTime),
DelayTime: parseTimeOrNil(r.DelayTime),
Attempts: r.Attempts,
RecognitionOpID: nullToPtr(r.RecognitionOpID),
TranscriptionText: nullToPtr(r.TranscriptionText),
}
if r.TgChatId.Valid && r.TgChatId.Int64 != 0 {
chatId := r.TgChatId.Int64
job.TgChatId = &chatId
}
if r.TgReplyMessageId.Valid && r.TgReplyMessageId.Int64 != 0 {
msgId := int(r.TgReplyMessageId.Int64)
job.TgReplyMessageId = &msgId
}
if created := parseTimeOrNil(r.Created); created != nil {
job.CreatedAt = *created
}
if updated := parseTimeOrNil(r.Updated); updated != nil {
job.UpdatedAt = *updated
}
return job
}
func derefString(v *string) string {
if v == nil {
return ""
}
return *v
}
func derefInt64(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
func derefInt(v *int) int {
if v == nil {
return 0
}
return *v
}
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
// хранилище это пустая строка, и она же значит «времени нет».
func dateOrEmpty(v *time.Time) any {
if v == nil {
return ""
}
date, err := types.ParseDateTime(*v)
if err != nil {
return ""
}
return date
}
func nilIfEmpty(v string) *string {
if v == "" {
return nil
}
return &v
}
func timeOrNil(v types.DateTime) *time.Time {
if v.IsZero() {
return nil
}
t := v.Time()
return &t
}
func nilIfZero64(v int64) *int64 {
if v == 0 {
return nil
}
return &v
}
func nilIfZeroInt(v int) *int {
if v == 0 {
return nil
}
return &v
}
func nullToPtr(v sql.NullString) *string {
if !v.Valid || v.String == "" {
return nil
}
s := v.String
return &s
}
func parseTimeOrNil(v sql.NullString) *time.Time {
if !v.Valid || v.String == "" {
return nil
}
date, err := types.ParseDateTime(v.String)
if err != nil || date.IsZero() {
return nil
}
t := date.Time()
return &t
}
@@ -1,122 +0,0 @@
package pocketbase
import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Схема заводится версионированными шагами, и применённый шаг не переписывается
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
// применённое по имени файла шага.
//
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
// `apis.Serve` прежде, чем поднять сервер.
func init() {
migrations.Register(up202608110001, down202608110001, "202608110001_init.go")
}
func up202608110001(app core.App) error {
files := core.NewBaseCollection(FilesCollection)
files.Fields.Add(
// Сам файл. Защищённым поле не помечено намеренно: право прочитать
// запись даёт знание её идентификатора, и файл встаёт вровень с опросом
// готовности задачи, а не ниже.
//
// Потолок задан **числом**: нулём библиотека читает не «без предела», а
// своё умолчание в 5 МиБ, и на нём отваливалось бы всё длиннее пяти
// минут. Число выведено из расчётного потолка записи в шесть часов с
// запасом на видео; оно же стоит строкой в docs/database.md.
&core.FileField{Name: "file", MaxSelect: 1, MaxSize: entity.MaxRecordSize},
// Где лежит копия. Поле названо `location`, а не `storage`: последним
// словом зовут само хранилище, и третий смысл развёл бы одно слово по
// разным вещам.
&core.SelectField{
Name: "location",
Values: []string{entity.LocationLocal, entity.LocationS3},
MaxSelect: 1,
Required: true,
},
// Ключ объекта во внешнем хранилище; у местной копии пуст.
&core.TextField{Name: "object_key"},
&core.NumberField{Name: "size", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
if err := app.Save(files); err != nil {
return err
}
jobs := core.NewBaseCollection(JobsCollection)
jobs.Fields.Add(
// Перечень состояний закрыт схемой: задача, заведённая в панели руками,
// не должна попасть в выборку с состоянием, которого конвейер не знает.
&core.SelectField{
Name: "state",
Values: []string{
entity.StateCreated,
entity.StateConverted,
entity.StateTranscribe,
entity.StateDone,
entity.StateFailed,
entity.StateDead,
},
MaxSelect: 1,
Required: true,
},
&core.SelectField{
Name: "source",
Values: []string{entity.SourceUnknown, entity.SourceApi, entity.SourceTelegram},
MaxSelect: 1,
Required: true,
},
// Текущий файл задачи: шаг конвейера переставляет ссылку на свой
// результат.
// Обязательна: задача без записи не может пройти ни одного шага, и
// заведённая в панели руками она дошла бы до шага только затем, чтобы
// отказать. Компилятор этого не держит — держит схема.
&core.RelationField{
Name: "file",
CollectionId: files.Id,
MaxSelect: 1,
Required: true,
},
&core.TextField{Name: "error_text"},
&core.TextField{Name: "acquisition_id"},
&core.DateField{Name: "acquire_time"},
&core.DateField{Name: "delay_time"},
// Число попыток: растёт при каждом захвате, обнуляется на шаге,
// завершившемся без отказа.
&core.NumberField{Name: "attempts", OnlyInt: true, Min: ptr(0.0)},
&core.TextField{Name: "recognition_op_id"},
&core.EditorField{Name: "transcription_text"},
&core.NumberField{Name: "tg_chat_id", OnlyInt: true},
&core.NumberField{Name: "tg_reply_message_id", OnlyInt: true},
&core.AutodateField{Name: "created", OnCreate: true},
&core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true},
)
// Выборка воркера идёт по состоянию, паузе и сроку захвата — индекс по
// состоянию снимает полный перебор, который был у прежней таблицы.
jobs.AddIndex("idx_transcribe_jobs_state", false, "state", "")
return app.Save(jobs)
}
func down202608110001(app core.App) error {
// Порядок обратный порядку заведения: задачи ссылаются на файлы.
for _, name := range []string{JobsCollection, FilesCollection} {
collection, err := app.FindCollectionByNameOrId(name)
if err != nil {
continue
}
if err := app.Delete(collection); err != nil {
return err
}
}
return nil
}
func ptr[T any](v T) *T { return &v }
-36
View File
@@ -1,36 +0,0 @@
package pocketbase
import (
"github.com/pocketbase/pocketbase/core"
)
// BindPanelRules подчиняет правку задачи в панели тем же правилам перехода, что
// и правку из кода.
//
// Панель — вход в задачу наравне с конвейером, а не окно просмотра: ради правки
// она и покупалась, мёртвая задача оживляется сменой состояния. Но правка полем
// идёт мимо кода, который чистит служебные поля прошлого состояния, и владелец,
// «вернувший задачу в работу», получил бы задачу с прежним признаком захвата
// (захвату она не выдастся до конца срока) и с числом попыток на пределе (умрёт
// от первого же отказа). Узнать об этом ему неоткуда.
//
// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное
// событие не различает, кто пишет, и срабатывало бы на каждом переходе
// конвейера: тогда задержка, поставленная шагом вместе со сменой состояния,
// стиралась бы тем же сохранением, а число попыток мёртвой задачи — которое
// переход хранит намеренно — приходило бы владельцу нулём.
func BindPanelRules(app core.App) {
app.OnRecordUpdateRequest(JobsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
original := e.Record.Original()
if original == nil || original.GetString("state") == e.Record.GetString("state") {
return e.Next()
}
e.Record.Set("acquisition_id", "")
e.Record.Set("acquire_time", "")
e.Record.Set("delay_time", "")
e.Record.Set("attempts", 0)
return e.Next()
})
}
@@ -1,147 +0,0 @@
package pocketbase
import (
"database/sql"
"errors"
"fmt"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
type TranscriptJobRepository struct {
app core.App
}
func NewTranscriptJobRepository(app core.App) *TranscriptJobRepository {
return &TranscriptJobRepository{app: app}
}
func (repo *TranscriptJobRepository) Create(job *entity.TranscribeJob) error {
collection, err := findCollection(repo.app, JobsCollection)
if err != nil {
return err
}
record := core.NewRecord(collection)
if job.Id != "" {
record.Id = job.Id
}
applyToRecord(record, job)
if err := repo.app.Save(record); err != nil {
return fmt.Errorf("failed to insert transcribe job: %w", err)
}
job.Id = record.Id
job.CreatedAt = record.GetDateTime("created").Time()
job.UpdatedAt = record.GetDateTime("updated").Time()
return nil
}
// Save сохраняет задачу, захват которой держит holder. Проверка и запись идут
// одной транзакцией: шаг, потерявший задачу за время работы, получает
// LostAcquisitionError и результата не пишет.
func (repo *TranscriptJobRepository) Save(job *entity.TranscribeJob, holder string) error {
err := repo.app.RunInTransaction(func(txApp core.App) error {
record, err := txApp.FindRecordById(JobsCollection, job.Id)
if err != nil {
return fmt.Errorf("failed to find transcribe job: %w", err)
}
if holder != "" && record.GetString("acquisition_id") != holder {
return &contract.LostAcquisitionError{JobID: job.Id}
}
// Кладём только то, чем распоряжается конвейер: правку владельца в
// панели снимок шага стирать не должен.
applyOwnedByPipeline(record, job)
if err := txApp.Save(record); err != nil {
return fmt.Errorf("failed to update transcribe job: %w", err)
}
job.UpdatedAt = record.GetDateTime("updated").Time()
return nil
})
if err != nil {
return err
}
return nil
}
func (repo *TranscriptJobRepository) GetByID(id string) (*entity.TranscribeJob, error) {
record, err := repo.app.FindRecordById(JobsCollection, id)
if err != nil {
return nil, fmt.Errorf("failed to get transcribe job: %w", err)
}
return recordToJob(record), nil
}
// Колонки, которые читает захват. Список нужен запросу дословно: `RETURNING *`
// отдал бы и порядок, зависящий от схемы.
const acquireColumns = `id, state, source, file, error_text, acquisition_id, ` +
`acquire_time, delay_time, attempts, recognition_op_id, transcription_text, ` +
`tg_chat_id, tg_reply_message_id, created, updated`
// FindAndAcquire забирает задачу одним неделимым шагом: выбор подходящей и
// пометка её захваченной идут вместе, и захваченная возвращается тем же
// запросом. Двум вызывающим, пришедшим за одним состоянием, запись достаётся
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
//
// Запрос идёт сырым, мимо записей коллекции: `app.DB()` направляет всё, кроме
// выборок, в пул с единственным соединением, и захваты выстраиваются в очередь.
// Хуки коллекции на нём не срабатывают, поэтому время изменения проставляет сам
// запрос.
//
// Все времена кладутся и сравниваются тем же видом, каким хранилище пишет свои
// `created`/`updated`: сравнение строк побайтово, и вид, разошедшийся хоть
// разделителем, обратил бы условие срока в постоянную истину или постоянную
// ложь — молча.
func (repo *TranscriptJobRepository) FindAndAcquire(state, acquisitionId string, rottingTime time.Time) (*entity.TranscribeJob, error) {
now := types.NowDateTime()
query := repo.app.DB().NewQuery(`
UPDATE {{` + JobsCollection + `}}
SET acquisition_id = {:acquisition_id},
acquire_time = {:now},
attempts = attempts + 1,
updated = {:now}
WHERE id = (
SELECT id FROM {{` + JobsCollection + `}}
WHERE state = {:state}
AND (delay_time = '' OR delay_time IS NULL OR delay_time < {:now})
AND (acquisition_id = '' OR acquisition_id IS NULL OR acquire_time < {:rotting})
ORDER BY created, id
LIMIT 1
)
RETURNING ` + acquireColumns)
rotting, err := types.ParseDateTime(rottingTime)
if err != nil {
return nil, fmt.Errorf("failed to parse rotting time: %w", err)
}
query.Bind(dbx.Params{
"acquisition_id": acquisitionId,
"now": now.String(),
"state": state,
"rotting": rotting.String(),
})
var row acquiredRow
if err := query.One(&row); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{State: state, Message: "appropriate job not found"}
}
return nil, fmt.Errorf("failed to aquire job with state %s: %w", state, err)
}
return row.toJob(), nil
}
@@ -1,410 +0,0 @@
package pocketbase
import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// newTestApp поднимает хранилище на пустом каталоге и накатывает схему — тем же
// путём, каким это делает сервис при старте.
func newTestApp(t *testing.T) core.App {
t.Helper()
app, err := New(t.TempDir())
require.NoError(t, err)
t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err)
}
})
return app
}
// newFile заводит запись о файле: ссылка на неё у задачи обязательна схемой.
func newFile(t *testing.T, app core.App) *entity.File {
t.Helper()
repo := NewFileRepository(app)
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
file, err := repo.CreateLocal("sample.mp3", work)
require.NoError(t, err)
return file
}
func newJob(t *testing.T, repo *TranscriptJobRepository, state string) *entity.TranscribeJob {
t.Helper()
file := newFile(t, repo.app)
job := &entity.TranscribeJob{State: state, Source: entity.SourceApi, FileID: &file.Id}
require.NoError(t, repo.Create(job))
return job
}
// Захват неделим: выбор подходящей задачи и пометка её захваченной идут вместе.
// Двум вызывающим, пришедшим за одним состоянием разом, запись достаётся
// одному — на этом стоит инвариант «Принятая запись не теряется молча».
func TestFindAndAcquire_OnlyOneOfThreeGetsTheJob(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
const racers = 3
var (
wg sync.WaitGroup
mu sync.Mutex
got []*entity.TranscribeJob
notFound int
)
start := make(chan struct{})
for i := 0; i < racers; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
<-start
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
mu.Lock()
defer mu.Unlock()
if err != nil {
var missing *contract.JobNotFoundError
if assert.ErrorAs(t, err, &missing) {
notFound++
}
return
}
got = append(got, acquired)
}(i)
}
close(start)
wg.Wait()
require.Len(t, got, 1, "запись получает ровно один из трёх захватов")
assert.Equal(t, job.Id, got[0].Id)
assert.Equal(t, racers-1, notFound, "остальные получают признак «работы нет»")
}
// Захваченная задача второй раз не выдаётся, пока срок захвата не истёк.
func TestFindAndAcquire_AcquiredJobIsNotHandedOutAgain(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
newJob(t, repo, entity.StateCreated)
first, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
require.NoError(t, err)
require.NotNil(t, first)
_, err = repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing, "захваченная задача второму не выдаётся")
}
// Захват протухает, и задача достаётся снова. Время захвата кладётся **не**
// нашим кодом, а тем же путём, что и `created`: проверка, кладущая его своим
// форматом, была бы зелена и тогда, когда сравнение вида сломано.
func TestFindAndAcquire_RottenAcquisitionIsHandedOutAgain(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
_, err := repo.FindAndAcquire(entity.StateCreated, "first", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Задним числом — записью коллекции, то есть тем же слоем, который пишет
// собственные времена хранилища.
record, err := app.FindRecordById(JobsCollection, job.Id)
require.NoError(t, err)
record.Set("acquire_time", types.NowDateTime().Add(-2*time.Hour))
require.NoError(t, app.Save(record))
again, err := repo.FindAndAcquire(entity.StateCreated, "second", time.Now().Add(-time.Hour))
require.NoError(t, err, "протухший захват не мешает выдать задачу следующему")
assert.Equal(t, job.Id, again.Id)
}
// Пауза держит задачу от выдачи, пока не кончится.
func TestFindAndAcquire_DelayedJobIsNotHandedOut(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
delay := time.Now().Add(time.Hour)
job.DelayTime = &delay
require.NoError(t, repo.Save(job, ""))
_, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
var missing *contract.JobNotFoundError
assert.ErrorAs(t, err, &missing, "задача не выдаётся, пока пауза не кончилась")
}
// Число попыток растёт при каждом захвате: только так попытка засчитывается и
// задаче, брошенной вместе с процессом.
func TestFindAndAcquire_AttemptsGrowOnEveryAcquisition(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
newJob(t, repo, entity.StateCreated)
for expected := 1; expected <= 3; expected++ {
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(time.Hour))
require.NoError(t, err)
assert.Equal(t, expected, acquired.Attempts)
}
}
// Захват отдаёт задачу целиком, а не только её ключ: сырой запрос идёт мимо
// записей коллекции, и расхождение перечня колонок иначе проявилось бы как
// потерянное поле.
func TestFindAndAcquire_ReturnsWholeJob(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
chatId := int64(4242)
replyId := 17
opId := "operation-id"
text := "расшифровка"
file := newFile(t, app)
job := &entity.TranscribeJob{
State: entity.StateTranscribe,
Source: entity.SourceTelegram,
FileID: &file.Id,
TgChatId: &chatId,
TgReplyMessageId: &replyId,
RecognitionOpID: &opId,
TranscriptionText: &text,
}
require.NoError(t, repo.Create(job))
acquired, err := repo.FindAndAcquire(entity.StateTranscribe, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
assert.Equal(t, job.Id, acquired.Id)
assert.Equal(t, entity.StateTranscribe, acquired.State)
assert.Equal(t, entity.SourceTelegram, acquired.Source)
require.NotNil(t, acquired.TgChatId)
assert.Equal(t, chatId, *acquired.TgChatId)
require.NotNil(t, acquired.TgReplyMessageId)
assert.Equal(t, replyId, *acquired.TgReplyMessageId)
require.NotNil(t, acquired.RecognitionOpID)
assert.Equal(t, opId, *acquired.RecognitionOpID)
require.NotNil(t, acquired.TranscriptionText)
assert.Equal(t, text, *acquired.TranscriptionText)
assert.False(t, acquired.CreatedAt.IsZero(), "время заведения доехало")
}
// Шаг, потерявший захват за время работы, результата не пишет: иначе два
// воркера пишут в одну задачу по очереди, а отправитель получает два ответа.
func TestSave_RefusesWriteFromLostAcquisition(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
newJob(t, repo, entity.StateCreated)
mine, err := repo.FindAndAcquire(entity.StateCreated, "mine", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Задача досталась другому, пока шаг работал.
record, err := app.FindRecordById(JobsCollection, mine.Id)
require.NoError(t, err)
record.Set("acquisition_id", "someone-else")
require.NoError(t, app.Save(record))
mine.MoveToState(entity.StateConverted)
err = repo.Save(mine, "mine")
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost)
// И состояние не поехало.
after, err := repo.GetByID(mine.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateCreated, after.State)
}
// Пустой держатель значит «задача не захватывалась» — так её сохраняет приём.
func TestSave_WithoutHolderWritesAnyway(t *testing.T) {
app := newTestApp(t)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
job.MoveToState(entity.StateConverted)
require.NoError(t, repo.Save(job, ""))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State)
}
// Правка состояния **запросом** — то есть из панели — чистит служебные поля
// прошлого состояния: те же, что чистит переход из кода. Иначе владелец,
// вернувший мёртвую задачу в работу, получил бы задачу, которая не выдаётся
// захвату и умирает от первого же отказа, и не узнал бы об этом.
func TestPanelRules_StateChangeByRequestClearsAcquisition(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateCreated)
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
require.NotNil(t, acquired.AcquisitionID)
record, err := app.FindRecordById(JobsCollection, job.Id)
require.NoError(t, err)
record.Set("attempts", 5)
record.Set("state", entity.StateDead)
require.NoError(t, app.Save(record))
// Владелец возвращает задачу в работу правкой состояния в панели — то есть
// запросом к записи, а не сохранением из кода.
patchRecord(t, app, job.Id, `{"state":"`+entity.StateCreated+`"}`)
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Nil(t, after.AcquisitionID, "признак захвата снят")
assert.Nil(t, after.AcquireTime, "время захвата снято")
assert.Nil(t, after.DelayTime, "пауза снята")
assert.Equal(t, 0, after.Attempts, "число попыток обнулено")
// И ближайший захват задачу выдаёт.
again, err := repo.FindAndAcquire(entity.StateCreated, "next", time.Now().Add(-time.Hour))
require.NoError(t, err)
assert.Equal(t, job.Id, again.Id)
}
// Обратная сторона того же правила, и она дороже: правила панели MUST не
// трогать записи, которые правит сам конвейер. Модельный хук их не различал, и
// пауза, поставленная шагом вместе со сменой состояния, стиралась тем же
// сохранением, а число попыток мёртвой задачи приходило владельцу нулём.
func TestPanelRules_DoNotTouchPipelineWrites(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
job := newJob(t, repo, entity.StateConverted)
acquired, err := repo.FindAndAcquire(entity.StateConverted, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Шаг ставит задержку опроса вместе со сменой состояния.
delay := time.Now().Add(10 * time.Second)
acquired.MoveToStateAndDelay(entity.StateTranscribe, &delay)
require.NoError(t, repo.Save(acquired, "holder"))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
require.NotNil(t, after.DelayTime, "задержка, поставленная шагом, пережила сохранение")
// Переход в «мертва» хранит число попыток намеренно: по нему владелец видит,
// сколько раз мы пробовали.
after.Attempts = 6
after.Die("attempts exhausted: 6")
require.NoError(t, repo.Save(after, ""))
dead, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateDead, dead.State)
assert.Equal(t, 6, dead.Attempts, "число попыток мёртвой задачи сохранено")
}
// patchRecord правит запись тем же путём, каким её правит панель: запросом к
// API от имени владельца.
func patchRecord(t *testing.T, app core.App, recordID, body string) {
t.Helper()
superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
require.NoError(t, err)
owner := core.NewRecord(superusers)
owner.Set("email", "owner@example.com")
owner.Set("password", "ownerpassword123")
require.NoError(t, app.Save(owner))
token, err := owner.NewStaticAuthToken(time.Hour)
require.NoError(t, err)
router, err := apis.NewRouter(app)
require.NoError(t, err)
mux, err := router.BuildMux()
require.NoError(t, err)
req := httptest.NewRequest(
http.MethodPatch,
"/api/collections/"+JobsCollection+"/records/"+recordID,
strings.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", token)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "правка записи владельцем: %s", w.Body.String())
}
// Правка владельца в панели переживает сохранение шага. Шаг держит задачу
// снимком с момента захвата и до своего сохранения — до восьми часов, — и
// безусловная запись снимка стёрла бы правку молча: ни строки в журнале, ни
// отказа в панели.
func TestSave_KeepsOwnerEditMadeWhileStepHeldTheJob(t *testing.T) {
app := newTestApp(t)
BindPanelRules(app)
repo := NewTranscriptJobRepository(app)
file := newFile(t, app)
chatId := int64(111)
job := &entity.TranscribeJob{
State: entity.StateCreated,
Source: entity.SourceTelegram,
FileID: &file.Id,
TgChatId: &chatId,
}
require.NoError(t, repo.Create(job))
// Шаг захватил задачу и работает.
acquired, err := repo.FindAndAcquire(entity.StateCreated, "holder", time.Now().Add(-time.Hour))
require.NoError(t, err)
// Владелец правит в панели поле, которого конвейер не касается.
patchRecord(t, app, job.Id, `{"tg_chat_id":999999}`)
// Шаг доработал и сохраняет свой снимок.
acquired.MoveToState(entity.StateConverted)
require.NoError(t, repo.Save(acquired, "holder"))
after, err := repo.GetByID(job.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateConverted, after.State, "шаг свой результат записал")
require.NotNil(t, after.TgChatId)
assert.Equal(t, int64(999999), *after.TgChatId, "правка владельца пережила сохранение шага")
}
+172
View File
@@ -0,0 +1,172 @@
// Package sqlite — хранилище сервиса: база на своей схеме и файлы записей своим
// каталогом.
//
// Пакет назван по драйверу, а не по роли: соседи в `internal/adapter` названы
// тем же способом — `converter`, `metaviewer`, `recognizer`, — и «repo/sqlite»
// читается как «репозитории поверх SQLite» без знания кода.
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
// Драйвер регистрируется загрузкой пакета. CGO ему не нужен — этим он и
// выбран: сборка бинарника остаётся без компилятора C.
_ "modernc.org/sqlite"
)
// driverName — имя, под которым драйвер регистрируется в `database/sql`.
const driverName = "sqlite"
// DatabaseFile — имя файла базы в каталоге данных. Рядом с ним драйвер кладёт
// журнал упреждающей записи и его указатель, поэтому каталог данных занят базой
// целиком, а не одним файлом.
const DatabaseFile = "transcriber.db"
// Settings — числа, которыми настраивается база. Оба приходят настройкой, а не
// константой кода: крутят их при одном и том же отказе — «база занята» под
// несколькими воркерами, — и подбор ответа на такой отказ не должен требовать
// пересборки образа.
type Settings struct {
// BusyTimeoutMs — сколько ждать занятую базу, миллисекунды.
BusyTimeoutMs int
// ReadConnections — сколько соединений держит читающий пул.
ReadConnections int
}
// Validate проверяет числа базы. Ноль и отрицательное — опечатка, а не режим:
// нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул
// чтения означает пул без предела, то есть настройку, которой не управляют.
func (s Settings) Validate() error {
if s.BusyTimeoutMs <= 0 {
return errors.New("storage: ожидание занятой базы задаётся положительным числом миллисекунд")
}
if s.ReadConnections <= 0 {
return errors.New("storage: число соединений читающего пула задаётся положительным числом")
}
return nil
}
// Обращения к базе идут с **собственным** контекстом, а не с контекстом
// запроса, и это решение, а не недосмотр. Репозитории отменять нечего: операции
// местные и короткие, а единственное ожидание — занятая база — задано числом. За
// отмену при этом платили бы дважды: шаг, прерванный остановкой сервиса,
// перестал бы освобождать захват и писать причину остановки — то есть отмена
// ломала бы ровно ту уборку, ради которой она и делается.
//
// Отмена, которой сервис распоряжается по-настоящему, доходит туда, где она
// стоит денег и времени: до `ffmpeg` и до платного распознавания.
// DB — база сервиса двумя пулами.
//
// Пишущий пул держит **одно** соединение: драйвер пишет единственным
// соединением, и несколько воркеров, пришедших писать разом мимо этого правила,
// получают отказ по занятости — на записи результата шага, то есть после
// оплаченной работы. Пул с одним соединением обращает их в очередь.
//
// Читающий пул отдельный: в журнале упреждающей записи читатели не мешают
// писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
type DB struct {
// writer — единственное пишущее соединение. Через него идёт всякая
// операция, которая читает состояние и следом его пишет: транзакцию,
// начатую на читающем соединении, SQLite до пишущей не повышает и отвечает
// отказом по занятости немедленно — заданное числом ожидание такой отказ не
// лечит, ждать там нечего.
writer *sql.DB
// reader — пул чтения.
reader *sql.DB
}
// Writer отдаёт пишущее соединение.
func (db *DB) Writer() *sql.DB { return db.writer }
// Reader отдаёт читающий пул.
func (db *DB) Reader() *sql.DB { return db.reader }
// Open открывает базу в каталоге данных, заводя каталог, если его ещё нет.
//
// Настройки соединения задаются **строкой подключения обоих пулов**, а не
// запросом после открытия. Соблюдение внешних ключей в SQLite — настройка
// соединения, а не базы, и по умолчанию она выключена; пул раздаёт соединения и
// заводит новые по мере надобности, поэтому запрос, выполненный один раз,
// настроил бы одно соединение из многих, а остальные остались бы с умолчанием —
// молча.
func Open(dataDir string, settings Settings) (*DB, error) {
if err := settings.Validate(); err != nil {
return nil, err
}
if err := os.MkdirAll(dataDir, 0o750); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err)
}
path := filepath.Join(dataDir, DatabaseFile)
// Пишущее соединение начинает транзакцию сразу пишущей (`immediate`):
// операция, которая читает и следом пишет, иначе взяла бы читающую
// транзакцию и упёрлась бы в отказ при первой же записи.
writer, err := open(path, settings, "immediate")
if err != nil {
return nil, err
}
writer.SetMaxOpenConns(1)
writer.SetMaxIdleConns(1)
reader, err := open(path, settings, "deferred")
if err != nil {
return nil, errors.Join(fmt.Errorf("failed to open read pool: %w", err), writer.Close())
}
reader.SetMaxOpenConns(settings.ReadConnections)
reader.SetMaxIdleConns(settings.ReadConnections)
db := &DB{writer: writer, reader: reader}
// Пробное обращение делается сразу: `sql.Open` соединения не открывает, и
// негодная строка подключения вылезла бы не на старте, а на первом запросе —
// то есть отказом каждого запроса вместо одной строки о причине.
if err := writer.PingContext(context.Background()); err != nil {
return nil, errors.Join(fmt.Errorf("failed to open database: %w", err), db.Close())
}
return db, nil
}
// open заводит один пул с общими настройками соединения.
func open(path string, settings Settings, txlock string) (*sql.DB, error) {
query := url.Values{}
query.Add("_pragma", "busy_timeout("+strconv.Itoa(settings.BusyTimeoutMs)+")")
query.Add("_pragma", "journal_mode(WAL)")
query.Add("_pragma", "foreign_keys(1)")
query.Set("_txlock", txlock)
db, err := sql.Open(driverName, "file:"+path+"?"+query.Encode())
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
return db, nil
}
// Close закрывает оба пула. Повторный вызов паники не даёт: закрытие уже
// закрытого пула отказом не считается.
func (db *DB) Close() error {
var errs []error
if db.reader != nil {
if err := db.reader.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close read pool: %w", err))
}
db.reader = nil
}
if db.writer != nil {
if err := db.writer.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close write pool: %w", err))
}
db.writer = nil
}
return errors.Join(errs...)
}
+554
View File
@@ -0,0 +1,554 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// testSettings — числа базы под проверками: те же по смыслу, что и умолчания
// конфига.
func testSettings() Settings {
return Settings{BusyTimeoutMs: 5000, ReadConnections: 4}
}
// newTestDB поднимает базу на пустом каталоге и накатывает схему — ровно тем же
// путём, каким это делает сервис при старте.
func newTestDB(t *testing.T) (*DB, *Store, string) {
t.Helper()
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Logf("не удалось закрыть базу: %v", err)
}
})
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
return db, NewStore(dir), dir
}
// newOwner заводит учётную запись и отдаёт её идентификатор.
func newOwner(t *testing.T, db *DB) string {
t.Helper()
account, _, err := NewUserRepository(db).EnsureUser(contract.Identity{Login: ident.New()})
require.NoError(t, err)
return account.ID
}
// Настройки соединения задаются строкой подключения **обоих** пулов: соблюдение
// внешних ключей в SQLite принадлежит соединению, а не базе, и запрос, сделанный
// один раз после открытия, настроил бы одно соединение из многих.
func TestSettingsApplyToEveryConnection(t *testing.T) {
db, _, _ := newTestDB(t)
var mode string
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA journal_mode").Scan(&mode))
assert.Equal(t, "wal", mode, "журнал упреждающей записи выключен")
var busy int
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&busy))
assert.Equal(t, testSettings().BusyTimeoutMs, busy, "ожидание занятой базы осталось умолчанием драйвера")
// Читающий пул раздаёт соединения по мере надобности, поэтому спрашиваем
// **несколько** разом: одно настроенное соединение из четырёх — ровно та
// поломка, ради которой настройка уехала в строку подключения.
var wg sync.WaitGroup
answers := make([]int, testSettings().ReadConnections)
start := make(chan struct{})
for i := range answers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
conn, err := db.Reader().Conn(context.Background())
if !assert.NoError(t, err) {
return
}
defer func() { assert.NoError(t, conn.Close()) }()
assert.NoError(t,
conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&answers[i]))
// Соединение придерживается, пока спрашивают остальные: иначе пул
// раздал бы всем одно и то же и правило проверило бы одну настройку
// вместо четырёх.
time.Sleep(10 * time.Millisecond)
}()
}
close(start)
wg.Wait()
for i, answer := range answers {
assert.Equal(t, 1, answer, "соединение %d читающего пула не соблюдает внешние ключи", i)
}
// И держатся внешние ключи **на деле**, а не только настройкой: вставка с
// несуществующим владельцем отвергается обоими пулами.
now := clock.Now().Format(timeLayout)
insert := `INSERT INTO audio_records
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`
_, err := db.Writer().ExecContext(context.Background(), insert,
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
require.Error(t, err, "пишущее соединение приняло запись с несуществующим владельцем")
_, err = db.Reader().ExecContext(context.Background(), insert,
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
require.Error(t, err, "читающее соединение приняло запись с несуществующим владельцем")
}
// Настройки проверяются на старте: ноль и отрицательное — опечатка, а не режим.
func TestSettingsAreValidated(t *testing.T) {
for name, settings := range map[string]Settings{
"нулевое ожидание": {BusyTimeoutMs: 0, ReadConnections: 4},
"нулевой пул чтения": {BusyTimeoutMs: 5000, ReadConnections: 0},
"отрицательный пул": {BusyTimeoutMs: 5000, ReadConnections: -1},
"отрицательный срок": {BusyTimeoutMs: -1, ReadConnections: 4},
} {
t.Run(name, func(t *testing.T) {
_, err := Open(t.TempDir(), settings)
assert.Error(t, err, "старт на негодном числе прошёл молча")
})
}
}
// Повторный запуск на заведённом каталоге схему второй раз не заводит и прежних
// записей не теряет.
func TestMigrateIsIdempotent(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
owner := newOwner(t, db)
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
var login string
require.NoError(t, db.Reader().
QueryRowContext(context.Background(),
"SELECT provider_login FROM users WHERE id = ?", owner).Scan(&login))
assert.NotEmpty(t, login, "повторный накат потерял прежние строки")
var applied int
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM goose_db_version").Scan(&applied))
assert.Equal(t, 2, applied, "шаг отмечен дважды: накат не идемпотентен")
}
// Накат держится исключающей блокировкой каталога данных: второй накат ждёт
// освобождения, а не применяет шаги параллельно.
//
// Библиотека шагов под SQLite блокировки не поставляет вовсе — её запиратели
// объявлены только для PostgreSQL, — поэтому замок наш, и проверка сторожит
// именно его.
func TestMigrationLockSerializesRuns(t *testing.T) {
dir := t.TempDir()
var (
mu sync.Mutex
inside int
overlap bool
)
hold := func() error {
mu.Lock()
inside++
if inside > 1 {
overlap = true
}
mu.Unlock()
time.Sleep(50 * time.Millisecond)
mu.Lock()
inside--
mu.Unlock()
return nil
}
var wg sync.WaitGroup
for range 3 {
wg.Add(1)
go func() {
defer wg.Done()
assert.NoError(t, withMigrationLock(dir, hold))
}()
}
wg.Wait()
assert.False(t, overlap, "два наката шли одновременно: замок не держит")
}
// Отказ шага роняет накат и называет шаг: сервис, поднявшийся на неприведённой
// схеме, отвечал бы отказом на каждый запрос.
func TestMigrateFailsLoudly(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
// Таблица уже занята чужой строкой: начальный шаг на такой базе не
// применяется.
_, err = db.Writer().ExecContext(context.Background(), "CREATE TABLE users (id TEXT)")
require.NoError(t, err)
err = Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))
require.Error(t, err, "отказ шага прошёл молча")
assert.Contains(t, err.Error(), "202608220002", "отказ не называет шаг")
// **Шаг и отметка о нём идут одной транзакцией**, поэтому отказавший шаг не
// оставляет за собой ни отметки, ни половины схемы. Полуприменённое
// состояние — то самое, из-за которого следующий запуск применил бы шаг
// второй раз и упал бы на заведённой таблице.
var version int
err = db.Reader().QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM goose_db_version WHERE version_id = 202608220002").Scan(&version)
if err == nil {
assert.Equal(t, 0, version, "отказавший шаг отмечен применённым")
}
var tables int
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'audio_records'").Scan(&tables))
assert.Equal(t, 0, tables, "отказавший шаг оставил за собой половину схемы")
}
// Все колонки времени объявлены одним типом и без умолчания: умолчание схемы
// писало бы свой вид времени, а вставка, забывшая проставить время, при нём
// прошла бы молча.
func TestSchemaHasOneTimeShapeWithoutDefaults(t *testing.T) {
db, _, _ := newTestDB(t)
tables := []string{
"users", "files", "topics", "audio_records",
"texts", "structures", "recognitions", "record_events",
}
seen := 0
for _, table := range tables {
rows, err := db.Reader().QueryContext(context.Background(),
"SELECT name, type, dflt_value FROM pragma_table_info(?)", table)
require.NoError(t, err)
for rows.Next() {
var (
name string
columnType string
dflt any
)
require.NoError(t, rows.Scan(&name, &columnType, &dflt))
if !isTimeColumn(name) {
continue
}
seen++
assert.Equal(t, "TEXT", columnType, "колонка %s.%s несёт время не текстом", table, name)
assert.Nil(t, dflt, "у колонки %s.%s есть умолчание времени", table, name)
}
require.NoError(t, rows.Err())
closeRows(t, rows)
}
require.Positive(t, seen, "колонок времени не найдено: правило потеряло предмет")
}
// closeRows закрывает выборку. Отдельной функцией, потому что закрывается она в
// цикле по таблицам: отложенное закрытие копилось бы до конца проверки.
func closeRows(t *testing.T, rows *sql.Rows) {
t.Helper()
require.NoError(t, rows.Close())
}
func isTimeColumn(name string) bool {
return strings.HasSuffix(name, "_at") || name == "delay_time"
}
// Строка, заведённая приёмом, и строка, заведённая запросом к базе, попадают в
// отбор захвата одинаково: вид времени в схеме один.
func TestHandwrittenRecordIsAcquiredToo(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
records := NewAudioRecordRepository(db)
byService := &entity.AudioRecord{
Id: ident.New(),
OwnerID: owner,
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
}
require.NoError(t, records.Create(byService))
byHand := ident.New()
now := clock.Now().Format(timeLayout)
_, err := db.Writer().ExecContext(context.Background(),
`INSERT INTO audio_records
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`,
byHand, owner, entity.StateUploaded, now, now, now,
)
require.NoError(t, err)
acquired := map[string]bool{}
for range 2 {
got, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
acquired[got.ID] = true
}
assert.True(t, acquired[byService.Id], "запись приёма захвату не досталась")
assert.True(t, acquired[byHand], "запись, заведённая запросом к базе, захвату не досталась")
}
// Горячие выборки опираются на индекс: полного сканирования таблицы аудиозаписей
// не показывает ни отбор захвата, ни список, сужаемый владельцем и страницей.
func TestHotQueriesUseIndexes(t *testing.T) {
db, _, _ := newTestDB(t)
acquire := explain(t, db, `
SELECT id FROM audio_records
WHERE state IN (?, ?)
AND halted_at IS NULL
AND (delay_time IS NULL OR delay_time < ?)
AND (acquisition_id IS NULL OR acquire_expires_at IS NULL OR acquire_expires_at < ?)
ORDER BY created_at, id
LIMIT 1`,
entity.StateUploaded, entity.StateNormalized, "now", "now")
list := explain(t, db, `
SELECT id FROM audio_records
WHERE owner_id = ?
AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC
LIMIT 31`,
"owner", "now", "now", "id")
for name, plan := range map[string]string{"отбор захвата": acquire, "список": list} {
assert.NotContains(t, plan, "SCAN audio_records",
"%s идёт полным сканированием таблицы аудиозаписей: %s", name, plan)
assert.Contains(t, plan, "USING", "%s не опирается на индекс: %s", name, plan)
assert.Contains(t, plan, "INDEX", "%s не опирается на индекс: %s", name, plan)
}
}
func explain(t *testing.T, db *DB, query string, args ...any) string {
t.Helper()
rows, err := db.Reader().QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...)
require.NoError(t, err)
defer func() { require.NoError(t, rows.Close()) }()
var plan strings.Builder
for rows.Next() {
var id, parent, notUsed int
var detail string
require.NoError(t, rows.Scan(&id, &parent, &notUsed, &detail))
plan.WriteString(detail)
plan.WriteString("; ")
}
require.NoError(t, rows.Err())
return plan.String()
}
// Мягкая остановка закрывает то же, что открыл подъём, и повторная остановка не
// даёт паники.
func TestCloseIsIdempotent(t *testing.T) {
db, err := Open(t.TempDir(), testSettings())
require.NoError(t, err)
require.NoError(t, db.Close())
assert.NoError(t, db.Close(), "повторное закрытие отказало")
}
// Укладка атомарна: источник, отдавший отказ на середине потока, не оставляет ни
// файла под рабочим именем, ни временного имени в подкаталоге записи.
func TestStorePutIsAtomic(t *testing.T) {
_, store, dir := newTestDB(t)
recordID := ident.New()
_, err := store.Put(recordID, "voice.mp3", &brokenReader{})
require.Error(t, err, "отказ источника прошёл молча")
_, err = os.Stat(filepath.Join(dir, recordsDir, recordID, "voice.mp3"))
assert.True(t, os.IsNotExist(err), "рабочее имя появилось при оборванном потоке")
temporary, err := store.HasTemporary(recordID)
require.NoError(t, err)
assert.False(t, temporary, "временное имя осталось в подкаталоге записи")
}
// brokenReader отдаёт часть потока и обрывается — так выглядит отправитель,
// закрывший соединение на середине.
type brokenReader struct {
sent bool
}
func (r *brokenReader) Read(p []byte) (int, error) {
if !r.sent {
r.sent = true
copy(p, strings.Repeat("a", min(len(p), 64)))
return min(len(p), 64), nil
}
return 0, errors.New("источник оборвался")
}
// Отказ укладки несёт причину и не несёт пути.
//
// Обе половины — одно требование, и порознь они друг друга отменяют. Причина
// нужна владельцу: исчерпание места, отсутствие прав и негодная раскладка
// каталога требуют трёх разных действий, а отказ укладки — единственная
// поверхность, на которой он их видит. Путь не нужен: он ведёт внутрь каталога
// данных, а отказ кончается в журнале, откуда строку потом не убрать.
func TestStoreFailureCarriesCauseWithoutPath(t *testing.T) {
_, store, dir := newTestDB(t)
// noPath судит вторую половину: ни каталога данных, ни временной приставки
// в цепочке отказа быть не должно.
noPath := func(t *testing.T, err error) {
t.Helper()
require.Error(t, err)
assert.NotContains(t, err.Error(), dir, "путь внутри каталога данных уехал в отказ")
assert.NotContains(t, err.Error(), tempPrefix, "временное имя укладки уехало в отказ")
}
t.Run("места на диске нет", func(t *testing.T) {
recordID := ident.New()
_, err := store.Put(recordID, "voice.mp3", &diskFullReader{
path: filepath.Join(dir, recordsDir, recordID, tempPrefix+"whatever"),
})
noPath(t, err)
assert.ErrorIs(t, err, syscall.ENOSPC, "причина отказа отброшена: место на диске неотличимо от прочего")
})
t.Run("прав на подкаталог записи нет", func(t *testing.T) {
recordID := ident.New()
recordDir := filepath.Join(dir, recordsDir, recordID)
require.NoError(t, os.MkdirAll(recordDir, 0o750))
require.NoError(t, os.Chmod(recordDir, 0o500))
t.Cleanup(func() {
if err := os.Chmod(recordDir, 0o750); err != nil {
t.Logf("не удалось вернуть права подкаталогу записи: %v", err)
}
})
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
noPath(t, err)
assert.ErrorIs(t, err, fs.ErrPermission, "причина отказа отброшена: отсутствие прав неотличимо от прочего")
})
t.Run("подкаталогом записи занято не то", func(t *testing.T) {
recordID := ident.New()
require.NoError(t, os.MkdirAll(filepath.Join(dir, recordsDir), 0o750))
require.NoError(t, os.WriteFile(filepath.Join(dir, recordsDir, recordID), []byte("не каталог"), 0o600))
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
noPath(t, err)
assert.ErrorIs(t, err, syscall.ENOTDIR, "причина отказа отброшена: негодная раскладка неотличима от прочего")
})
t.Run("копии нет", func(t *testing.T) {
_, err := store.Open(ident.New(), "voice.mp3")
noPath(t, err)
assert.ErrorIs(t, err, fs.ErrNotExist, "причина отказа отброшена: «файла нет» неотличимо от прочего")
})
}
// diskFullReader отказывает так, как отказывает диск: причина приходит обёрткой
// пакета `os`, и путь лежит в ней. Настоящим источником укладки служит `*os.File`
// рабочей копии, и его отказ приходит ровно этой формой.
type diskFullReader struct {
path string
}
func (r *diskFullReader) Read([]byte) (int, error) {
return 0, &os.PathError{Op: "write", Path: r.path, Err: syscall.ENOSPC}
}
// Копии одной записи лежат вместе — под её идентификатором, — и второго места,
// где лежит что-то из них, нет.
func TestCopiesOfRecordLiveTogether(t *testing.T) {
db, store, dir := newTestDB(t)
owner := newOwner(t, db)
files := NewFileRepository(db, store)
recordID := ident.New()
for _, name := range []string{"original.mp3", "normalized.ogg"} {
work, err := files.Stage(filepath.Ext(name), strings.NewReader("содержимое "+name))
require.NoError(t, err)
_, err = files.Create(recordID, name, work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
require.NoError(t, work.Close())
}
entries, err := os.ReadDir(filepath.Join(dir, recordsDir, recordID))
require.NoError(t, err)
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
assert.ElementsMatch(t, []string{"original.mp3", "normalized.ogg"}, names)
records, err := os.ReadDir(filepath.Join(dir, recordsDir))
require.NoError(t, err)
assert.Len(t, records, 1, "второго места для копий записи не появляется")
}
// Содержимое читается потоком с перемоткой: отдача по диапазону берёт кусок, а
// не файл целиком.
func TestOpenGivesSeekableStream(t *testing.T) {
db, store, _ := newTestDB(t)
owner := newOwner(t, db)
files := NewFileRepository(db, store)
recordID := ident.New()
work, err := files.Stage(".mp3", strings.NewReader("0123456789"))
require.NoError(t, err)
file, err := files.Create(recordID, "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
require.NoError(t, work.Close())
reader, err := files.Open(file.Id)
require.NoError(t, err)
defer func() { require.NoError(t, reader.Close()) }()
_, err = reader.Seek(4, io.SeekStart)
require.NoError(t, err)
slice := make([]byte, 3)
_, err = io.ReadFull(reader, slice)
require.NoError(t, err)
assert.Equal(t, "456", string(slice))
}
+237
View File
@@ -0,0 +1,237 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// workFile — рабочая копия файла на диске. Живёт во временном каталоге системы,
// а не в каталоге данных: последний смонтирован на сервере, и временному там не
// место.
type workFile struct {
path string
}
func (w *workFile) Path() string { return w.path }
func (w *workFile) Size() (int64, error) {
info, err := os.Stat(w.path)
if err != nil {
return 0, fmt.Errorf("failed to stat work file: %w", err)
}
return info.Size(), nil
}
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
// до его создания, и повторный Close тоже законен.
func (w *workFile) Close() error {
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove work file: %w", err)
}
return nil
}
// FileRepository — копии записей: строка в базе и содержимое в каталоге данных.
type FileRepository struct {
db *DB
store *Store
}
func NewFileRepository(db *DB, store *Store) *FileRepository {
return &FileRepository{db: db, store: store}
}
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
func newWorkFile(ext string) (*workFile, error) {
f, err := os.CreateTemp("", "transcriber-*"+ext)
if err != nil {
return nil, fmt.Errorf("failed to create work file: %w", err)
}
path := f.Name()
if err := f.Close(); err != nil {
_ = os.Remove(path)
return nil, fmt.Errorf("failed to close work file: %w", err)
}
return &workFile{path: path}, nil
}
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
return newWorkFile(ext)
}
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
work, err := newWorkFile(ext)
if err != nil {
return nil, err
}
if err := writeTo(work.path, content); err != nil {
// Отказ уборки не подменяет отказ записи, но и не теряется.
return nil, errors.Join(err, work.Close())
}
return work, nil
}
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
file, err := repo.GetByID(fileID)
if err != nil {
return nil, err
}
work, err := newWorkFile(filepath.Ext(file.FileName))
if err != nil {
return nil, err
}
src, err := repo.store.Open(file.RecordID, file.FileName)
if err != nil {
return nil, errors.Join(err, work.Close())
}
defer func() { _ = src.Close() }()
if err := writeTo(work.path, src); err != nil {
return nil, errors.Join(err, work.Close())
}
return work, nil
}
// Create кладёт рабочую копию в каталог данных и заводит строку о файле.
//
// Порядок один: строка заводится **после** того, как содержимое лежит целиком
// под рабочим именем. Обратный порядок оставлял бы в базе строку, указывающую на
// файл, которого ещё нет или который короче принятого.
//
// Отсюда и уборка: содержимое легло, а строка не сохранилась — уложенный файл
// убирается, и следа от него не остаётся. Файл, переживший свою строку, —
// штатное состояние только у приведённой копии, которую заводит шаг конвейера; у
// принятой это мусор, на который не ссылается ничто и о котором узнать неоткуда.
//
// Владелец обязателен и лежит своей колонкой: пустой отвергает схема — колонка
// объявлена связью с учётной записью, и пустое значение ей не отвечает.
func (repo *FileRepository) Create(
recordID, name string,
work contract.WorkFile,
meta contract.FileMeta,
ownerID string,
) (*entity.File, error) {
source, err := os.Open(work.Path())
if err != nil {
// Причина сохраняется, путь снимается: он ведёт к рабочей копии чужого
// аудио, а отказ кончается в журнале.
return nil, fmt.Errorf("failed to read work file: %w", causeOf(err))
}
size, putErr := repo.store.Put(recordID, name, source)
closeErr := source.Close()
if err := errors.Join(putErr, closeErr); err != nil {
return nil, err
}
file := &entity.File{
Id: ident.New(),
RecordID: recordID,
FileName: name,
Size: size,
Format: meta.Format,
DurationMs: meta.DurationMs,
CreatedAt: clock.Now(),
}
query, args := insertSQL("files", map[string]any{
"id": file.Id,
"owner_id": ownerID,
"record_id": file.RecordID,
"file_name": file.FileName,
"size_bytes": file.Size,
"format": file.Format,
"duration_ms": file.DurationMs,
"created_at": formatTime(file.CreatedAt),
})
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
// Уложенное содержимое убирается: строки о нём не будет, и ссылаться на
// него нечему. Имя файла в отказ не идёт — оно часть пути к чужому аудио.
return nil, errors.Join(
fmt.Errorf("failed to store the file row of record %s: %w", recordID, err),
repo.store.Remove(recordID, name),
)
}
return file, nil
}
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
file := &entity.File{}
var (
createdAt string
recordID string
fileName string
size int64
format string
durationMs int64
)
err := repo.db.Reader().QueryRowContext(context.Background(),
`SELECT record_id, file_name, size_bytes, format, duration_ms, created_at
FROM files WHERE id = ?`, id,
).Scan(&recordID, &fileName, &size, &format, &durationMs, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("file %s is not found", id)
}
return nil, fmt.Errorf("failed to get file %s: %w", id, err)
}
file.Id = id
file.RecordID = recordID
file.FileName = fileName
file.Size = size
file.Format = format
file.DurationMs = durationMs
file.CreatedAt = requiredTimeOf(createdAt)
return file, nil
}
// Open отдаёт содержимое хранимой копии потоком с перемоткой: отдача по
// диапазону читает кусок, а не файл целиком.
func (repo *FileRepository) Open(fileID string) (io.ReadSeekCloser, error) {
file, err := repo.GetByID(fileID)
if err != nil {
return nil, err
}
return repo.store.Open(file.RecordID, file.FileName)
}
// writeTo переливает содержимое в файл потоком. В память запись целиком не
// читается: расчётный потолок — шесть часов.
func writeTo(path string, content io.Reader) error {
dst, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to open work file: %w", err)
}
if _, err := io.Copy(dst, content); err != nil {
_ = dst.Close()
return fmt.Errorf("failed to write work file: %w", err)
}
if err := dst.Close(); err != nil {
return fmt.Errorf("failed to close work file: %w", err)
}
return nil
}
+159
View File
@@ -0,0 +1,159 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// UserRepository — учётные записи сервиса.
type UserRepository struct {
db *DB
}
func NewUserRepository(db *DB) *UserRepository {
return &UserRepository{db: db}
}
// EnsureUser находит учётную запись по логину у провайдера, а не найдя — заводит
// её.
//
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
// представиться — личные токены — возьмёт этот же метод; правило, уложенное
// куском в слой транспорта, пришлось бы тогда либо дублировать вторым куском,
// либо вытаскивать задним числом.
//
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
// его работы.
//
// **Поиск идёт читающим пулом, и пишущая транзакция открывается только тогда,
// когда запись не нашлась.** Узнавание одето на весь корень приложения, поэтому
// пишущая транзакция, взятая до поиска, доставалась бы всему узнанному потоку —
// опросу карточки и каждому запросу диапазона при проигрывании, — и вставала бы
// в очередь к единственному пишущему соединению. Ждать там нечего: заводится
// учётная запись один раз за жизнь человека.
//
// **Окно между двумя соединениями закрыто повторным поиском внутри
// транзакции.** Между поиском читающим пулом и открытием пишущей транзакции
// запись успевает завести сосед; ветвь ниже находит её и берёт заведённую, а
// уникальность ключа держит схема — не порядок обращений.
func (repo *UserRepository) EnsureUser(identity contract.Identity) (*contract.UserAccount, bool, error) {
login, ok := entity.AcceptProviderLogin(identity.Login)
if !ok {
return nil, false, contract.ErrLoginNotAcceptable
}
account, err := findUserByLogin(repo.db.Reader(), login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, nil
}
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
if err != nil {
return nil, false, fmt.Errorf("failed to open a transaction for the user account: %w", err)
}
defer func() { _ = tx.Rollback() }()
// Повторный поиск закрывает окно между читающим пулом и пишущей
// транзакцией: пока её ждали, запись мог завести сосед.
account, err = findUserByLogin(tx, login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, commitAccount(tx, account)
}
name := entity.AcceptDisplayName(identity.Name)
email, _ := entity.AcceptEmail(identity.Email)
account, err = insertUser(tx, login, name, email)
switch {
case err == nil:
return account, true, commitAccount(tx, account)
case !isUniqueViolation(err):
return nil, false, fmt.Errorf("failed to create user account: %w", err)
}
// **Два отказа уникальности различаются, и исход у них разный**, а какая
// колонка не сошлась, код отказа не называет. Различает их повторный поиск
// по ключу: нашёлся — это гонка двух первых обращений одним логином, и надо
// просто взять заведённую соседом запись.
account, err = findUserByLogin(tx, login)
if err != nil {
return nil, false, err
}
if account != nil {
return account, false, commitAccount(tx, account)
}
// Не нашёлся — значит не сошлась другая колонка: адрес почты, пришедший от
// провайдера, занят другой учётной записью (общий ящик, семья, группа).
// Запись заводится **без почты**: она необязательна и ключом не служит. Без
// этого разреза второй человек с общим адресом не завёлся бы никогда —
// повторный поиск по логину снова ничего не находит.
account, err = insertUser(tx, login, name, "")
if err != nil {
return nil, false, fmt.Errorf("failed to create user account without email: %w", err)
}
return account, true, commitAccount(tx, account)
}
func commitAccount(tx *sql.Tx, account *contract.UserAccount) error {
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit the user account %s: %w", account.ID, err)
}
return nil
}
func insertUser(tx *sql.Tx, login, name, email string) (*contract.UserAccount, error) {
id := ident.New()
now := formatTime(clock.Now())
_, err := tx.ExecContext(context.Background(),
`INSERT INTO users (id, provider_login, name, email, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
id, login, name, email, now, now,
)
if err != nil {
return nil, err
}
return &contract.UserAccount{ID: id, Name: name}, nil
}
// rowQuerier — то общее, чем поиск учётной записи пользуется у читающего пула и
// у пишущей транзакции. Оба поиска — до транзакции и внутри неё — идут одним
// запросом: второй его копией они разошлись бы молча.
type rowQuerier interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
// findUserByLogin ищет учётную запись по ключу. Значение уходит базе
// **параметром** запроса, а не подстановкой в текст: строка приходит снаружи, и
// подставленная в текст она правила бы сам запрос, а не только его аргумент.
func findUserByLogin(q rowQuerier, login string) (*contract.UserAccount, error) {
account := &contract.UserAccount{}
err := q.QueryRowContext(context.Background(),
"SELECT id, name FROM users WHERE provider_login = ?", login,
).Scan(&account.ID, &account.Name)
switch {
case err == nil:
return account, nil
case errors.Is(err, sql.ErrNoRows):
return nil, nil
default:
return nil, fmt.Errorf("failed to look up user account: %w", err)
}
}
+110
View File
@@ -0,0 +1,110 @@
package sqlite
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"syscall"
"github.com/pressly/goose/v3"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite/migrations"
)
// migrationLockFile — файл, на котором берётся замок наката. Лежит в каталоге
// данных рядом с базой: замок принадлежит каталогу, а не машине.
const migrationLockFile = "migrate.lock"
// Migrate приводит схему к последнему шагу.
//
// # Порядок
//
// Накат идёт **до подъёма входов и до старта воркеров**, а его отказ роняет
// старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом на каждый
// запрос и на каждый прогон воркера — вместо одной строки о причине их
// становятся сотни, и первопричина в них теряется.
//
// # Чем держится неделимость
//
// Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
// соединении и внутри выполняет и сам шаг, и вставку версии в таблицу учёта.
// Отменяет это только пометка `NO TRANSACTION` у самого шага, и мы её не ставим.
//
// Порядок шагов детерминирован и выводится из версии шага, а не из порядка
// чтения каталога: собранные шаги сортируются по версии, а две одинаковых версии
// дают отказ сбора, а не молчаливый выбор одного.
//
// # Почему замок наш
//
// Исключающей блокировки наката библиотека под SQLite не даёт вовсе: её
// запиратели объявлены только для PostgreSQL, а провайдер без запирателя
// накатывает без всякой блокировки. Замок поэтому берём сами — на файле в
// каталоге данных. С умершим процессом его снимает ядро, поэтому просроченного
// замка, который надо чистить руками, не остаётся.
//
// Накат идёт по **пишущему** соединению: он читает таблицу учёта и следом в неё
// пишет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
// повышает.
func Migrate(ctx context.Context, db *DB, dataDir string, logger *slog.Logger) error {
if logger == nil {
logger = slog.Default()
}
provider, err := goose.NewProvider(
goose.DialectSQLite3,
db.Writer(),
nil,
goose.WithGoMigrations(migrations.All()...),
// Глобальный список библиотеки не читается: перечень шагов приходит
// доводом, и два провайдера в одном процессе за общее состояние не
// спорят.
goose.WithDisableGlobalRegistry(true),
)
if err != nil {
return fmt.Errorf("failed to prepare schema migrations: %w", err)
}
return withMigrationLock(dataDir, func() error {
results, err := provider.Up(ctx)
if err != nil {
// Отказ называет шаг: библиотека кладёт версию в текст отказа, и
// владелец сервиса по ней находит файл шага.
return fmt.Errorf("failed to apply schema migration: %w", err)
}
for _, result := range results {
logger.Info("Schema migration applied",
"migration_version", result.Source.Version,
"duration_ms", result.Duration.Milliseconds())
}
return nil
})
}
// withMigrationLock берёт исключающий замок каталога данных на всё время наката.
//
// Замок блокирующий: второй процесс, поднятый на том же каталоге, ждёт его
// освобождения, а не применяет шаги параллельно. Два наката, разошедшихся на
// одном шаге, оставили бы схему в состоянии, которого не описывает ни один шаг.
func withMigrationLock(dataDir string, run func() error) error {
path := filepath.Join(dataDir, migrationLockFile)
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o640)
if err != nil {
return fmt.Errorf("failed to open migration lock: %w", err)
}
// Замок снимается **закрытием дескриптора**, и отдельного снятия не нужно:
// он принадлежит открытому файлу, а не процессу. С умершим процессом его
// снимает ядро тем же движением — просроченного замка, который надо чистить
// руками, не остаётся.
defer func() { _ = file.Close() }()
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
return fmt.Errorf("failed to lock the data directory for migration: %w", err)
}
return run()
}
@@ -0,0 +1,254 @@
package migrations
import (
"context"
"database/sql"
"fmt"
"strconv"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// up202608220002 заводит схему сервиса целиком.
//
// Шаг один, и он начальный: прежние шаги встроенного хранилища удалены вместе с
// ним — разовое снятие инварианта «применённая миграция не переписывается»
// решением владельца от 2026-08-22. Причина названа прямо: стадия проекта —
// стройка, на сервере данных нет, сервис остановлен, а новая база ведёт учёт
// применённого своей таблицей, которой отметки прежнего каталога не годятся
// вовсе. Снятие кончается этим шагом: уехав на сервер, он подпадает под
// инвариант как всякий прежний.
//
// Порядок заведения задан связями: сперва учётные записи, потом всё, что на них
// ссылается, и только потом обратные ссылки записи на её приложения.
//
// **Времени умолчанием схема не ставит.** Вид времени один на все колонки —
// `TEXT` в RFC 3339, UTC, секундная точность, — и ставит его приложение единой
// точкой. `CURRENT_TIMESTAMP` писал бы свой вид, отличный от объявленного, а
// вставка, забывшая проставить время, при умолчании прошла бы молча.
//
// **Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки
// и вид текста были закрыты схемой, потому что панель владельца правила запись
// руками и вправе была завести значение, которого сервис не знает. Панели нет,
// правка идёт только нашим кодом, и `CHECK` остался бы ценой — новое значение
// стоило бы нового шага схемы — без покупателя.
func up202608220002(ctx context.Context, tx *sql.Tx) error {
for _, statement := range initStatements() {
if _, err := tx.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("failed to apply initial schema: %w", err)
}
}
return nil
}
// down202608220002 сносит схему целиком. Порядок обратный порядку заведения:
// приложения ссылаются на запись, запись — на учётную запись.
func down202608220002(ctx context.Context, tx *sql.Tx) error {
tables := []string{
"record_events",
"recognitions",
"structures",
"texts",
"record_topics",
"audio_records",
"topics",
"files",
"users",
}
for _, table := range tables {
if _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table); err != nil {
return fmt.Errorf("failed to drop %s: %w", table, err)
}
}
return nil
}
// initStatements — шаг по одному оператору на элемент.
//
// Россыпью, а не одной строкой с разделителями: тело триггера само несёт точку с
// запятой, и разбиение общей строки резало бы его пополам.
func initStatements() []string {
return []string{
// Учётная запись. Ключ — логин у провайдера: его приносит заголовок
// доверенного источника, и по нему запись находится при каждом
// обращении. Адрес почты необязателен и ключом не служит — он меняется,
// и первое обращение с чужим адресом досталось бы чужой записи.
`CREATE TABLE users (
id TEXT NOT NULL PRIMARY KEY,
provider_login TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_users_provider_login ON users (provider_login)`,
// Уникальность почты частичная: пустая почта законна и не спорит с
// другой пустой. Индекс нужен затем, чтобы занятый адрес отвергался
// схемой — по этому отказу заведение переходит на ветвь «запись без
// почты», а не отдаёт чужую учётную запись.
`CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email <> ''`,
// Копия записи на диске. Владелец лежит своей колонкой, а не выводится
// через запись: файл переживает свою запись — шаг заводит его до
// сохранения, — и заведённый до неё остаётся с владельцем и без ссылки.
//
// Ссылки на аудиозапись внешним ключом нет намеренно, и `record_id`
// здесь — имя подкаталога, где копия лежит. Приём заводит файл **до**
// самой записи, и обязательная связь отвергала бы первую же принятую
// запись.
`CREATE TABLE files (
id TEXT NOT NULL PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id),
record_id TEXT NOT NULL,
file_name TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
format TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`,
`CREATE INDEX idx_files_owner ON files (owner_id)`,
// Словарь тем. Своя таблица, а не набор строк в записи: перечень тем
// человека нужен целиком перед каждым обращением к модели, а собрать его
// из наборов строк можно только перебором всех его записей.
`CREATE TABLE topics (
id TEXT NOT NULL PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id),
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_topics_owner_name ON topics (owner_id, name)`,
// Аудиозапись — центральная сущность. Поля очереди соседствуют с
// доменом, но не с содержимым: расшифровка лежит строкой `texts`, и
// чтение очереди её не тянет.
//
// Колонка владельца обязательна и объявлена внешним ключом: ничьей
// записи не бывает, и держит это схема, а не проверка вызывающего.
// Пустое значение внешнему ключу не отвечает — идентификаторы у учётных
// записей непустые, — поэтому ничью запись отвергает та же связь.
//
// `duration_ms` и `size_bytes` обязательны и различать «неизвестно» и
// «ноль» не обязаны: обе величины ставит приём и ставит всегда — запись,
// метаданные которой прочитать не удалось, отвергается отказом и не
// заводится вовсе. Решение владельца 2026-08-15.
`CREATE TABLE audio_records (
id TEXT NOT NULL PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users (id),
title TEXT,
brief TEXT,
original_filename TEXT,
duration_ms INTEGER NOT NULL,
size_bytes INTEGER NOT NULL,
state TEXT NOT NULL,
state_entered_at TEXT NOT NULL,
halted_at TEXT,
halt_reason TEXT,
error_text TEXT,
acquisition_id TEXT,
acquire_expires_at TEXT,
delay_time TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
original_file_id TEXT REFERENCES files (id),
normalized_file_id TEXT REFERENCES files (id),
transcript_text_id TEXT,
literary_text_id TEXT,
structure_id TEXT,
recognition_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
// Отбор захвата идёт по рубежу, признаку остановки и порядку ленты.
// Индекс заводится здесь, а не потом: применённый шаг схемы не
// переписывается, и добавление индекса стоило бы отдельного шага.
`CREATE INDEX idx_audio_records_acquire
ON audio_records (state, halted_at, created_at, id)`,
// Страница списка сужается владельцем и режется полным ключом
// сортировки — парой «время заведения и ключ записи».
`CREATE INDEX idx_audio_records_owner_page
ON audio_records (owner_id, created_at, id)`,
// Темы записи. Отдельной таблицей связи, а не колонкой-перечнем: у
// набора строк в колонке нет ни связи, ни потолка.
`CREATE TABLE record_topics (
record_id TEXT NOT NULL REFERENCES audio_records (id),
topic_id TEXT NOT NULL REFERENCES topics (id),
PRIMARY KEY (record_id, topic_id)
)`,
`CREATE INDEX idx_record_topics_topic ON record_topics (topic_id)`,
// Потолок числа тем держит схема: без него часовой разговор даёт два
// десятка тем, и словарь распухает за неделю. Число берётся у домена —
// то же самое, которое сервис объявляет приложению.
`CREATE TRIGGER trg_record_topics_limit
BEFORE INSERT ON record_topics
BEGIN
SELECT RAISE(ABORT, 'record has too many topics')
WHERE (
SELECT COUNT(*) FROM record_topics WHERE record_id = NEW.record_id
) >= ` + strconv.Itoa(entity.MaxTopicsPerRecord) + `;
END`,
// Тексты записи. Пара «запись и вид» уникальна: повтор прерванного шага
// иначе завёл бы второй комплект строк, и вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния.
`CREATE TABLE texts (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
kind TEXT NOT NULL,
contents TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_texts_record_kind ON texts (record_id, kind)`,
// Структура реплик. Номер версии нужен потому, что разбор сохранённого
// ответа изменится раньше, чем архив пересчитают.
`CREATE TABLE structures (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
version INTEGER NOT NULL,
contents TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX idx_structures_record_version ON structures (record_id, version)`,
// Попытка распознавания у внешнего провайдера.
//
// Сохранённый ответ лежит **третьим файлом в подкаталоге записи**, а
// здесь стоит только его имя: шаг опроса читает эту строку раз в
// несколько секунд, и ответ на многочасовую запись, положенный колонкой,
// ехал бы в память при каждом опросе.
`CREATE TABLE recognitions (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
provider TEXT NOT NULL,
model TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
source_uri TEXT NOT NULL DEFAULT '',
payload_file TEXT NOT NULL DEFAULT '',
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE INDEX idx_recognitions_record ON recognitions (record_id)`,
// Журнал событий записи. Колонка текста отказа зовётся `outcome_text`, а
// не `error_text`: последнее имя названо поимённо инвариантом проекта о
// секрете, и две колонки с этим именем сделали бы инвариант
// двусмысленным.
`CREATE TABLE record_events (
id TEXT NOT NULL PRIMARY KEY,
record_id TEXT NOT NULL REFERENCES audio_records (id),
origin TEXT NOT NULL,
step TEXT NOT NULL DEFAULT '',
outcome TEXT NOT NULL,
outcome_text TEXT NOT NULL DEFAULT '',
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`,
`CREATE INDEX idx_record_events_record ON record_events (record_id)`,
}
}
@@ -0,0 +1,36 @@
// Package migrations — шаги схемы базы.
//
// Шаг лежит своим файлом, имя файла начинается версией, и **применённый шаг не
// переписывается** — только новым файлом. Инвариант проекта держится так же, как
// держался прежде: изменение схемы это новый шаг, а не правка уехавшего.
//
// Шаги лежат отдельным каталогом, а не файлом внутри пакета хранилища, по
// внешней причине: сверка документов ловит изменённый шаг схемы при нетронутом
// `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции
// `[docs]`), а префикс наводится только на каталог.
//
// Регистрация идёт **перечнем**, а не глобальным списком библиотеки: провайдер
// заводится в точке входа и получает этот перечень доводом, поэтому два
// провайдера в одном процессе — например, сервис и проверка — не спорят за общее
// состояние.
package migrations
import (
"github.com/pressly/goose/v3"
)
// All — шаги схемы в порядке версий.
//
// Порядок исхода от порядка этого перечня не зависит: библиотека сортирует шаги
// по версии сама. Перечень собран ради того, чтобы шаг, добавленный файлом и
// забытый здесь, не оказался незамеченным: незарегистрированный шаг не
// накатывается вовсе.
func All() []*goose.Migration {
return []*goose.Migration{
goose.NewGoMigration(
202608220002,
&goose.GoFunc{RunTx: up202608220002},
&goose.GoFunc{RunTx: down202608220002},
),
}
}
@@ -0,0 +1,163 @@
package sqlite
import (
"context"
"errors"
"fmt"
"io"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// payloadSuffix — окончание имени файла, под которым лежит сохранённый ответ
// провайдера. Имя задаёт сервис, как и у копий аудио.
const payloadSuffix = ".payload"
type RecognitionRepository struct {
db *DB
store *Store
}
func NewRecognitionRepository(db *DB, store *Store) *RecognitionRepository {
return &RecognitionRepository{db: db, store: store}
}
// Create заводит строку попытки **до** обращения к провайдеру.
//
// Порядок здесь несущий: окно между ответом провайдера и записью идентификатора
// операции — то место, где теряется оплаченное. Заведённая заранее строка даёт
// повторному шагу, чем проверить сделанное прежде, чем платить второй раз.
func (repo *RecognitionRepository) Create(r *entity.Recognition) error {
started := clock.Now()
if r.Id == "" {
r.Id = ident.New()
}
now := formatTime(started)
_, err := repo.db.Writer().ExecContext(context.Background(),
`INSERT INTO recognitions
(id, record_id, provider, model, external_id, source_uri, started_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
r.Id, r.RecordID, r.Provider, r.Model, r.ExternalID, r.SourceURI, now, now, now,
)
if err != nil {
return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err)
}
r.StartedAt = &started
return nil
}
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
// последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз
// наружу не платит.
func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error {
_, err := repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET source_uri = ?, external_id = ?, updated_at = ? WHERE id = ?",
sourceURI, externalID, formatTime(clock.Now()), id,
)
if err != nil {
return fmt.Errorf("failed to store operation id of attempt %s: %w", id, err)
}
return nil
}
// Finish кладёт сохранённый ответ провайдера **третьим файлом в подкаталоге
// записи** и отмечает завершение попытки.
//
// Файлом, а не колонкой: шаг опроса читает эту строку раз в несколько секунд, и
// ответ на многочасовую запись, положенный колонкой, ехал бы в память при каждом
// опросе. Хранится он потому, что результат операции у провайдера не
// переспрашивается.
//
// Пустой ответ поверх сохранённого не кладётся — тем же доводом, что и у текста:
// повторный опрос вправе вернуть пустое, и безусловная замена стёрла бы
// сохранённое без возврата.
func (repo *RecognitionRepository) Finish(id string, raw []byte) error {
attempt, err := repo.GetByID(id)
if err != nil {
return err
}
name := id + payloadSuffix
if len(raw) > 0 {
if _, err := repo.store.Put(attempt.RecordID, name, bytesReader(raw)); err != nil {
// Путь к сохранённому ответу наружу не идёт: отказ называет попытку
// её идентификатором.
return errors.Join(fmt.Errorf("failed to store provider payload of attempt %s", id), err)
}
}
finished := formatTime(clock.Now())
if len(raw) > 0 {
_, err = repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET payload_file = ?, finished_at = ?, updated_at = ? WHERE id = ?",
name, finished, finished, id,
)
} else {
_, err = repo.db.Writer().ExecContext(context.Background(),
"UPDATE recognitions SET finished_at = ?, updated_at = ? WHERE id = ?",
finished, finished, id,
)
}
if err != nil {
return fmt.Errorf("failed to store provider payload of attempt %s", id)
}
return nil
}
func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) {
attempt := &entity.Recognition{Id: id}
var startedAt, finishedAt, payloadFile nullString
err := repo.db.Reader().QueryRowContext(context.Background(),
`SELECT record_id, provider, model, external_id, source_uri, payload_file, started_at, finished_at
FROM recognitions WHERE id = ?`, id,
).Scan(
&attempt.RecordID, &attempt.Provider, &attempt.Model,
&attempt.ExternalID, &attempt.SourceURI, &payloadFile,
&startedAt, &finishedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
}
attempt.StartedAt = timeOf(startedAt.NullString)
attempt.FinishedAt = timeOf(finishedAt.NullString)
return attempt, nil
}
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ
// нужен: шаг опроса читает строку попытки без него.
func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) {
attempt, err := repo.GetByID(id)
if err != nil {
return nil, err
}
var payloadFile string
if err := repo.db.Reader().QueryRowContext(context.Background(),
"SELECT payload_file FROM recognitions WHERE id = ?", id,
).Scan(&payloadFile); err != nil {
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
}
if payloadFile == "" {
return nil, fmt.Errorf("recognition attempt %s has no stored payload", id)
}
file, err := repo.store.Open(attempt.RecordID, payloadFile)
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
raw, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read stored payload of attempt %s", id)
}
return raw, nil
}
@@ -0,0 +1,62 @@
package sqlite
import (
"bytes"
"context"
"database/sql"
"fmt"
"io"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// nullString — обёртка ради читаемости выборок: колонка, допускающая пустое
// значение, читается в неё, а домену отдаётся указателем.
type nullString struct {
sql.NullString
}
// bytesReader отдаёт содержимое в памяти потоком: сохранённый ответ провайдера
// приходит целиком байтами, а укладка принимает поток.
func bytesReader(raw []byte) io.Reader {
return bytes.NewReader(raw)
}
type RecordEventRepository struct {
db *DB
}
func NewRecordEventRepository(db *DB) *RecordEventRepository {
return &RecordEventRepository{db: db}
}
// Append пишет строку журнала событий записи.
//
// Журнал пишется на смену рубежа, на остановку и на возврат в работу, а не на
// каждое откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни
// один шаг конвейера его не читает, чтобы решить, что делать дальше: решение
// принимается по рубежу записи, и второй источник решения разошёлся бы с первым
// молча.
//
// Содержимое записи сюда не попадает — инвариант приватности действует здесь
// наравне с журналом сервиса.
func (repo *RecordEventRepository) Append(event *entity.RecordEvent) error {
if event.Id == "" {
event.Id = ident.New()
}
_, err := repo.db.Writer().ExecContext(context.Background(),
`INSERT INTO record_events
(id, record_id, origin, step, outcome, outcome_text, duration_ms, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
event.Id, event.RecordID, event.Origin, event.Step,
event.Outcome, event.OutcomeText, event.DurationMs, formatTime(clock.Now()),
)
if err != nil {
return fmt.Errorf("failed to append event of record %s: %w", event.RecordID, err)
}
return nil
}
+260
View File
@@ -0,0 +1,260 @@
package sqlite
import (
"context"
"fmt"
"strings"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// defaultListLimit — умолчание, к которому приводится непозитивный предел.
// Значение своё, а не занятое у транспорта: адаптер о транспорте не знает.
const defaultListLimit = 30
// List отдаёт страницу записей владельца, новыми сверху.
//
// **Страница берётся ключом, а не смещением.** Приём пишет в голову той же
// ленты, которую читает список, и человек, загрузивший запись и листающий свой
// архив, — штатный сценарий. Смещение сдвинуло бы окно на единицу: последний
// элемент первой страницы пришёл бы вторым разом первым элементом второй, а один
// элемент между ними не пришёл бы никогда — и оба раза молча.
//
// Ключ полный: пара «время заведения и идентификатор». Одного времени мало — у
// записей, принятых одним запросом, оно совпадает, и порядок между ними иначе не
// определён вовсе.
//
// Ни расшифровки, ни структуры реплик выборка не читает: обе лежат порознь от
// записи ровно затем, чтобы список их не тянул. Длительность и размер берутся
// колонками самой записи.
func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.RecordPage, error) {
// Пустой владелец не совпадает ни с одной записью. Правило записано со
// стороны спрашивающего: обязательность, которую держит одна лишь схема,
// пустую строку пропустила бы.
if q.OwnerID == "" {
return &contract.RecordPage{Items: []*entity.AudioRecord{}}, nil
}
// Непозитивный предел приводится к умолчанию, а не роняет процесс: ниже
// стоит обращение по индексу `q.Limit-1`, и нулевой предел дал бы индекс −1.
// Сегодня отсекает его обработчик, но метод — часть интерфейса, и второй
// вызывающий с забытым полем структуры получил бы панику, а восстановления у
// воркеров нет вовсе.
if q.Limit <= 0 {
q.Limit = defaultListLimit
}
conditions := []string{"owner_id = ?"}
args := []any{q.OwnerID}
state, stateArgs, err := stateCondition(q.Filter)
if err != nil {
return nil, err
}
if state != "" {
conditions = append(conditions, state)
args = append(args, stateArgs...)
}
total, err := repo.countRecords(conditions, args)
if err != nil {
return nil, err
}
pageConditions := conditions
pageArgs := args
// Курсор режет ленту по паре: строго раньше по времени, а при равном времени
// — строго меньше по идентификатору.
if q.Cursor != nil {
pageConditions = append(append([]string{}, conditions...),
"(created_at < ? OR (created_at = ? AND id < ?))")
cursorTime := formatTime(q.Cursor.CreatedAt)
pageArgs = append(append([]any{}, args...),
cursorTime, cursorTime, q.Cursor.ID)
}
row := &recordRow{}
columns, targets := selectList(readRecordColumns(row), "")
// Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли
// следующая страница» без второго запроса и без вычислений по общему числу,
// которое к этому моменту могло измениться.
query := "SELECT " + columns + " FROM " + recordsTable +
" WHERE " + strings.Join(pageConditions, " AND ") +
" ORDER BY created_at DESC, id DESC LIMIT ?"
pageArgs = append(pageArgs, q.Limit+1)
rows, err := repo.db.Reader().QueryContext(context.Background(), query, pageArgs...)
if err != nil {
return nil, fmt.Errorf("failed to list audio records: %w", err)
}
defer func() { _ = rows.Close() }()
items := []*entity.AudioRecord{}
for rows.Next() {
if err := rows.Scan(targets...); err != nil {
return nil, fmt.Errorf("failed to read an audio record of the page: %w", err)
}
items = append(items, rowToAudioRecord(row))
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read the page of audio records: %w", err)
}
page := &contract.RecordPage{TotalItems: total}
if len(items) > q.Limit {
last := items[q.Limit-1]
page.NextCursor = &contract.RecordCursor{
CreatedAt: last.CreatedAt,
ID: last.Id,
}
items = items[:q.Limit]
}
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, item.Id)
}
topics, err := repo.topicsOf(ids)
if err != nil {
return nil, err
}
for _, item := range items {
item.TopicIDs = topics[item.Id]
}
page.Items = items
return page, nil
}
// stateCondition переводит состояние отбора в условие запроса.
//
// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор
// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе
// молча поменял бы состав всех трёх состояний.
func stateCondition(filter *entity.ListFilter) (string, []any, error) {
if filter == nil {
return "", nil, nil
}
switch *filter {
case entity.ListFilterHalted:
return "halted_at IS NOT NULL", nil, nil
case entity.ListFilterWorking:
condition, args := stateIn(entity.WorkingStages())
return "halted_at IS NULL AND " + condition, args, nil
case entity.ListFilterDone:
condition, args := stateIn(entity.TerminalStages())
return "halted_at IS NULL AND " + condition, args, nil
}
// Ветвь отказа, а не молчаливое «без сужения»: значение, добавленное в
// перечень состояний и забытое здесь, иначе вернуло бы человеку весь архив
// под именем отбора — и заметить это было бы нечем.
return "", nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter)
}
func stateIn(stages []entity.Stage) (string, []any) {
names := entity.StageNames(stages)
args := make([]any, 0, len(names))
placeholders := make([]string, 0, len(names))
for _, name := range names {
args = append(args, name)
placeholders = append(placeholders, "?")
}
return "state IN (" + strings.Join(placeholders, ", ") + ")", args
}
func (repo *AudioRecordRepository) countRecords(conditions []string, args []any) (int, error) {
var total int
query := "SELECT COUNT(*) FROM " + recordsTable + " WHERE " + strings.Join(conditions, " AND ")
if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(&total); err != nil {
return 0, fmt.Errorf("failed to count audio records: %w", err)
}
return total, nil
}
// topicsOf читает темы страницы **одним запросом**: страница в сотню записей
// иначе стоила бы сотни обращений к базе.
func (repo *AudioRecordRepository) topicsOf(recordIDs []string) (map[string][]string, error) {
out := map[string][]string{}
if len(recordIDs) == 0 {
return out, nil
}
placeholders := make([]string, 0, len(recordIDs))
args := make([]any, 0, len(recordIDs))
for _, id := range recordIDs {
placeholders = append(placeholders, "?")
args = append(args, id)
}
query := "SELECT record_id, topic_id FROM record_topics WHERE record_id IN (" +
strings.Join(placeholders, ", ") + ") ORDER BY topic_id"
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
if err != nil {
return nil, fmt.Errorf("failed to read record topics: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var recordID, topicID string
if err := rows.Scan(&recordID, &topicID); err != nil {
return nil, fmt.Errorf("failed to read a record topic: %w", err)
}
out[recordID] = append(out[recordID], topicID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read record topics: %w", err)
}
return out, nil
}
// ResolveTopicNames разрешает темы названиями **одним запросом на страницу**, а
// не по запросу на запись.
//
// Названия, а не идентификаторы, потому что экран показывает названия: отдай мы
// ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради
// чего контракт согласуется один раз.
//
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека — пара
// «владелец и название» уникальна, — и разрешение без сужения отдало бы название
// чужой темы, как только темы начнёт писать языковая модель.
func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) {
out := map[string]string{}
if len(ids) == 0 || ownerID == "" {
return out, nil
}
placeholders := make([]string, 0, len(ids))
args := []any{ownerID}
for _, id := range ids {
placeholders = append(placeholders, "?")
args = append(args, id)
}
query := "SELECT id, name FROM topics WHERE owner_id = ? AND id IN (" +
strings.Join(placeholders, ", ") + ")"
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
if err != nil {
return nil, fmt.Errorf("failed to resolve topics: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err != nil {
return nil, fmt.Errorf("failed to read a topic: %w", err)
}
out[id] = name
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to resolve topics: %w", err)
}
return out, nil
}
@@ -0,0 +1,173 @@
package sqlite
import (
"database/sql"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Отображение аудиозаписи в строку базы и обратно живёт одним местом.
//
// Работает оно **по имени колонки**: именованные параметры запроса и место
// назначения, найденное по имени. Причина в самой сущности — у аудиозаписи поля
// одного типа идут длинным непрерывным рядом, и ссылки на файл, на структуру
// реплик, на два вида текста и на попытку распознавания стоят в нём подряд.
// Позиционный список дал бы сдвиг на одно поле, который компилируется молча и
// кладёт идентификатор файла в колонку текста. По имени такого сдвига не
// существует вовсе: лишнее имя или недостающее — отказ запроса, а не тихая
// подмена значения.
//
// Инвариант проекта о колонках записи эта форма не снимает: колонку по-прежнему
// можно забыть в отображении или в шаге схемы, и сверку держат правила
// `internal/archrules`.
// writeOwnedByPipeline — колонки, которыми распоряжается конвейер.
//
// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до
// своего сохранения, а это часы. Всё, что владелец правил за это время,
// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа
// тому, кто правил. Владелец, заголовок, краткое описание, имя файла
// отправителя, длительность, размер и темы не трогаются вовсе.
func writeOwnedByPipeline(r *entity.AudioRecord) map[string]any {
return map[string]any{
"state": r.State,
"state_entered_at": formatTime(r.StateEnteredAt),
"halted_at": timeValue(r.HaltedAt),
"halt_reason": stringValue(r.HaltReason),
"error_text": stringValue(r.ErrorText),
"acquisition_id": stringValue(r.AcquisitionID),
"acquire_expires_at": timeValue(r.AcquireExpiresAt),
"delay_time": timeValue(r.DelayTime),
"attempts": r.Attempts,
"original_file_id": stringValue(r.OriginalFileID),
"normalized_file_id": stringValue(r.NormalizedFileID),
"transcript_text_id": stringValue(r.TranscriptTextID),
"literary_text_id": stringValue(r.LiteraryTextID),
"structure_id": stringValue(r.StructureID),
"recognition_id": stringValue(r.RecognitionID),
"updated_at": formatTime(r.UpdatedAt),
}
}
// writeRecord — запись целиком: это заведение, и спорить за поля здесь не с кем.
//
// Колонки заведения перечислены той же формой, что и колонки конвейера, — картой
// «имя колонки в значение»: сверка колонок в `internal/archrules` читает именно
// её, и колонка, положенная присваиванием мимо карты, выпала бы из-под правила
// молча.
func writeRecord(r *entity.AudioRecord) map[string]any {
values := writeOwnedByPipeline(r)
own := map[string]any{
"id": r.Id,
// Владелец кладётся только здесь, при заведении. В перечне конвейера его
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок
// шага, записанный поверх, стёр бы его молча.
"owner_id": r.OwnerID,
"title": stringValue(r.Title),
"brief": stringValue(r.Brief),
// Имя файла отправителя, длительность и размер кладёт приём и только он:
// это снимок принятого, и конвейер его не пересчитывает.
"original_filename": stringValue(r.OriginalFilename),
"duration_ms": numberValue(r.DurationMs),
"size_bytes": numberValue(r.SizeBytes),
"created_at": formatTime(r.CreatedAt),
}
for name, value := range own {
values[name] = value
}
return values
}
// recordRow — сырые значения одной строки аудиозаписи.
type recordRow struct {
id string
ownerID string
title sql.NullString
brief sql.NullString
originalFilename sql.NullString
durationMs int64
sizeBytes int64
state string
stateEnteredAt string
haltedAt sql.NullString
haltReason sql.NullString
errorText sql.NullString
acquisitionID sql.NullString
acquireExpiresAt sql.NullString
delayTime sql.NullString
attempts int
originalFileID sql.NullString
normalizedFileID sql.NullString
transcriptTextID sql.NullString
literaryTextID sql.NullString
structureID sql.NullString
recognitionID sql.NullString
createdAt string
updatedAt string
}
// readRecordColumns — куда кладётся каждая колонка при чтении.
//
// Перечень колонок выборки собирается из этой же карты, поэтому расхождению
// между тем, что спрошено, и тем, куда оно ляжет, взяться неоткуда.
func readRecordColumns(row *recordRow) map[string]any {
return map[string]any{
"id": &row.id,
"owner_id": &row.ownerID,
"title": &row.title,
"brief": &row.brief,
"original_filename": &row.originalFilename,
"duration_ms": &row.durationMs,
"size_bytes": &row.sizeBytes,
"state": &row.state,
"state_entered_at": &row.stateEnteredAt,
"halted_at": &row.haltedAt,
"halt_reason": &row.haltReason,
"error_text": &row.errorText,
"acquisition_id": &row.acquisitionID,
"acquire_expires_at": &row.acquireExpiresAt,
"delay_time": &row.delayTime,
"attempts": &row.attempts,
"original_file_id": &row.originalFileID,
"normalized_file_id": &row.normalizedFileID,
"transcript_text_id": &row.transcriptTextID,
"literary_text_id": &row.literaryTextID,
"structure_id": &row.structureID,
"recognition_id": &row.recognitionID,
"created_at": &row.createdAt,
"updated_at": &row.updatedAt,
}
}
// rowToAudioRecord собирает доменную запись из прочитанной строки.
func rowToAudioRecord(row *recordRow) *entity.AudioRecord {
return &entity.AudioRecord{
Id: row.id,
OwnerID: row.ownerID,
Title: stringOf(row.title),
Brief: stringOf(row.brief),
OriginalFilename: stringOf(row.originalFilename),
DurationMs: numberOf(row.durationMs),
SizeBytes: numberOf(row.sizeBytes),
State: row.state,
StateEnteredAt: requiredTimeOf(row.stateEnteredAt),
HaltedAt: timeOf(row.haltedAt),
HaltReason: stringOf(row.haltReason),
ErrorText: stringOf(row.errorText),
AcquisitionID: stringOf(row.acquisitionID),
AcquireExpiresAt: timeOf(row.acquireExpiresAt),
DelayTime: timeOf(row.delayTime),
Attempts: row.attempts,
OriginalFileID: stringOf(row.originalFileID),
NormalizedFileID: stringOf(row.normalizedFileID),
TranscriptTextID: stringOf(row.transcriptTextID),
LiteraryTextID: stringOf(row.literaryTextID),
StructureID: stringOf(row.structureID),
RecognitionID: stringOf(row.recognitionID),
TopicIDs: []string{},
CreatedAt: requiredTimeOf(row.createdAt),
UpdatedAt: requiredTimeOf(row.updatedAt),
}
}
+237
View File
@@ -0,0 +1,237 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// recordsTable — таблица аудиозаписей.
const recordsTable = "audio_records"
type AudioRecordRepository struct {
db *DB
}
func NewAudioRecordRepository(db *DB) *AudioRecordRepository {
return &AudioRecordRepository{db: db}
}
// Create заводит аудиозапись.
//
// Идентификатор приходит готовым, когда его назначил вызывающий: приём знает его
// раньше, чем кладёт файл, — копии записи лежат подкаталогом под этим самым
// идентификатором. Пустой заполняется единой точкой выдачи.
func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
if r.Id == "" {
r.Id = ident.New()
}
now := clock.Now()
if r.CreatedAt.IsZero() {
r.CreatedAt = now
}
r.UpdatedAt = now
if r.StateEnteredAt.IsZero() {
r.StateEnteredAt = now
}
query, args := insertSQL(recordsTable, writeRecord(r))
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
return fmt.Errorf("failed to insert audio record: %w", err)
}
return nil
}
// Save сохраняет запись, захват которой держит holder.
//
// Сверка захвата и запись идут **одним запросом**: значение признака стоит
// условием правки, поэтому между проверкой и записью не остаётся окна. Сверяется
// именно значение, а не занятость записи — захват, перевыданный другому по
// протуханию срока или после того, как человек вернул запись в работу, обязан
// обратить запись первого в отказ; условие по непустоте признака пропустило бы
// обоих, и два шага записали бы в одну запись по очереди, портя её результат.
//
// Пустой holder снимает условность и в конвейере не употребляется: все его шаги
// получают признак захвата от FindAndAcquire.
func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
r.UpdatedAt = clock.Now()
where := "id = :id"
whereArgs := []any{sql.Named("id", r.Id)}
if holder != "" {
where += " AND acquisition_id = :holder"
whereArgs = append(whereArgs, sql.Named("holder", holder))
}
query, args := updateSQL(recordsTable, writeOwnedByPipeline(r), where, whereArgs)
result, err := repo.db.Writer().ExecContext(context.Background(), query, args...)
if err != nil {
return fmt.Errorf("failed to update audio record: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to read the outcome of an audio record update: %w", err)
}
if affected > 0 {
return nil
}
// Строк не тронуто по одной из двух причин, и различить их можно только
// чтением: записи нет вовсе либо захват достался другому. Разница несущая —
// первая означает поломку, вторая штатный исход шага, потерявшего запись.
if _, err := repo.Get(r.Id); err != nil {
return err
}
return &contract.LostAcquisitionError{JobID: r.Id}
}
// GetByID отдаёт запись, только если её владелец — ownerID.
//
// Чужая запись и несуществующая дают одну и ту же ошибку: по разнице ответов
// иначе перебирается список заведённых записей, а идентификатор записи и есть
// то, что разграничение прячет.
//
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило не
// стало избыточным с обязательностью колонки: схема запрещает **заводить** ничью
// запись, а здесь запрещено **спрашивать** ничьим именем — иначе вызывающий без
// учётной записи получил бы выборку вместо отказа.
func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
if ownerID == "" {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
record, err := repo.read("id = ? AND owner_id = ?", id, ownerID)
if err != nil {
return nil, err
}
return record, nil
}
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья выборка
// владельцем не сужается.
func (repo *AudioRecordRepository) Get(id string) (*entity.AudioRecord, error) {
return repo.read("id = ?", id)
}
// read читает одну запись по условию.
func (repo *AudioRecordRepository) read(where string, args ...any) (*entity.AudioRecord, error) {
row := &recordRow{}
columns, targets := selectList(readRecordColumns(row), "")
query := "SELECT " + columns + " FROM " + recordsTable + " WHERE " + where + " LIMIT 1"
if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(targets...); err != nil {
// «Такой записи нет» переводится в доменную ошибку здесь, у источника,
// как велит конвенция об ошибках. Иначе исходы, которые разграничение
// обязано сделать неразличимыми, разъезжаются: чужая запись даёт
// доменную ошибку, а несуществующая — отказ базы, неотличимый от
// настоящей аварии хранилища.
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
return nil, fmt.Errorf("failed to get audio record: %w", err)
}
record := rowToAudioRecord(row)
topics, err := repo.topicsOf([]string{record.Id})
if err != nil {
return nil, err
}
record.TopicIDs = topics[record.Id]
return record, nil
}
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом:
// выбор подходящей и пометка её захваченной идут вместе, одним оператором с
// возвратом.
//
// Возвращается **идентификатор и признак этого захвата**, а не перечень колонок.
// Колонки шаг читает обычным чтением: иначе всякая новая колонка записи попадала
// бы под инвариант проекта о колонках очереди, а забытая приезжала бы нулевой, и
// первое же сохранение писало бы этот ноль поверх сохранённого значения.
//
// Срок протухания захвата приезжает **с рубежом**, а не с воркером: воркер не
// привязан к шагу и не знает заранее, что вытянет. Перечень рубежей и их сроков
// приходит одним дескриптором — перечислять их порознь нельзя: рубеж, забытый в
// отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
// проекта не пишется в журнал и не считается в метрику.
//
// Запрос идёт по **пишущему** соединению: он читает состояние, которое сам же
// меняет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
// повышает.
func (repo *AudioRecordRepository) FindAndAcquire(stages []entity.Stage) (*contract.AcquiredRecord, error) {
if len(stages) == 0 {
return nil, &contract.JobNotFoundError{Message: "no working stages declared"}
}
now := clock.Now()
holder := ident.New()
args := []any{
sql.Named("holder", holder),
sql.Named("now", formatTime(now)),
}
// Срок протухания у каждого рубежа свой, поэтому он выбирается по рубежу
// самой записи прямо в запросе: воркер, ещё не знающий, что вытянет,
// подставить его не может.
var expiry strings.Builder
expiry.WriteString("CASE state")
states := make([]string, 0, len(stages))
for i, stage := range stages {
stateKey := fmt.Sprintf("state%d", i)
expiryKey := fmt.Sprintf("expiry%d", i)
fmt.Fprintf(&expiry, " WHEN :%s THEN :%s", stateKey, expiryKey)
args = append(args,
sql.Named(stateKey, stage.Name),
sql.Named(expiryKey, formatTime(now.Add(stage.AcquireTimeout))),
)
states = append(states, ":"+stateKey)
}
expiry.WriteString(" END")
// Порядок выборки определён однозначно: время заведения плюс ключ записи.
// Сравнения по неуникальному значению для этого мало — порядок обработки
// стал бы невоспроизводимым.
query := `
UPDATE ` + recordsTable + `
SET acquisition_id = :holder,
acquire_expires_at = ` + expiry.String() + `,
attempts = attempts + 1,
updated_at = :now
WHERE id = (
SELECT id FROM ` + recordsTable + `
WHERE state IN (` + strings.Join(states, ", ") + `)
AND halted_at IS NULL
AND (delay_time IS NULL OR delay_time < :now)
AND (acquisition_id IS NULL
OR acquire_expires_at IS NULL
OR acquire_expires_at < :now)
ORDER BY created_at, id
LIMIT 1
)
RETURNING id`
var id string
if err := repo.db.Writer().QueryRowContext(context.Background(), query, args...).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, &contract.JobNotFoundError{Message: "no record is ready for work"}
}
return nil, fmt.Errorf("failed to acquire an audio record: %w", err)
}
return &contract.AcquiredRecord{ID: id, Holder: holder}, nil
}
+626
View File
@@ -0,0 +1,626 @@
package sqlite
import (
"context"
"log/slog"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// newWorkingRecord заводит запись, пригодную к захвату.
func newWorkingRecord(t *testing.T, db *DB, owner string) *entity.AudioRecord {
t.Helper()
record := &entity.AudioRecord{
Id: ident.New(),
OwnerID: owner,
State: entity.StateUploaded,
StateEnteredAt: clock.Now(),
}
require.NoError(t, NewAudioRecordRepository(db).Create(record))
return record
}
// Ничьей записи не бывает, и держит это схема: колонка владельца объявлена
// связью с учётной записью и пустого значения не принимает.
func TestRecordWithoutOwnerIsRejected(t *testing.T) {
db, _, _ := newTestDB(t)
records := NewAudioRecordRepository(db)
t.Run("пустой владелец", func(t *testing.T) {
err := records.Create(&entity.AudioRecord{
Id: ident.New(), State: entity.StateUploaded, StateEnteredAt: clock.Now(),
})
assert.Error(t, err, "запись с пустым владельцем сохранилась")
})
t.Run("владельца нет среди учётных записей", func(t *testing.T) {
err := records.Create(&entity.AudioRecord{
Id: ident.New(), OwnerID: ident.New(),
State: entity.StateUploaded, StateEnteredAt: clock.Now(),
})
assert.Error(t, err, "запись с выдуманным владельцем сохранилась")
})
t.Run("запросом к базе тоже", func(t *testing.T) {
now := clock.Now().Format(timeLayout)
_, err := db.Writer().ExecContext(context.Background(),
`INSERT INTO audio_records
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
VALUES (?, '', 0, 0, ?, ?, ?, ?)`,
ident.New(), entity.StateUploaded, now, now, now,
)
assert.Error(t, err, "ничья запись завелась запросом к базе")
})
}
// Файл без владельца не сохраняется — тем же правилом схемы.
func TestFileWithoutOwnerIsRejected(t *testing.T) {
db, store, _ := newTestDB(t)
files := NewFileRepository(db, store)
work, err := files.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, "")
assert.Error(t, err, "файл с пустым владельцем сохранился")
}
// Учётная запись, у которой остались аудиозаписи, файлы либо темы, не удаляется:
// запрет держит схема обязательной связью, а не проверка вызывающего.
func TestAccountWithBelongingsIsNotDeletable(t *testing.T) {
db, store, _ := newTestDB(t)
t.Run("с аудиозаписями", func(t *testing.T) {
owner := newOwner(t, db)
newWorkingRecord(t, db, owner)
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.Error(t, err, "учётная запись с аудиозаписями удалилась")
})
t.Run("с одними файлами", func(t *testing.T) {
owner := newOwner(t, db)
files := NewFileRepository(db, store)
work, err := files.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }()
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
require.NoError(t, err)
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.Error(t, err, "учётная запись с файлами удалилась")
})
t.Run("с одними темами", func(t *testing.T) {
owner := newOwner(t, db)
now := clock.Now().Format(timeLayout)
_, err := db.Writer().ExecContext(context.Background(),
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
ident.New(), owner, "тема", now, now,
)
require.NoError(t, err)
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.Error(t, err, "учётная запись с темами удалилась")
})
t.Run("пустая удаляется", func(t *testing.T) {
owner := newOwner(t, db)
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
assert.NoError(t, err, "учётная запись без принадлежностей не удалилась")
})
}
// У записи не больше пяти тем, и держит это схема.
func TestRecordTopicsAreCapped(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
now := clock.Now().Format(timeLayout)
for i := range entity.MaxTopicsPerRecord + 1 {
topicID := ident.New()
_, err := db.Writer().ExecContext(context.Background(),
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
topicID, owner, "тема-"+topicID, now, now,
)
require.NoError(t, err)
_, err = db.Writer().ExecContext(context.Background(),
"INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", record.Id, topicID,
)
if i < entity.MaxTopicsPerRecord {
require.NoError(t, err, "тема %d не назначилась", i)
continue
}
assert.Error(t, err, "шестая тема назначилась")
}
}
// TestAcquireGivesRecordToExactlyOne — **критерий приёмки**: захват неделим.
//
// Одна пригодная запись, несколько захватов разом: запись достаётся ровно
// одному, остальные получают признак «работы сейчас нет».
func TestAcquireGivesRecordToExactlyOne(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
const racers = 8
var (
mu sync.Mutex
acquired []*contract.AcquiredRecord
empty int
)
start := make(chan struct{})
var wg sync.WaitGroup
for range racers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
got, err := records.FindAndAcquire(entity.WorkingStages())
mu.Lock()
defer mu.Unlock()
var missing *contract.JobNotFoundError
switch {
case err == nil:
acquired = append(acquired, got)
case assert.ErrorAs(t, err, &missing):
empty++
}
}()
}
close(start)
wg.Wait()
require.Len(t, acquired, 1, "запись досталась не одному захвату")
assert.Equal(t, racers-1, empty, "остальные получили не признак «работы нет»")
assert.Equal(t, record.Id, acquired[0].ID)
assert.NotEmpty(t, acquired[0].Holder, "захват не отдал своего признака")
}
// Результат пишет только держатель захвата, и держатель узнаётся **значением**
// признака, а не занятостью записи.
func TestSaveIsConditionalOnHolderValue(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
first, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
// Захват уходит другому: срок протухает, и запись достаётся следующему.
_, err = db.Writer().ExecContext(context.Background(),
"UPDATE audio_records SET acquire_expires_at = ? WHERE id = ?",
clock.Now().Add(-time.Hour).Format(timeLayout), record.Id,
)
require.NoError(t, err)
second, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
assert.NotEqual(t, first.Holder, second.Holder, "признак перевыданного захвата совпал с прежним")
// Прежний держатель пишет свой результат — и не пишет.
stale, err := records.Get(record.Id)
require.NoError(t, err)
stale.MoveToState(entity.StateNormalized)
err = records.Save(stale, first.Holder)
var lost *contract.LostAcquisitionError
require.ErrorAs(t, err, &lost, "шаг, потерявший захват, записал результат")
after, err := records.Get(record.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateUploaded, after.State, "чужая запись изменила состояние")
}
// Правка владельца переживает сохранение шага: конвейер пишет только те поля,
// которыми распоряжается сам.
func TestPipelineSaveKeepsOwnerFields(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
acquired, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err)
held, err := records.Get(record.Id)
require.NoError(t, err)
// Владелец за это время правит поле, которого шаг не касается.
_, err = db.Writer().ExecContext(context.Background(), "UPDATE audio_records SET title = ? WHERE id = ?", "название", record.Id)
require.NoError(t, err)
held.MoveToState(entity.StateNormalized)
require.NoError(t, records.Save(held, acquired.Holder))
after, err := records.Get(record.Id)
require.NoError(t, err)
assert.Equal(t, entity.StateNormalized, after.State, "результат шага не записан")
require.NotNil(t, after.Title)
assert.Equal(t, "название", *after.Title, "правка владельца стёрта снимком шага")
}
// Составная операция, которая читает и следом пишет, идёт по пишущему
// соединению и по занятости базы не отказывает — сколько бы потоков её ни вело.
func TestComposedOperationsDoNotFailOnBusyDatabase(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
texts := NewTextRepository(db)
var wg sync.WaitGroup
start := make(chan struct{})
for i := range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
_, err := texts.Put(record.Id, entity.TextKindTranscript, "разбор")
assert.NoError(t, err, "поток %d отказал по занятости базы", i)
}()
}
close(start)
wg.Wait()
var count int
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM texts").Scan(&count))
assert.Equal(t, 1, count, "восемь потоков завели больше одной строки текста")
}
// Пустая замена не стирает ни сохранённый текст, ни сохранённый ответ
// провайдера: повторный опрос вправе вернуть пустое, и безусловная замена
// стёрла бы расшифровку живого человека без следа.
func TestEmptyReplacementKeepsStoredResult(t *testing.T) {
db, store, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
texts := NewTextRepository(db)
stored, err := texts.Put(record.Id, entity.TextKindTranscript, "живая расшифровка")
require.NoError(t, err)
again, err := texts.Put(record.Id, entity.TextKindTranscript, "")
require.NoError(t, err)
assert.Equal(t, stored.Id, again.Id, "строка та же")
read, err := texts.GetByID(stored.Id)
require.NoError(t, err)
assert.Equal(t, "живая расшифровка", read.Contents, "пустое стёрло сохранённую расшифровку")
structures := NewStructureRepository(db)
first, err := structures.Put(record.Id, entity.StructureVersion,
[]entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
require.NoError(t, err)
_, err = structures.Put(record.Id, entity.StructureVersion, nil)
require.NoError(t, err)
structure, err := structures.GetByID(first.Id)
require.NoError(t, err)
assert.Len(t, structure.Replicas, 1, "пустой разбор стёр сохранённые реплики")
recognitions := NewRecognitionRepository(db, store)
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
require.NoError(t, recognitions.Create(attempt))
require.NoError(t, recognitions.Finish(attempt.Id, []byte("ответ провайдера")))
require.NoError(t, recognitions.Finish(attempt.Id, nil))
raw, err := recognitions.ReadRaw(attempt.Id)
require.NoError(t, err)
assert.Equal(t, "ответ провайдера", string(raw), "пустое стёрло сохранённый ответ провайдера")
}
// Сохранённый ответ провайдера лежит третьим файлом в подкаталоге записи, и шаг
// опроса читает строку попытки без него.
func TestProviderPayloadLivesInRecordDirectory(t *testing.T) {
db, store, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
recognitions := NewRecognitionRepository(db, store)
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
require.NoError(t, recognitions.Create(attempt))
require.NoError(t, recognitions.Finish(attempt.Id, []byte("полный ответ провайдера")))
// Строка попытки читается без ответа: он не колонка.
read, err := recognitions.GetByID(attempt.Id)
require.NoError(t, err)
require.NotNil(t, read.FinishedAt)
file, err := store.Open(record.Id, attempt.Id+payloadSuffix)
require.NoError(t, err)
require.NoError(t, file.Close())
}
// Два одновременных первых обращения одним значением дают ровно одну учётную
// запись: уникальность держит схема, а не порядок обращений.
func TestConcurrentFirstRequestsGiveOneAccount(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
login := ident.New()
var (
mu sync.Mutex
ids = map[string]bool{}
)
start := make(chan struct{})
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
<-start
account, _, err := users.EnsureUser(contract.Identity{Login: login})
if assert.NoError(t, err) {
mu.Lock()
ids[account.ID] = true
mu.Unlock()
}
}()
}
close(start)
wg.Wait()
assert.Len(t, ids, 1, "одновременные первые обращения дали разные учётные записи")
var rows int
require.NoError(t, db.Reader().
QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM users WHERE provider_login = ?", login).Scan(&rows))
assert.Equal(t, 1, rows, "в таблице пользователей больше одной строки")
}
// Занятая почта не мешает завести запись: она необязательна и ключом не служит.
func TestBusyEmailDoesNotBlockAccount(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
first, _, err := users.EnsureUser(contract.Identity{Login: "one", Email: "shared@example.com"})
require.NoError(t, err)
second, created, err := users.EnsureUser(contract.Identity{Login: "two", Email: "shared@example.com"})
require.NoError(t, err)
require.True(t, created)
assert.NotEqual(t, first.ID, second.ID)
var email string
require.NoError(t, db.Reader().
QueryRowContext(context.Background(),
"SELECT email FROM users WHERE id = ?", second.ID).Scan(&email))
assert.Empty(t, email, "вторая запись завелась с чужой почтой")
}
// Найденную запись повторное обращение не переписывает: иначе правка имени у
// провайдера меняла бы карточку человека молча, посреди его работы.
func TestSecondRequestDoesNotRewriteAccount(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
first, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Первое имя"})
require.NoError(t, err)
require.True(t, created)
again, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Второе имя"})
require.NoError(t, err)
assert.False(t, created)
assert.Equal(t, first.ID, again.ID)
assert.Equal(t, "Первое имя", again.Name, "имя переписано вторым обращением")
}
// Узнавание известного не берёт пишущего соединения: поиск идёт читающим пулом,
// и занятый писатель его не держит.
//
// Проверка нужна потому, что цена ошибки здесь не видна ни отказом, ни строкой в
// журнале. Слой узнавания одет на весь корень приложения, поэтому пишущая
// транзакция, взятая до поиска, досталась бы всему узнанному потоку — опросу
// карточки раз в четыре секунды и каждому запросу диапазона при проигрывании, —
// и встала бы в очередь к единственному пишущему соединению. Замером триажа
// такое ожидание доходило до сотен миллисекунд и **отказом не кончалось**:
// очередь к соединению ожиданием занятой базы не ограничена.
func TestKnownAccountDoesNotTakeWriter(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
login := ident.New()
first, created, err := users.EnsureUser(contract.Identity{Login: login})
require.NoError(t, err)
require.True(t, created)
// Писателя занимает открытая транзакция: соединение у пишущего пула одно,
// поэтому пока она держится, второй транзакции не начаться.
tx, err := db.Writer().BeginTx(context.Background(), nil)
require.NoError(t, err)
defer func() { _ = tx.Rollback() }()
type answer struct {
account *contract.UserAccount
created bool
err error
took time.Duration
}
done := make(chan answer, 1)
go func() {
started := time.Now()
account, created, err := users.EnsureUser(contract.Identity{Login: login})
done <- answer{account: account, created: created, err: err, took: time.Since(started)}
}()
// Предел взят с запасом: запрос читающим пулом идёт по месту, а ждущее
// узнавание не дождётся вовсе — транзакцию отпускают уже после проверки.
const limit = time.Second
select {
case got := <-done:
require.NoError(t, got.err)
assert.False(t, got.created, "узнавание известного завело вторую учётную запись")
assert.Equal(t, first.ID, got.account.ID)
t.Logf("узнавание известного заняло %s при занятом писателе", got.took)
case <-time.After(limit):
t.Errorf("узнавание известного ждёт писателя дольше %s", limit)
}
}
// Негодный логин учётной записи не заводит: пустой заголовок прокси шлёт штатно
// там, где никого не назвал.
func TestUnacceptableLoginCreatesNothing(t *testing.T) {
db, _, _ := newTestDB(t)
users := NewUserRepository(db)
for name, login := range map[string]string{
"пустой": "",
"одни пробелы": " ",
"управляющий знак": "ali\x00ce",
"длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1),
} {
t.Run(name, func(t *testing.T) {
_, _, err := users.EnsureUser(contract.Identity{Login: login})
assert.ErrorIs(t, err, contract.ErrLoginNotAcceptable)
})
}
var rows int
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&rows))
assert.Equal(t, 0, rows, "негодный логин завёл учётную запись")
}
// TestResumeReleasesEveryGuard — то, что делает подкоманда возврата в работу.
//
// Возврат идёт **через домен**: тот, кто его делает, называет запись, а поля
// сбрасывает домен одним действием. Перечень назван целиком, потому что забытое
// поле не даёт ни отказа, ни строки в журнале: оставленный признак захвата
// держит запись занятой до протухания срока, оставленное время входа в рубеж
// останавливает её снова первым же захватом, а оставленные отказы и пауза
// откладывают первый прогон на накопленный срок.
func TestResumeReleasesEveryGuard(t *testing.T) {
db, _, _ := newTestDB(t)
owner := newOwner(t, db)
record := newWorkingRecord(t, db, owner)
records := NewAudioRecordRepository(db)
events := NewRecordEventRepository(db)
// Так выглядит запись, остановленная после долгих отказов: захват на ней
// стоит, срок его далеко впереди, отказы накоплены, пауза назначена, а в
// рубеже она простояла дольше предела.
_, err := db.Writer().ExecContext(context.Background(), `
UPDATE audio_records
SET halted_at = ?, halt_reason = ?, error_text = ?,
acquisition_id = ?, acquire_expires_at = ?,
delay_time = ?, attempts = 7, state_entered_at = ?
WHERE id = ?`,
clock.Now().Format(timeLayout), entity.HaltReasonAttempts, "исчерпаны отказы",
ident.New(), clock.Now().Add(8*time.Hour).Format(timeLayout),
clock.Now().Add(time.Hour).Format(timeLayout),
clock.Now().Add(-48*time.Hour).Format(timeLayout),
record.Id,
)
require.NoError(t, err)
// Остановленная запись захвату не выдаётся.
_, err = records.FindAndAcquire(entity.WorkingStages())
var missing *contract.JobNotFoundError
require.ErrorAs(t, err, &missing, "остановленная запись досталась захвату")
halted, err := records.Get(record.Id)
require.NoError(t, err)
halted.Resume()
require.NoError(t, records.Save(halted, ""))
require.NoError(t, events.Append(&entity.RecordEvent{
RecordID: record.Id,
Origin: entity.EventOriginHuman,
Step: "resume",
Outcome: entity.EventOutcomeResumed,
}))
after, err := records.Get(record.Id)
require.NoError(t, err)
assert.False(t, after.IsHalted(), "признак остановки остался")
assert.Nil(t, after.HaltReason, "причина остановки осталась")
assert.Nil(t, after.ErrorText, "машинный текст отказа остался")
assert.Nil(t, after.AcquisitionID, "признак захвата остался")
assert.Nil(t, after.AcquireExpiresAt, "срок протухания захвата остался")
assert.Nil(t, after.DelayTime, "пауза перед повтором осталась")
assert.Equal(t, 0, after.Attempts, "число отказов осталось")
assert.WithinDuration(t, clock.Now(), after.StateEnteredAt, time.Minute,
"время входа в рубеж не сброшено: запись остановится снова первым же захватом")
assert.Equal(t, entity.StateUploaded, after.State, "рубеж не пережил возврата в работу")
// Ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока.
acquired, err := records.FindAndAcquire(entity.WorkingStages())
require.NoError(t, err, "возвращённая в работу запись захвату не досталась")
assert.Equal(t, record.Id, acquired.ID)
// И возврат виден в журнале событий записи — происхождением «человек».
var origin, outcome string
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
"SELECT origin, outcome FROM record_events WHERE record_id = ?", record.Id,
).Scan(&origin, &outcome))
assert.Equal(t, entity.EventOriginHuman, origin)
assert.Equal(t, entity.EventOutcomeResumed, outcome)
}
// Подъём на чистом каталоге данных не оставляет в журнале ни одного отказа: до
// строки о готовности схема приведена целиком.
func TestCleanStartLeavesNoFailureInJournal(t *testing.T) {
dir := t.TempDir()
journal := &strings.Builder{}
logger := slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug}))
db, err := Open(dir, testSettings())
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
require.NoError(t, Migrate(context.Background(), db, dir, logger))
assert.NotContains(t, journal.String(), "level=ERROR", "подъём оставил отказ в журнале")
assert.NotContains(t, journal.String(), "level=WARN", "подъём оставил предупреждение в журнале")
assert.Contains(t, journal.String(), "Schema migration applied", "накат не отчитался")
// И хранилище готово принимать записи сразу: ручного шага между подъёмом и
// первым приёмом нет.
owner := newOwner(t, db)
newWorkingRecord(t, db, owner)
}
+170
View File
@@ -0,0 +1,170 @@
package sqlite
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
// recordsDir — раздел каталога данных, в котором лежат файлы записей.
const recordsDir = "records"
// tempPrefix — приставка временного имени укладки. Точка в начале уводит такие
// имена из обычного перечисления каталога, а сама приставка отличает
// незавершённую укладку от рабочего имени.
const tempPrefix = ".partial-"
// Store — файлы записей в каталоге данных.
//
// Раскладка: подкаталог на запись, названный её идентификатором, и в нём копии
// под именами, которые задаёт сервис. Так копии одной записи лежат вместе, а
// запись убирается целиком одним движением — плоский каталог, где копии
// различаются приставкой в имени, обращал бы уборку в перебор по маске.
//
// Имя, данное отправителем, в раскладку не попадает ни одной частью: ни именем
// файла, ни именем каталога.
type Store struct {
root string
}
// NewStore заводит раздел записей в каталоге данных.
func NewStore(dataDir string) *Store {
return &Store{root: filepath.Join(dataDir, recordsDir)}
}
// dir — подкаталог одной записи.
func (s *Store) dir(recordID string) string {
return filepath.Join(s.root, recordID)
}
// path — путь копии. Наружу не отдаётся: путь на диске не идёт ни в журнал, ни
// в ответ, ни в метку метрики.
func (s *Store) path(recordID, name string) string {
return filepath.Join(s.dir(recordID), name)
}
// Put кладёт содержимое под рабочим именем **атомарно**.
//
// Содержимое пишется во временное имя в том же подкаталоге записи и
// переименовывается в рабочее только после того, как поток дочитан до конца без
// отказа. Временное имя берётся в том же каталоге потому, что переименование в
// его пределах не копирует содержимое и не может оборваться на середине.
//
// Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины
// записи со строкой файла не сверяются. Усечённая запись поэтому уехала бы в
// конвейер, оплатила распознавание и отдала расшифровку половины как готовый
// результат — атомарная укладка единственное, что этого не допускает.
//
// Отказ источника и отмена посреди потока кончаются одним исходом: временного
// имени не остаётся, рабочего имени не появляется.
func (s *Store) Put(recordID, name string, src io.Reader) (int64, error) {
if err := os.MkdirAll(s.dir(recordID), 0o750); err != nil {
return 0, fmt.Errorf("failed to create the directory of record %s: %w", recordID, causeOf(err))
}
temp := s.path(recordID, tempPrefix+ident.New())
file, err := os.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
if err != nil {
return 0, fmt.Errorf("failed to open the incoming copy of record %s: %w", recordID, causeOf(err))
}
size, copyErr := io.Copy(file, src)
syncErr := file.Sync()
closeErr := file.Close()
if err := errors.Join(copyErr, syncErr, closeErr); err != nil {
_ = os.Remove(temp)
// Путь и имя файла в цепочку не идут, а причина идёт: отказ кончается в
// журнале, журнал уезжает в собранные логи, откуда строку не убрать, —
// но по причине владелец различает исчерпание места, отсутствие прав и
// файловую систему только для чтения.
return 0, fmt.Errorf("failed to store a copy of record %s: %w", recordID, causeOf(err))
}
if err := os.Rename(temp, s.path(recordID, name)); err != nil {
_ = os.Remove(temp)
return 0, fmt.Errorf("failed to publish a copy of record %s: %w", recordID, causeOf(err))
}
return size, nil
}
// Open отдаёт содержимое копии потоком с возможностью перемотки: отдача файла
// по диапазону читает кусок, а не файл целиком.
func (s *Store) Open(recordID, name string) (*os.File, error) {
file, err := os.Open(s.path(recordID, name))
if err != nil {
// Отказ называет запись её идентификатором и не несёт имени файла:
// имя — часть пути к чужому аудио. Причина при этом остаётся: «файла
// нет» и «прав нет» ведут владельца к разным действиям.
return nil, fmt.Errorf("failed to read a copy of record %s: %w", recordID, causeOf(err))
}
return file, nil
}
// Remove убирает копию. Отсутствие файла отказом не считается: уборка зовётся и
// там, где укладка до него не дошла.
func (s *Store) Remove(recordID, name string) error {
if err := os.Remove(s.path(recordID, name)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove a copy of record %s: %w", recordID, causeOf(err))
}
return nil
}
// HasTemporary говорит, осталось ли в подкаталоге записи незавершённое имя.
// Нужен проверкам: обещание атомарной укладки иначе судилось бы только по
// отсутствию рабочего имени.
func (s *Store) HasTemporary(recordID string) (bool, error) {
entries, err := os.ReadDir(s.dir(recordID))
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("failed to read the directory of record %s: %w", recordID, causeOf(err))
}
for _, entry := range entries {
if len(entry.Name()) > len(tempPrefix) && entry.Name()[:len(tempPrefix)] == tempPrefix {
return true, nil
}
}
return false, nil
}
// causeOf снимает с отказа файловой операции путь, оставляя причину.
//
// Обе половины обязательны, и порознь они друг друга отменяют. Причина нужна:
// по ней владелец различает исчерпание места, отсутствие прав и файловую систему
// только для чтения — три поломки, требующие трёх разных действий, а отказ
// укладки — единственная поверхность, на которой он их видит. Путь не нужен и
// вреден: он ведёт внутрь каталога данных, а отказ кончается в журнале, откуда
// строку потом не убрать.
//
// Пакет `os` отдаёт причину обёрнутой в `*os.PathError` либо `*os.LinkError` —
// именно там и лежит путь. Заворачивается поэтому `.Err`, а не обёртка целиком:
// `errors.Is` до `fs.ErrPermission` и `syscall.ENOSPC` сравнивает значение под
// обёрткой и от её снятия не страдает.
//
// Соединённый отказ разбирается по частям: укладка складывает отказы записи,
// сброса и закрытия, и путь лежит в каждой из них.
func causeOf(err error) error {
switch typed := err.(type) { //nolint:errorlint // разбирается сам отказ, а не цепочка: обёртку и надо снять
case *os.PathError:
return typed.Err
case *os.LinkError:
return typed.Err
case interface{ Unwrap() []error }:
parts := typed.Unwrap()
causes := make([]error, 0, len(parts))
for _, part := range parts {
causes = append(causes, causeOf(part))
}
return errors.Join(causes...)
}
return err
}
+205
View File
@@ -0,0 +1,205 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
)
type TextRepository struct {
db *DB
}
func NewTextRepository(db *DB) *TextRepository {
return &TextRepository{db: db}
}
// Put кладёт текст записи, заменяя прежний того же вида.
//
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
// человеку» стал бы вопросом порядка записи, а не состояния.
//
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
// умер, сохранение рубежа отказало, человек вернул запись в работу. Провайдер
// при этом вправе ответить пустым потоком, отказом это не считается, и
// безусловная замена стирала бы сохранённую расшифровку живого человека без
// следа и без возврата. Та же защита стоит у сохранённого ответа провайдера, и
// разное правило у двух хранителей одного результата читалось бы как недосмотр.
//
// **Граница транзакции — весь метод.** Он читает состояние, которое сам же
// пишет, и идёт целиком по пишущему соединению: разорванный надвое, он завёл бы
// вторую строку на гонке двух шагов.
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
if err != nil {
return nil, fmt.Errorf("failed to open a transaction for the text of record %s: %w", recordID, err)
}
defer func() { _ = tx.Rollback() }()
var (
id string
existing string
)
err = tx.QueryRowContext(context.Background(),
"SELECT id, contents FROM texts WHERE record_id = ? AND kind = ?", recordID, kind,
).Scan(&id, &existing)
now := formatTime(clock.Now())
switch {
case err == nil:
// Прежнее непустое содержимое пустым не заменяется: строка остаётся как
// есть, и вызывающий получает её обратно.
if contents == "" && existing != "" {
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: existing}, nil
}
if _, err := tx.ExecContext(context.Background(),
"UPDATE texts SET contents = ?, updated_at = ? WHERE id = ?", contents, now, id,
); err != nil {
// Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от
// драйвера несёт значение поля.
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
}
case errors.Is(err, sql.ErrNoRows):
id = ident.New()
if _, err := tx.ExecContext(context.Background(),
`INSERT INTO texts (id, record_id, kind, contents, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
id, recordID, kind, contents, now, now,
); err != nil {
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
}
default:
// Отказ базы «строкой нет» не является, и подменять его вставкой нельзя:
// она упрётся в уникальный индекс, и наверх уедет жалоба на запись
// вместо правды о недоступной базе.
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit the text of record %s: %w", recordID, err)
}
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: contents}, nil
}
func (repo *TextRepository) GetByID(id string) (*entity.Text, error) {
text := &entity.Text{Id: id}
err := repo.db.Reader().QueryRowContext(context.Background(),
"SELECT record_id, kind, contents FROM texts WHERE id = ?", id,
).Scan(&text.RecordID, &text.Kind, &text.Contents)
if err != nil {
return nil, fmt.Errorf("failed to get text %s: %w", id, err)
}
return text, nil
}
type StructureRepository struct {
db *DB
}
func NewStructureRepository(db *DB) *StructureRepository {
return &StructureRepository{db: db}
}
// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот
// же, что и у текста: повтор шага не должен заводить второй строки, а пустой
// перечень реплик поверх непустого не кладётся.
func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) {
if replicas == nil {
replicas = []entity.Replica{}
}
contents, err := json.Marshal(replicas)
if err != nil {
return nil, fmt.Errorf("failed to encode structure of record %s", recordID)
}
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
if err != nil {
return nil, fmt.Errorf("failed to open a transaction for the structure of record %s: %w", recordID, err)
}
defer func() { _ = tx.Rollback() }()
var (
id string
existing string
)
err = tx.QueryRowContext(context.Background(),
"SELECT id, contents FROM structures WHERE record_id = ? AND version = ?", recordID, version,
).Scan(&id, &existing)
now := formatTime(clock.Now())
switch {
case err == nil:
if len(replicas) == 0 && len(existing) > len("[]") {
stored, decodeErr := decodeReplicas(id, existing)
if decodeErr != nil {
return nil, decodeErr
}
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: stored}, nil
}
if _, err := tx.ExecContext(context.Background(),
"UPDATE structures SET contents = ?, updated_at = ? WHERE id = ?", string(contents), now, id,
); err != nil {
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
}
case errors.Is(err, sql.ErrNoRows):
id = ident.New()
if _, err := tx.ExecContext(context.Background(),
`INSERT INTO structures (id, record_id, version, contents, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
id, recordID, version, string(contents), now, now,
); err != nil {
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
}
default:
return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit the structure of record %s: %w", recordID, err)
}
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: replicas}, nil
}
func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) {
structure := &entity.Structure{Id: id}
var contents string
err := repo.db.Reader().QueryRowContext(context.Background(),
"SELECT record_id, version, contents FROM structures WHERE id = ?", id,
).Scan(&structure.RecordID, &structure.Version, &contents)
if err != nil {
return nil, fmt.Errorf("failed to get structure %s: %w", id, err)
}
replicas, err := decodeReplicas(id, contents)
if err != nil {
return nil, err
}
structure.Replicas = replicas
return structure, nil
}
// decodeReplicas разбирает сохранённые реплики. Текст расшифровки наружу
// отказом не выходит: сообщение несёт идентификатор строки, и только его.
func decodeReplicas(id, contents string) ([]entity.Replica, error) {
if contents == "" {
return nil, nil
}
var replicas []entity.Replica
if err := json.Unmarshal([]byte(contents), &replicas); err != nil {
return nil, fmt.Errorf("failed to decode structure %s", id)
}
return replicas, nil
}
+190
View File
@@ -0,0 +1,190 @@
package sqlite
import (
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
sqlitedriver "modernc.org/sqlite"
sqlitelib "modernc.org/sqlite/lib"
)
// timeLayout — единственный вид времени в схеме: RFC 3339, UTC, суффикс `Z`,
// секундная точность.
//
// Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT`
// совпадает с хронологией, и отбор по колонке времени работает без разбора
// значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё
// положили, — колонка, заполненная то одним видом, то другим, обратила бы
// условие срока протухания захвата в постоянную истину или ложь молча, и запись
// не выдавалась бы ни одному воркеру никогда.
const timeLayout = "2006-01-02T15:04:05Z"
// formatTime приводит метку времени к виду колонки.
func formatTime(v time.Time) string {
return v.UTC().Format(timeLayout)
}
// timeValue кладёт время в колонку, допускающую пустое значение. Нулевое время
// и отсутствующее — одно и то же: «времени нет».
func timeValue(v *time.Time) any {
if v == nil || v.IsZero() {
return nil
}
return formatTime(*v)
}
// timeOf читает колонку времени. Нечитаемое значение отдаётся нулевым: колонка
// пишется только нами, и разбор здесь — сторож, а не ветвь поведения.
func timeOf(v sql.NullString) *time.Time {
if !v.Valid || v.String == "" {
return nil
}
parsed, err := time.Parse(timeLayout, v.String)
if err != nil {
return nil
}
parsed = parsed.UTC()
return &parsed
}
// requiredTimeOf читает обязательную колонку времени.
func requiredTimeOf(v string) time.Time {
parsed, err := time.Parse(timeLayout, v)
if err != nil {
return time.Time{}
}
return parsed.UTC()
}
// stringValue кладёт необязательную строку: пустая и отсутствующая — одно и то
// же.
func stringValue(v *string) any {
if v == nil || *v == "" {
return nil
}
return *v
}
// stringOf читает необязательную строку.
func stringOf(v sql.NullString) *string {
if !v.Valid || v.String == "" {
return nil
}
out := v.String
return &out
}
// numberOf читает необязательное число.
//
// Указатель здесь не выражает «неизвестно»: обе величины записи ставит приём и
// ставит всегда, а колонки объявлены обязательными. Форма осталась указателем
// потому, что её несёт домен, а ответ приложению обязан различать поле и его
// отсутствие.
func numberOf(v int64) *int64 {
out := v
return &out
}
// numberValue кладёт необязательное число нулём: колонка обязательна.
func numberValue(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
// insertSQL собирает вставку из карты «колонка → значение».
//
// Именованными параметрами, а не позиционным списком: у аудиозаписи поля одного
// типа идут длинным непрерывным рядом, и позиционный сдвиг на одно поле
// скомпилировался бы молча, положив идентификатор файла в колонку текста. По
// имени такого сдвига не существует вовсе.
//
// Порядок колонок берётся сортировкой, а не порядком обхода карты: обход карты
// в Go случаен, и текст запроса менялся бы от прогона к прогону — отладка по
// журналу читала бы каждый раз новый запрос.
func insertSQL(table string, values map[string]any) (string, []any) {
names := sortedNames(values)
placeholders := make([]string, 0, len(names))
args := make([]any, 0, len(names))
for _, name := range names {
placeholders = append(placeholders, ":"+name)
args = append(args, sql.Named(name, values[name]))
}
query := fmt.Sprintf(
"INSERT INTO %s (%s) VALUES (%s)",
table,
strings.Join(names, ", "),
strings.Join(placeholders, ", "),
)
return query, args
}
// updateSQL собирает правку из карты «колонка → значение» и условия.
func updateSQL(table string, values map[string]any, where string, whereArgs []any) (string, []any) {
names := sortedNames(values)
assignments := make([]string, 0, len(names))
args := make([]any, 0, len(names)+len(whereArgs))
for _, name := range names {
assignments = append(assignments, name+" = :"+name)
args = append(args, sql.Named(name, values[name]))
}
args = append(args, whereArgs...)
query := fmt.Sprintf(
"UPDATE %s SET %s WHERE %s",
table,
strings.Join(assignments, ", "),
where,
)
return query, args
}
func sortedNames(values map[string]any) []string {
names := make([]string, 0, len(values))
for name := range values {
names = append(names, name)
}
sort.Strings(names)
return names
}
// selectList собирает перечень колонок для выборки из той же карты, по которой
// потом идёт чтение. Один источник у обеих половин: колонка, забытая в перечне,
// не имеет места назначения, и наоборот — расхождению взяться неоткуда.
func selectList(targets map[string]any, prefix string) (string, []any) {
names := sortedNames(targets)
columns := make([]string, 0, len(names))
scan := make([]any, 0, len(names))
for _, name := range names {
columns = append(columns, prefix+name)
scan = append(scan, targets[name])
}
return strings.Join(columns, ", "), scan
}
// isUniqueViolation говорит, отказала ли запись по уникальному индексу.
//
// Судится **код** отказа, а не его текст: текст у драйвера свой на каждую
// версию, а узнавание ошибки по тексту запрещено правилом проекта. Какая именно
// колонка не сошлась, код не называет — и это не мешает: заведение учётной
// записи различает два отказа повторным поиском по ключу, а не разбором текста.
func isUniqueViolation(err error) bool {
var sqliteErr *sqlitedriver.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_UNIQUE ||
sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY
}
-67
View File
@@ -1,67 +0,0 @@
package telegram
import (
"log/slog"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
)
const (
TextLengthLimit = 4000
)
type TelegramMessageSender struct {
bot *tgbotapi.BotAPI
logger *slog.Logger
}
func NewTelegramMessageSender(botToken string, logger *slog.Logger) (*TelegramMessageSender, error) {
bot, err := tgbotapi.NewBotAPI(botToken)
if err != nil {
return nil, err
}
return &TelegramMessageSender{
bot: bot,
logger: logger,
}, nil
}
func (s *TelegramMessageSender) Send(text string, chatId int64, replyToMessageId *int) error {
// If message is short enough, send it directly
if len([]rune(text)) <= TextLengthLimit {
return s.sendSingleMessage(text, chatId, replyToMessageId)
}
// Split long message into parts
parts := s.splitMessageByWords(text, TextLengthLimit)
// Send each part
for i, part := range parts {
var replyId *int
// Only use replyToMessageId for the first part
if i == 0 {
replyId = replyToMessageId
}
err := s.sendSingleMessage(part, chatId, replyId)
if err != nil {
return err
}
}
return nil
}
// sendSingleMessage sends a single message
func (s *TelegramMessageSender) sendSingleMessage(text string, chatId int64, replyToMessageId *int) error {
resultMsg := tgbotapi.NewMessage(chatId, text)
if replyToMessageId != nil {
resultMsg.ReplyToMessageID = *replyToMessageId
}
_, err := s.bot.Send(resultMsg)
if err != nil {
s.logger.Error("Failed to send message to tg bot", "error", err)
return err
}
return nil
}
-62
View File
@@ -1,62 +0,0 @@
package telegram
// splitMessageByWords splits a message into parts of maxLen UTF-8 characters
// splitting by words to avoid cutting words in the middle
func (s *TelegramMessageSender) splitMessageByWords(text string, maxLen int) []string {
var parts []string
// If text is already short enough, return as is
if len([]rune(text)) <= maxLen {
return []string{text}
}
runes := []rune(text)
for len(runes) > 0 {
// Determine the end position for this part
end := len(runes)
if end > maxLen {
end = maxLen
}
// Try to find a good split point (word boundary)
splitPoint := end
for i := end - 1; i > end-20 && i > 0; i-- { // Look back up to 20 characters
// Check if this is a good split point (after a space)
if runes[i] == ' ' {
splitPoint = i + 1 // Include the space in the previous part
break
}
}
// If we couldn't find a good split point, just split at maxLen
if splitPoint == end && end == maxLen {
// Check if we're in the middle of a word
if end < len(runes) && runes[end] != ' ' && runes[end-1] != ' ' {
// Try to find a split point going forward
for i := end; i < len(runes) && i < end+20; i++ {
if runes[i] == ' ' {
splitPoint = i
break
}
}
}
}
// If still no good split point, use the original end
if splitPoint > len(runes) {
splitPoint = len(runes)
}
// Add this part
parts = append(parts, string(runes[:splitPoint]))
// Move to the next part
if splitPoint >= len(runes) {
break
}
runes = runes[splitPoint:]
}
return parts
}

Some files were not shown because too many files have changed in this diff Show More