- адреса приложения переехали в своё пространство `/app/`, опрос готовности убран целиком: рубеж и причину остановки владелец узнаёт карточкой записи, текст — отдельным адресом названного вида - заведена единая точка отображения доменной ошибки и слой, приводящий к той же форме отказы библиотеки: тело несёт машиночитаемый код рядом с сообщением - у записи появились имя файла отправителя, длительность и размер своими колонками, а у ленты владельца — свой индекс: без него страница сканировала весь архив сервиса
36 KiB
Схема хранилища
Хранилище, коллекции, правило времени и идентификаторов.
Хранилище — встроенная 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, «Механизировано». Имена коллекций живут там же, рядом с шагом, который их заводит; пакет репозитория берёт их оттуда.
Идентификаторы записей выдаёт хранилище — 15 знаков собственного алфавита.
Свои UUID остались только в именах файлов: имя, под которым запись ложится в
хранилище, задаёт сервис, и это <uuid><расширение>.
Время — вид хранилища: строка 2006-01-02 15:04:05.000Z в UTC. Колонки
created и updated проставляет само хранилище; те же поля в сыром запросе
захвата кладёт наш код — тем же видом, потому что сравнение строк в SQLite
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
постоянную ложь молча.
Того, что единой точки генерации идентификатора и времени нет, здесь не повторяем: перечень единых точек и их отсутствий держит 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, unknown; значение telegram осталось историческим — вход убран, новых записей с ним не появляется |
title, brief |
TEXT | Заголовок и краткое описание: читаются вместе со списком |
original_filename |
TEXT ≤ 255 | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
duration_ms |
INTEGER ≥ 0 | Длительность принятого, миллисекунды; ставит приём и всегда |
size_bytes |
INTEGER ≥ 0 | Размер принятого, байты |
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 | Проставляет хранилище |
Индексов три. Первый — по паре «рубеж и признак остановки»: по ним, паузе и
сроку протухания идёт выборка захвата. Два других завела страница списка приложения:
(owner, created DESC, id DESC) под страницу «новыми сверху» и
(owner, state, halted_at) под отбор тремя состояниями.
Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает
ничем. Замер на задаче json-api-for-spa 2026-08-15: без своего индекса
страница сканировала таблицу целиком и досортировывала результат во временном
дереве, а рост архива с 5 тысяч строк до 200 тысяч растил время одной страницы
владельца в двадцать-тридцать раз — при неизменных сорока его собственных
записях. Цена росла с чужими записями, потому что сервис объявлен архивом и
хранит их бессрочно.
Имя файла и заголовок — разные колонки. Заголовок несёт название, которое дал человек либо посчитала языковая модель; имя файла — то, по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя файла в хранилище и в журнал оно по-прежнему не идёт.
Длительность и размер лежат и на записи, и на её файле, и равенство между ними не поддерживается никем — намеренно. На записи снимок принятого, взятый приёмом один раз; на файле — величины нынешней копии файла. Уточнение длительности меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и «что лежит сейчас». Колонками записи они нужны потому, что показываются в списке, а список читается без содержимого. Решение владельца от 2026-08-15.
«Неизвестно» эти колонки не выражают, и это решение владельца от 2026-08-15. Числовая колонка хранилища пустого значения не держит: пустое она кладёт нулём. Платить за отличимость четвёртой колонкой-признаком или текстовым типом у чисел не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными метаданными отвергается отказом и не заводится вовсе.
Ссылки на файлы две и порознь. Прежняя модель держала одну и переставляла её каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем хранилище, и принятого человеком файла не найти было ничем.
Остановка — признак, а не рубеж. Прежние состояния 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 в
обеих таблицах, — и шагом 202608140003 пустого значения больше не принимает.
Прежде принимал, и цену за это платили записи входа Telegram: связи чата с
учётной записью сервис не вёл. Вход убран 2026-08-14, ничью запись заводить стало
некому, и обязательность переехала из приёма в схему — туда, где её держит
хранилище, а не договорённость.
Колонки tg_chat_id и tg_reply_message_id остались от убранного входа и
кодом больше не читаются. Из схемы они не убираются: заводили их применённые
шаги 202608110001 и 202608140002, а применённый шаг не переписывается.
Выборка по владельцу сужает чтение записи: чужая, ничья и несуществующая
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции files
владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора
файловой записи равнялось праву скачать чужое аудио.
Учётная запись с записями не удаляется. Каскадное удаление у связи выключено,
но одного этого мало: при выключенном каскаде хранилище снимает ссылку и
сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья
запись не достаётся никому. Отказ ставит слой приложения GuardOwnerDeletion,
а не правило коллекции: панель ходит правами суперпользователя, и правило её не
судит. Считаются все коллекции с колонкой владельца — audio_records, files и
topics, — и перечень живёт одним местом: пропущенная коллекция пропускает
удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь
вместо нашего отказа с причиной.
Правила доступа новых коллекций пусты, то есть перечислять и читать их может
только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не
поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок.
Проверено прогоном: анонимный запрос к /api/collections/*/records отвечает
403, к /api/logs, /api/backups, /api/settings и /api/crons — 401.
Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- Расшифровка лежит отдельной строкой
texts, а не колонкой записи. Захват её не тянет вовсе: он возвращает идентификатор и признак своего захвата, а колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той же строки и читалась при каждом опросе очереди. - Аудио лежит в раскладке хранилища:
data/storage/<коллекция>/<запись>/<имя>рядом с файлом атрибутов. Имя задаёт сервис —<uuid><расширение>; собственного суффикса хранилище не дописывает, потому что умолчание, строящее имя из имени отправителя, не применяется. Ни файлы, ни объекты в Object Storage не удаляются после завершения задачи: каталог и бакет растут неограниченно. - Файл отдаётся ссылкой
/api/files/<коллекция>/<запись>/<имя>. Поле файла помечено защищённым шагом202608120001, а правило просмотра коллекции пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт знание её идентификатора» — отменено задачейoidc-login2026-08-12. Имя файла в хранилище в журнал не пишется по-прежнему: оно последняя часть ссылки. - Коллекция
usersзаводится самой библиотекой, а шаг202608120001её сужает: создание записи разрешено только контексту обмена OIDC (@request.context = "oauth2"), вход по паролю и одноразовый код выключены. Без этого сужения закрытие API обходится двумя запросами — завести себе запись и войти паролем. Продление сессии закрыто слоем в приложении, а не настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда. - Захват записи — один запрос с
RETURNING, мимо записей коллекции.app.DB()направляет всё, кроме выборок, в пул с единственным соединением, поэтому захваты выстраиваются в очередь. Порядок выборки — по времени заведения и по ключу: время неуникально, и без ключа порядок обработки невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания захвата и отсутствию признака остановки; срок протухания выбирается по рубежу самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить его не может. - Запись результата условна по признаку захвата — инвариант «Результат пишет только держатель захвата» в CLAUDE.md, «Инварианты» (major); норма — pipeline. Здесь названо потому, что условие проверяется тем же запросом, что и сам захват.
- Список колонок задан двумя местами —
applyOwnedByPipelineвместе сapplyToRecordиrecordToAudioRecord, — плюс шагом схемы. Мест было четыре, пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и перечень перестал расти с моделью. Правило правки и его серьёзность — инвариант в 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 |
первая |
| Умолчание размера страницы списка | 30 | controller/http.DefaultPageLimit |
столько помещается на экран телефона без прокрутки в два экрана |
| Потолок размера страницы списка | 100 | controller/http.MaxPageLimit |
против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром |
Ограничитель частоты под /app/ |
120 запросов за 60 секунд | controller/http.appRateMaxRequests, appRateWindowSec |
сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
| Доля бюджета под опрос карточки | 1/8 | controller/http.pollBudgetShare |
опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли выводится объявляемая частота опроса, и своей константы у неё нет |
| Потолок длины имени файла отправителя | 255 знаков | entity.MaxOriginalFilenameLen |
предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
| Потолок длины расширения | 32 знака | service/transcribe.go, maxExtLen |
сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а x. с четырьмястами знаками роняет заведение временного файла |
| Потолок тем на запись | 5 | entity.MaxTopicsPerRecord |
решение владельца: без него часовой разговор даёт два десятка тем |
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг 202608140002 |
ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
| Задержка перед первой проверкой операции | 10 секунд | service/transcribe.go |
как было |
| Задержка между проверками операции | 5 секунд | там же | как было |
| Пауза воркера между прогонами | 1 секунда | controller/worker/worker.go |
как было |
| Таймаут мягкой остановки | 5 секунд | конфиг, [server] shutdown_timeout |
— |
| Таймаут жёсткой остановки | 20 секунд | конфиг, [server] force_shutdown_timeout |
— |
| Качество кодирования vorbis | -q:a 4 |
adapter/converter/ffmpeg/ffmpeg.go |
— |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | entity.MaxRecordSize |
расчётный потолок в шесть часов с запасом на видео |
| Срок жизни сессии | нормирует access | pbrepo.SessionDuration, ставится при подъёме |
решение владельца 2026-08-12; умолчание библиотеки никем не выбрано, и спека прямо запрещает его применять |
| Потолок времени на вход у провайдера | 10 минут | controller/http/auth.go |
дольше носитель состояния не нужен |
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
У сторожа простоя есть второй потолок, и он не тот, что в настройке. Предел
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
протухания ещё не истёк. Значит для держателя, погибшего жёстко — контейнер убит
по нехватке памяти или docker kill, — запись невидима сторожу до истечения
срока захвата её рубежа, то есть восьми часов у приведения и отправки.
Замерено прогоном: до истечения срока повторный захват записи не выдаёт, и
остановка «застряла» наступает только после него. Мягкая остановка сюда не
подпадает: она снимает захват сама.
Потолок размера назван числом в двух местах сразу — у поля файла в схеме и у тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по медленному каналу переживают любой фиксированный, а стойкость к целенаправленной нагрузке объявлена вне модели угроз.
Чего среди настроек нет: режим журналирования, таймаут занятости и размер пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного.