# Схема хранилища Хранилище, коллекции, правило времени и идентификаторов. Хранилище — **встроенная PocketBase 0.39.10**: она держит и базу, и файлы записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`, умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому CGO сборке не нужен. Схему двигают **шаги миграций PocketBase** на Go, каталог `internal/adapter/repo/pocketbase/migrations`, файл на шаг и имя файла — имя шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме хранилища (`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не переписывается — изменение только новым шагом: применённое хранилище считает по имени шага. Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя: шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу пути**, а префикс наводится только на каталог. Где этот префикс задан — [conventions/go-linters.md](conventions/go-linters.md), «Механизировано». Имена коллекций живут там же, рядом с шагом, который их заводит; пакет репозитория берёт их оттуда. **Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита. Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в хранилище, задаёт сервис, и это `<расширение>`. **Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки `created` и `updated` проставляет само хранилище; те же поля в сыром запросе захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или постоянную ложь молча. Того, что единой точки генерации идентификатора и времени нет, здесь не повторяем: перечень единых точек и их отсутствий держит [architecture.md](architecture.md), «Единые точки проекта». ## Коллекции ### `files` Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не считается — она существует только потому, что провайдер распознавания читает аудио по адресу, и её ключ живёт в строке попытки распознавания. | Поле | Тип | Что | | --- | --- | --- | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `file` | file | Сам файл | | `owner` | relation → `users` | Владелец файла; пусто у файлов записи, принятой ботом | | `location` | select | `local` или `s3` | | `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется | | `size` | INTEGER | Размер в байтах | | `format` | TEXT | Расширение без точки, в нижнем регистре | | `duration_ms` | INTEGER | Длительность, если её удалось прочитать | | `created`, `updated` | DATETIME | Проставляет хранилище | Поле названо `location`, а не `storage`: последним словом зовут само хранилище и capability, и третий смысл развёл бы одно слово по разным вещам. ### `audio_records` Аудиозапись — центральная сущность сервиса. Домен, поля очереди и ссылки на приложения лежат здесь; содержимое — по ссылкам, отдельными строками. | Поле | Тип | Что | | --- | --- | --- | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `owner` | relation → `users` | Владелец записи; пусто у записей, принятых ботом | | `source` | select | `api`, `telegram`, `unknown` | | `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком | | `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой | | `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания | | `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается | | `halt_reason` | select | `step_failed`, `attempts_exhausted`, `stuck` | | `error_text` | TEXT | Текст ошибки, машинный | | `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого | | `acquire_expires_at` | DATETIME | Срок протухания захвата; приезжает с рубежом | | `delay_time` | DATETIME | Не брать запись раньше этого времени | | `attempts` | INTEGER ≥ 0 | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании | | `original_file` | relation → `files` | Принятая копия | | `normalized_file` | relation → `files` | Копия, приведённая к рабочему формату | | `transcript_text`, `literary_text` | relation → `texts` | Тексты записи | | `structure` | relation → `structures` | Структура реплик | | `recognition` | relation → `recognitions` | Попытка распознавания | | `topics` | relation → `topics`, до 5 | Темы записи | | `tg_chat_id` | INTEGER | Куда отправить результат | | `tg_reply_message_id` | INTEGER | С каким сообщением связать | | `created`, `updated` | DATETIME | Проставляет хранилище | Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним, паузе и сроку протухания. **Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем хранилище, и принятого человеком файла не найти было ничем. **Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead` схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием признака, — и различие между ними перестало быть структурным. Рубеж при остановке сохраняется, поэтому запись продолжает с места остановки. **Сторожей двое.** `attempts` ограничивает повторы внутри шага, `state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не справлялось ни с одной. ### `texts` | Поле | Тип | Что | | --- | --- | --- | | `record` | relation → `audio_records` | Чья это расшифровка | | `kind` | select | `transcript` или `literary` | | `contents` | editor | Сам текст | Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки. Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат файла. ### `structures` | Поле | Тип | Что | | --- | --- | --- | | `record` | relation → `audio_records` | Чья это структура | | `version` | INTEGER | Версия вида разбора | | `contents` | JSON | Реплики со временем | Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор сохранённого ответа изменится раньше, чем архив пересчитают. ### `recognitions` Попытка распознавания у внешнего провайдера — всё, что зависит от него. | Поле | Тип | Что | | --- | --- | --- | | `record` | relation → `audio_records` | Чья это попытка | | `provider`, `model` | TEXT | Кем и какой моделью считано | | `external_id` | TEXT | Идентификатор операции у провайдера | | `source_uri` | TEXT | Адрес, по которому провайдер читает аудио | | `payload` | file, **защищённое** | Сырой ответ провайдера целиком | | `started_at`, `finished_at` | DATETIME | Границы операции | **Сырой ответ лежит вложением, а не колонкой.** Шаг опроса читает эту строку раз в несколько секунд, а хранилище читает запись целиком: ответ на многочасовую запись ехал бы в память при каждом опросе. Хранится он потому, что результат операции у провайдера не переспрашивается. Поле вложения помечено защищённым: сырой ответ — это полный текст речи, и умолчание библиотеки отдавало бы его по ссылке любому, кто её знает. ### `record_events` | Поле | Тип | Что | | --- | --- | --- | | `record` | relation → `audio_records` | Чьё это событие | | `origin` | select | `pipeline` или `human` | | `step` | TEXT | Имя шага | | `outcome` | select | `done`, `failed`, `halted`, `resumed` | | `outcome_text` | TEXT | Причина, если она есть | | `duration_ms` | INTEGER | Сколько шаг занял | Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант двусмысленным. Журнал пишется на смену рубежа, на остановку и на снятие остановки — не на каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить, что делать дальше. ### `topics` | Поле | Тип | Что | | --- | --- | --- | | `owner` | relation → `users` | Чей это словарь | | `name` | TEXT | Название темы | Пара «владелец и название» уникальна: словарь тем свой у каждого человека. Коллекцией, а не набором строк в записи, потому что перечень тем нужен целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не платила вторым необратимым шагом схемы. ### Чего в схеме больше нет Коллекция `transcribe_jobs` удалена шагом `202608140002`. Данных под ней не было: сервис на сервере остановлен, а прежние записи удалены решением владельца 2026-08-14 — переноса это изменение не делало. Оставленная пустая коллекция висела бы в панели вторым домом для понятия, которого больше нет. **Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в обеих таблицах. Пустое значение допустимо, и это решение с ценой: записи, принятые ботом, владельца не имеют вовсе, потому что связи чата Telegram с учётной записью сервис не ведёт. Обязательность для приёма по HTTP держит поэтому сам приём, а не схема. Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции `files` владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора файловой записи равнялось праву скачать чужое аудио. **Учётная запись с записями не удаляется.** Каскадное удаление у связи выключено, но одного этого мало: при выключенном каскаде хранилище снимает ссылку и сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья запись не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`, а не правило коллекции: панель ходит правами суперпользователя, и правило её не судит. Считаются все коллекции с колонкой владельца — `audio_records`, `files` и `topics`, — и перечень живёт одним местом: пропущенная коллекция пропускает удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь вместо нашего отказа с причиной. **Правила доступа новых коллекций пусты**, то есть перечислять и читать их может только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок. Проверено прогоном: анонимный запрос к `/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`, `/api/settings` и `/api/crons` — `401`. ## Представление данных Чем физически лежит запись и что происходит при чтении и записи. - **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той же строки и читалась при каждом опросе очереди. - **Аудио лежит в раскладке хранилища:** `data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт сервис — `<расширение>`; собственного суффикса хранилище не дописывает, потому что умолчание, строящее имя из имени отправителя, не применяется. Ни файлы, ни объекты в Object Storage не удаляются после завершения задачи: каталог и бакет растут неограниченно. - **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла помечено защищённым шагом `202608120001`, а правило просмотра коллекции пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки. - **Коллекция `users`** заводится самой библиотекой, а шаг `202608120001` её сужает: создание записи разрешено только контексту обмена OIDC (`@request.context = "oauth2"`), вход по паролю и одноразовый код выключены. Без этого сужения закрытие API обходится двумя запросами — завести себе запись и войти паролем. Продление сессии закрыто слоем в приложении, а не настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда. - **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции. `app.DB()` направляет всё, кроме выборок, в пул с единственным соединением, поэтому захваты выстраиваются в очередь. Порядок выборки — по времени заведения **и по ключу**: время неуникально, и без ключа порядок обработки невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания захвата и отсутствию признака остановки; срок протухания выбирается по рубежу самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить его не может. - **Запись результата условна по признаку захвата** — инвариант «Результат пишет только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major); норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому, что условие проверяется тем же запросом, что и сам захват. - **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с `applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре, пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и перечень перестал расти с моделью. Правило правки и его серьёзность — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила `internal/archrules`. - **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`. Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя: рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту проекта не пишется в журнал и не считается в метрику. - **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки в Object Storage: отказ SDK несёт полный URL объекта. ## Настройки с числовым значением | Настройка | Значение | Где | Откуда число | | --- | --- | --- | --- | | Предел отказов | 5 | `service/transcribe.go` | обычное умолчание, не замер | | Пауза перед повтором | `2^(отказ−1)` с, потолок 5 минут | там же | то же | | Срок захвата, приведение | 8 часов | `entity/stage.go` | потолок записи 6 часов плюс запас | | Срок захвата, отправка на распознавание | 8 часов | там же | то же | | Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды | | Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды | | Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение | | Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже | | Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого | | Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая | | Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем | | Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих | | Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем | | Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было | | Задержка между проверками операции | 5 секунд | там же | как было | | Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было | | Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` | предел Telegram | | Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — | | Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — | | Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` | — | | Срок ожидания Telegram при сборке клиента | 10 секунд | `adapter/telegram.ProbeTimeout` | решение, не замер: одно обращение за `getMe` укладывается в доли секунды, дольше Telegram считается недоступным и сервис поднимается без него. Длинный опрос этим сроком не ограничен — клиент подменяется сразу после сборки | | Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — | | Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — | | Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | | Срок жизни сессии | нормирует [access](../openspec/specs/access/spec.md) | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки никем не выбрано, и спека прямо запрещает его применять | | Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен | | Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым | **У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит по нехватке памяти или `docker kill`, — запись невидима сторожу до истечения **срока захвата** её рубежа, то есть восьми часов у приведения и отправки. Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и остановка «застряла» наступает только после него. Мягкая остановка сюда не подпадает: она снимает захват сама. **Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по медленному каналу переживают любой фиксированный, а стойкость к целенаправленной нагрузке объявлена вне модели угроз. Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.