# Схема хранилища Хранилище, коллекции, правило времени и идентификаторов. Хранилище — **встроенная PocketBase 0.39.10**: она держит и базу, и файлы записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`, умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому CGO сборке не нужен. Схему двигают **шаги миграций PocketBase** на Go, каталог `internal/adapter/repo/pocketbase/migrations`, файл на шаг и имя файла — имя шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме хранилища (`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый шаг не переписывается — изменение только новым шагом: применённое хранилище считает по имени шага. Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя: шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу пути** (`.av-dev.toml`, ключ `migrations` секции `[docs]`), а префикс наводится только на каталог. Имена коллекций живут там же, рядом с шагом, который их заводит; пакет репозитория берёт их оттуда. **Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита. Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в хранилище, задаёт сервис, и это `<расширение>`. **Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки `created` и `updated` проставляет само хранилище; те же поля в сыром запросе захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или постоянную ложь молча. Того, что единой точки генерации идентификатора и времени нет, здесь не повторяем: перечень единых точек и их отсутствий держит [architecture.md](architecture.md), «Единые точки проекта». ## Коллекции ### `files` Один файл на одну физическую копию: исходник, результат конвертации и копия в Object Storage — три разные записи. | Поле | Тип | Что | | --- | --- | --- | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `file` | file | Сам файл; пусто у копии в Object Storage | | `location` | select | `local` или `s3` | | `object_key` | TEXT | Ключ объекта; пусто у местной копии | | `size` | INTEGER | Размер в байтах | | `created`, `updated` | DATETIME | Проставляет хранилище | Поле названо `location`, а не `storage`: последним словом зовут само хранилище и capability, и третий смысл развёл бы одно слово по разным вещам. ### `transcribe_jobs` Задача расшифровки и она же очередь. | Поле | Тип | Что | | --- | --- | --- | | `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `state` | select | `created`, `converted`, `transcribe`, `done`, `failed`, `dead`; перечень закрыт схемой | | `source` | select | `api`, `telegram`, `unknown` | | `file` | relation → `files` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат | | `delay_time` | DATETIME | Не брать задачу раньше этого времени | | `acquisition_id` | TEXT | Кто захватил задачу | | `acquire_time` | DATETIME | Когда захватил; по нему считается протухание | | `attempts` | INTEGER ≥ 0 | Число попыток: растёт при захвате, обнуляется на шаге без отказа | | `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud | | `transcription_text` | editor | Результат распознавания | | `error_text` | TEXT | Текст ошибки, машинный | | `tg_chat_id` | INTEGER | Куда отправить результат | | `tg_reply_message_id` | INTEGER | С каким сообщением связать | | `created`, `updated` | DATETIME | Проставляет хранилище | Индекс один — по `state`: выборка воркера идёт по нему, паузе и сроку захвата. Прежней колонки `is_error` нет: задача выбывает из выборки состоянием, и способ этот один. **Состояния `failed` и `dead` — разные приговоры**, и чей это приговор, нормирует [pipeline](../openspec/specs/pipeline/spec.md), «Число попыток и состояние «мертва»». Схеме принадлежит только закрытость перечня: шестое состояние потребует нового шага. **Правила доступа обеих коллекций пусты**, то есть перечислять и читать записи может только владелец панели. Проверено прогоном: анонимный запрос к `/api/collections/*/records` отвечает `403`, к `/api/logs`, `/api/backups`, `/api/settings` и `/api/crons` — `401`. ## Представление данных Чем физически лежит запись и что происходит при чтении и записи. - **Расшифровка лежит целиком в поле `transcription_text`** одной строкой. Запись длиной в час даёт десятки килобайт в одной ячейке; читается она целиком при каждом чтении задачи и при каждом захвате. - **Аудио лежит в раскладке хранилища:** `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). Здесь названо потому, что условие проверяется тем же запросом, что и сам захват. - **Список колонок задан четырьмя местами** — `applyToRecord`, `recordToJob`, константой `acquireColumns` и структурой `acquiredRow`, — плюс шагом схемы. Все четыре лежат в одном пакете, но компилятор видит два: правило правки и его серьёзность (critical/major) — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты». - **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки в Object Storage: отказ SDK несёт полный URL объекта. ## Настройки с числовым значением | Настройка | Значение | Где | Откуда число | | --- | --- | --- | --- | | Предел попыток | 5 | `service/transcribe.go` | обычное умолчание, не замер | | Пауза перед повтором | `2^(попытка−1)` с, потолок 5 минут | там же | то же | | Срок захвата, конвертация | 8 часов | там же | потолок записи 6 часов плюс запас | | Срок захвата, распознавание | 8 часов | там же | то же | | Срок захвата, проверка операции | 1 час | там же | опрос идёт секунды | | Задержка перед первой проверкой операции | 10 секунд | там же | как было | | Задержка между проверками операции | 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` | — | | Качество кодирования 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 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым | **Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по медленному каналу переживают любой фиксированный, а стойкость к целенаправленной нагрузке объявлена вне модели угроз. Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к Telegram, S3 и SpeechKit тоже нет — ни одного.