хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -13,6 +13,25 @@ PocketBase уходит из проекта целиком: состояние
|
||||
ничем**: пока идёт стройка, остановленную запись возвращает в работу запрос к
|
||||
базе.
|
||||
|
||||
**Два решения абзаца выше сменились при разметке изменения**, и заменившее
|
||||
названо здесь.
|
||||
|
||||
Шаги схемы двигает библиотека `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) и
|
||||
|
||||
+71
-59
@@ -21,18 +21,20 @@
|
||||
`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;
|
||||
2026-08-14, `storage-without-pocketbase` 2026-08-22;
|
||||
- [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
|
||||
задачи и срок его протухания, число попыток, остановка признаком, пауза перед
|
||||
повтором и молчание конвейера наружу: задачи
|
||||
`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. Переходы состояний и отмена
|
||||
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
|
||||
2026-08-14 и `storage-without-pocketbase` 2026-08-22. Переходы состояний и отмена
|
||||
контекста посреди шага остаются
|
||||
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
|
||||
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
|
||||
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage`
|
||||
2026-08-12;
|
||||
и её файл, как файл отдаётся и что видит владелец: задачи `pocketbase-storage`
|
||||
2026-08-12 и `storage-without-pocketbase` 2026-08-22. Последняя убрала
|
||||
встроенное хранилище целиком: база стала своей, файлы — своим каталогом,
|
||||
панель владельца исчезла и не заменена ничем;
|
||||
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
|
||||
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
|
||||
целиком и вложением, как из сохранённого строится структура реплик без
|
||||
@@ -66,13 +68,15 @@
|
||||
|
||||
- **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
|
||||
делят одну базу. Отдельного воркер-процесса нет намеренно.
|
||||
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость
|
||||
- **Очередь таблицей.** Состояние задачи лежит таблицей базы; неделимость
|
||||
захвата и порядок выборки нормирует
|
||||
[pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим».
|
||||
Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца,
|
||||
не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11,
|
||||
[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). Решение пережило
|
||||
уход встроенного хранилища: замер снят на том же драйвере, и отменилось у него
|
||||
одно слово — таблица перестала быть коллекцией.
|
||||
- **Шаг конвейера идемпотентен по повтору.** Что делает срок захвата и когда
|
||||
задача возвращается в работу, нормирует
|
||||
[pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
|
||||
@@ -83,11 +87,10 @@
|
||||
`cmd/transcriber`. Слои, их дома и словарь модели — раздел «Слои и модель
|
||||
домена» ниже. Правило механизировано тестами-сканерами `internal/archrules`, и
|
||||
они же держат обратные направления: транспорты не знают друг о друге, адаптер
|
||||
не знает ни ядра, ни транспортов.
|
||||
*Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http`
|
||||
импортирует `adapter/repo/pocketbase`, потому что HTTP-поверхность и есть
|
||||
роутер этого хранилища, а не наш сервер поверх него. Правила на это
|
||||
направление нет намеренно.
|
||||
не знает ни ядра, ни транспортов, транспорт не знает адаптеров. Изъятие,
|
||||
разрешавшее транспорту знать адаптер хранилища, снято 2026-08-22 вместе с
|
||||
предметом: HTTP-поверхность была роутером встроенного хранилища, а стала своей,
|
||||
и правило на это направление заведено впервые.
|
||||
|
||||
## Слои и модель домена
|
||||
|
||||
@@ -133,10 +136,10 @@
|
||||
наблюдаем: правило о записи, записанное в `internal/service` условием над её
|
||||
полями, принадлежит `internal/entity`.
|
||||
|
||||
Место, где подход нарушен сегодня, названо изъятием в «Принципах»: транспорт
|
||||
знает адаптер хранилища, потому что HTTP-поверхность и есть роутер этого
|
||||
хранилища. Изъятие снимает задача `storage-without-pocketbase` — своя отдача
|
||||
файла и свои маршруты возвращают транспорту независимость от инфраструктуры.
|
||||
Изъятий у подхода сегодня нет: последнее — транспорт знал адаптер хранилища —
|
||||
снято задачей `storage-without-pocketbase` 2026-08-22. Своя отдача файла и свои
|
||||
маршруты вернули транспорту независимость от инфраструктуры, а узнавание
|
||||
пришедшего приходит ему интерфейсом `contract.UserRepository`.
|
||||
|
||||
## Компоненты
|
||||
|
||||
@@ -146,14 +149,15 @@
|
||||
|
||||
| Компонент | Где | Что делает |
|
||||
| --- | --- | --- |
|
||||
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/`: приём записи, страница своих записей, карточка, текст названного вида, пределы сервера и «кто вошёл» |
|
||||
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, узнавание, требование учётной записи |
|
||||
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
|
||||
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
|
||||
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
|
||||
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
|
||||
| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом |
|
||||
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций |
|
||||
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» |
|
||||
| Репозитории | `internal/adapter/repo/sqlite` | Учётные записи, записи, файлы, тексты, структура, попытки распознавания и журнал событий — таблицами базы; захват — одним запросом с `RETURNING` по пишущему соединению |
|
||||
| Файлы записей | `internal/adapter/repo/sqlite`, `store.go` | Подкаталог на запись под её идентификатором; укладка атомарна — временное имя рядом и переименование |
|
||||
| Шаги схемы | `internal/adapter/repo/sqlite/migrations` | Файл на шаг, версия — число в начале имени; накатывает `pressly/goose/v3` под своим замком |
|
||||
| Оснастка владельца | `cmd/devtools` | Подставной прокси для местного запуска и возврат остановленной записи в работу. Панели у сервиса нет и не будет: экраны правки приносят отдельные задачи |
|
||||
| Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет |
|
||||
| Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
|
||||
|
||||
@@ -174,6 +178,12 @@
|
||||
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
|
||||
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
|
||||
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
|
||||
- **SQLite через `modernc.org/sqlite`.** Драйвер на чистом Go: CGO сборке не
|
||||
нужен. База и файлы записей лежат под одним каталогом данных.
|
||||
- **`github.com/pressly/goose/v3`.** Шаги схемы — библиотекой, а не командной
|
||||
строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте.
|
||||
Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных
|
||||
берём сами.
|
||||
- **Node и его установщик пакетов.** Нужны только сборке приложения и на машину
|
||||
не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`.
|
||||
Требованием к машине разработчика поэтому становится docker. Реестр пакетов —
|
||||
@@ -191,34 +201,23 @@
|
||||
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
|
||||
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
|
||||
сервис поднимает молча.
|
||||
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об
|
||||
этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
|
||||
те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
|
||||
поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после
|
||||
чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном
|
||||
двух бинарей на одном каталоге данных.
|
||||
- **Откат образа на версию до 2026-08-22 не работает вовсе.** Каталог данных
|
||||
сменил раскладку целиком: база зовётся другим файлом, файлы записей лежат
|
||||
другими путями, а учёт применённых шагов ведёт другая таблица. Прежний образ на
|
||||
таком каталоге поднимется, накатит **свои** шаги в пустое место и заведёт
|
||||
вторую, чужую схему рядом. Лечится повторной выкладкой вперёд; обратного шага
|
||||
схемы нет и не планируется.
|
||||
|
||||
Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с
|
||||
этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа;
|
||||
обратного шага схемы нет и не планируется. Порог перехода назван прямо: до
|
||||
выкладки `record-centric-model` откат образа работает, после — нет.
|
||||
- **Откат образа через шаг схемы `202608220001` обрывает вход.** Шаг закрывает
|
||||
правила коллекции пользователей наглухо, а прежний образ заводил учётную
|
||||
запись внутренним запросом обмена кода — и этот запрос закрытое правило
|
||||
отвергает. Проверено прогоном прежнего кода поверх нового каталога данных:
|
||||
вход отвечает `401`, в журнале «storage rejected the exchange with code 403».
|
||||
Порог тот же по форме, что и у `202608140002`: до выкладки
|
||||
`trusted-header-login` откат работает, после — нет, и лечится он повторной
|
||||
выкладкой вперёд. Обратного шага схемы нет и не планируется.
|
||||
|
||||
Окно этого порога сегодня пусто: сервис не выложен, а откат уже не работает с
|
||||
шага `202608140002`. Строка стоит здесь потому, что порог принято называть
|
||||
прямо, а не потому, что риск сегодня чем-то грозит.
|
||||
Прежние два порога — шаги `202608140002` и `202608220001` — этим поглощены: до
|
||||
выкладки `record-centric-model` откат работал, после перестал, а с уходом
|
||||
встроенного хранилища перестал окончательно. Окно порога сегодня пусто: сервис
|
||||
не выложен. Строка стоит здесь потому, что порог принято называть прямо, а не
|
||||
потому, что риск сегодня чем-то грозит.
|
||||
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
||||
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
||||
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
||||
|
||||
<!-- канон: поведение → openspec/specs/intake, pipeline -->
|
||||
<!-- канон: поведение → openspec/specs/intake, pipeline, storage -->
|
||||
|
||||
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
||||
| --- | --- | --- | --- | --- |
|
||||
@@ -226,7 +225,7 @@
|
||||
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||||
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||
| База (файл на диске) | Старт кончается отказом с именем шага схемы либо шаг падает на каждом запросе | Ожидание занятой базы задано числом; исчерпав его, операция отказывает, и запись остаётся пригодной к повтору | — | — |
|
||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||
|
||||
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи:
|
||||
@@ -237,8 +236,8 @@
|
||||
«падает приведение» отличается от «падает распознавание». Плюс логи
|
||||
контейнера. Отдельного оповещения нет.
|
||||
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
|
||||
на смену рубежа, на остановку и на снятие остановки; читает его человек в
|
||||
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет.
|
||||
на смену рубежа, на остановку и на возврат в работу; ни один шаг конвейера на
|
||||
него не смотрит. Читается запросом к базе: ни панели, ни экрана у него нет.
|
||||
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
|
||||
вхолостую с паузой из
|
||||
[database.md](database.md), «Настройки с числовым значением».
|
||||
@@ -248,7 +247,11 @@
|
||||
| Что | Где |
|
||||
| --- | --- |
|
||||
| Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище |
|
||||
| Правка записи владельцем | панель хранилища; правка запросом проходит правила перехода (`pocketbase.BindPanelRules`), а шаг конвейера пишет только свои поля и правку владельца не стирает |
|
||||
| Возврат остановленной записи в работу | `cmd/devtools resume` — зовёт домен и пишет событие журнала записи с происхождением «человек»; колонок сама не пишет |
|
||||
| Выдача идентификатора строки | `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` — таблица, а не привязка к воркеру |
|
||||
@@ -264,13 +267,17 @@
|
||||
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
|
||||
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
|
||||
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
|
||||
| Узнавание предъявителя | `pbrepo.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод — `internal/controller/http.TrustedHeaderIdentity` |
|
||||
| Узнавание предъявителя | `sqlite.UserRepository.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом `contract.UserRepository` — `internal/controller/http.TrustedHeaderIdentity` |
|
||||
| Приём значения заголовка | `internal/entity.AcceptProviderLogin`, `AcceptDisplayName`, `AcceptEmail` — правило одно на все способы представиться |
|
||||
| Ограничитель частоты | `internal/controller/http.RateLimit` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса |
|
||||
|
||||
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
||||
генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло
|
||||
2026-08-13: его читает `internal/clock`, и запрет держит линтер; отображение
|
||||
доменной ошибки — 2026-08-15 задачей `json-api-for-spa`, и до неё обработчик
|
||||
решал сам: опрос отвечал `404` на упавшую базу, а приём — `500` на негодный файл.
|
||||
Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось.
|
||||
Время ушло из перечня отсутствий 2026-08-13 — его читает `internal/clock`, и
|
||||
запрет держит линтер; отображение доменной ошибки — 2026-08-15 задачей
|
||||
`json-api-for-spa`, и до неё обработчик решал сам: опрос отвечал `404` на упавшую
|
||||
базу, а приём — `500` на негодный файл; выдача идентификаторов — 2026-08-22
|
||||
задачей `storage-without-pocketbase`, и до неё их выдавало встроенное хранилище
|
||||
своим алфавитом, а сервис звал `uuid.NewString()` по месту.
|
||||
|
||||
## Деплой
|
||||
|
||||
@@ -320,8 +327,9 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
|
||||
[ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md).
|
||||
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
|
||||
зависит возвращение убранного входа.
|
||||
Панель администратора при этом Authelia не закрывает: у неё свой пароль
|
||||
суперпользователя.
|
||||
Второго периметра на порту сервиса при этом не осталось: панель администратора
|
||||
ушла вместе со встроенным хранилищем 2026-08-22, и закрывать её на прокси
|
||||
больше нечего.
|
||||
- **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение
|
||||
открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и
|
||||
списка нет — их делают `upload-and-status-screen` и `records-list-screen`.
|
||||
@@ -343,7 +351,8 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
|
||||
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
|
||||
записи живёт в хранилище»; откуда взято число —
|
||||
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
|
||||
записка не узнала».
|
||||
записка не узнала». Записка описывает умолчания ушедшей библиотеки, и живой
|
||||
она осталась только этим числом.
|
||||
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
|
||||
задан числом в [database.md](database.md), «Настройки с числовым значением»;
|
||||
обрыв начинает загрузку заново.
|
||||
@@ -358,8 +367,8 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
|
||||
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
|
||||
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
|
||||
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
|
||||
всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его
|
||||
или нет, тоже не решено.
|
||||
всегда целой. Готового копирования по расписанию у сервиса нет вовсе: оно
|
||||
ушло вместе со встроенным хранилищем, и заводить своё пока не решено.
|
||||
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
|
||||
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
|
||||
сервис определяет содержимое сам, то ли часть записей теряется на этом.
|
||||
@@ -374,12 +383,15 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
|
||||
паузы, а не замер
|
||||
([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` остаётся и развивается. Чем — дописывать
|
||||
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
|
||||
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
|
||||
выкладке сегодня нет.
|
||||
- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
|
||||
считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
|
||||
Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает
|
||||
Появляется ещё одна внешняя зависимость, платная, и текст расшифровки начинает
|
||||
уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не
|
||||
решено, отдельный это шаг конвейера или продолжение шага распознавания.
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
||||
Главные: комментариями снабжена половина полей; единого места проверки на старте
|
||||
нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в точке входа, а пустые
|
||||
ключи `[yandex]` ловит конструктор распознавателя.
|
||||
нет: у секций `[auth]`, `[pipeline]` и `[storage]` свой `Validate()` в точке
|
||||
входа, а пустые ключи `[yandex]` ловит конструктор распознавателя.
|
||||
|
||||
**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
|
||||
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
|
||||
@@ -137,9 +137,12 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
|
||||
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
|
||||
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
|
||||
|
||||
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
|
||||
`AuthConfig.Validate()` зовётся из `cmd/transcriber` сразу после загрузки и роняет
|
||||
процесс с именем незаполненного ключа. Причина в цене умолчания: поднявшись с
|
||||
Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит
|
||||
на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки
|
||||
и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой
|
||||
базы и число соединений читающего пула: ноль у первого отдаёт «база занята»
|
||||
первому же воркеру, ноль у второго означает пул без предела — то есть настройку,
|
||||
которой не управляют. Причина в цене умолчания: поднявшись с
|
||||
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
|
||||
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
|
||||
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
|
||||
|
||||
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует
|
||||
этому частью: ключи — UUID v4, а не ULID, и единой точки их генерации нет. Время
|
||||
единой точкой читается с 2026-08-13 — `internal/clock`, метка в UTC, — и правило
|
||||
держит линтер. Правила действуют на новый код; переписывание существующего —
|
||||
отдельная работа, и до неё расхождение читается как долг, а не как нарушение.
|
||||
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует этому
|
||||
целиком: ключи — ULID в нижнем регистре, выдаёт их единая точка `internal/ident`
|
||||
(с 2026-08-22, задача `storage-without-pocketbase`), время читает единая точка
|
||||
`internal/clock` (с 2026-08-13), и правило времени держит линтер. Расхождений у
|
||||
записи не осталось.
|
||||
|
||||
**Механизировано:** сверка изменённого шага схемы с
|
||||
[../database.md](../database.md) (`docs.py check`), чтение времени единой точкой
|
||||
@@ -17,17 +17,17 @@
|
||||
## Первичные ключи — ULID, не автоинкремент
|
||||
|
||||
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
|
||||
**приложением** в момент создания записи.
|
||||
*Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков
|
||||
собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id`
|
||||
хронологией не является: порядок берут по колонке времени с ключом.
|
||||
**приложением** в момент создания записи. Выдача монотонна внутри одной
|
||||
миллисекунды: колонка времени несёт секунды, и порядок записей одной секунды
|
||||
задаёт ключ. Порядок ленты берут парой «время заведения и ключ» — одного
|
||||
времени мало.
|
||||
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
|
||||
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
|
||||
целиком), глобально уникален между таблицами — поиск по голому id находит все
|
||||
записи сущности в логах.
|
||||
- **Точка генерации и разбора одна**: создание — при вставке записи в
|
||||
репозитории, разбор — на входных границах. Самодельных генераторов по месту
|
||||
вызова не заводим.
|
||||
- **Точка генерации и разбора одна** — `internal/ident`: `New` выдаёт, `Parse`
|
||||
разбирает пришедшее снаружи. Самодельных генераторов по месту вызова не
|
||||
заводим.
|
||||
|
||||
## Канонический вид — lowercase
|
||||
|
||||
@@ -47,31 +47,28 @@
|
||||
|
||||
## Прочее
|
||||
|
||||
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые
|
||||
значения держит код.
|
||||
*Расхождение:* перечни, по которым панель владельца правит запись руками,
|
||||
закрыты схемой (`SelectField`), а не кодом: правка руками не должна заводить
|
||||
значение, которого сервис не знает. Закрыты рубеж записи, причина её
|
||||
остановки, вид текста, источник и исход события журнала. Цена названа: новое
|
||||
значение любого из них потребует нового шага схемы, а применённый шаг не
|
||||
переписывается. Прочие перечни остаются обычным `TEXT`.
|
||||
- Enum-поля (`state`, `halt_reason`, …) — обычный `TEXT` без `CHECK`; допустимые
|
||||
значения держит код. Прежде часть перечней закрывала схема — правку руками вела
|
||||
панель владельца, и она вправе была завести значение, которого сервис не
|
||||
знает. Панели нет с 2026-08-22, правка идёт только нашим кодом, и закрытый
|
||||
перечень в схеме остался бы ценой — новое значение стоило бы нового шага — без
|
||||
покупателя.
|
||||
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
|
||||
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
|
||||
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
|
||||
Единая точка генерации — приложение, а не умолчание в схеме: так забытая
|
||||
вставка падает громко. Измерение длительности — не метка времени.
|
||||
*Расхождение:* вид времени задаёт хранилище — `2006-01-02 15:04:05.000Z`,
|
||||
пробел вместо `T` и доли секунды ([../database.md](../database.md), «Время»).
|
||||
Правило RFC 3339 действует на то, что пишем мы сами мимо хранилища; вид
|
||||
хранилища не меняем — сравнение строк в сыром запросе побайтово, и
|
||||
разошедшийся вид молча обращает условие срока захвата в константу.
|
||||
- Миграции — шаги PocketBase на Go
|
||||
(`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их
|
||||
поля заводятся кодом. При изменении структуры обновляем схему
|
||||
[../database.md](../database.md) тем же изменением.
|
||||
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким
|
||||
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и
|
||||
разошедшийся вид обращает условие в постоянную истину или ложь — молча.
|
||||
Умолчаний вида `CURRENT_TIMESTAMP` в схеме нет ни у одной колонки, и вид один
|
||||
на все — включая те, что пишет только сам сервис: своего типа времени у SQLite
|
||||
нет, а колонка, заполненная то одним видом, то другим, молча обращает условие
|
||||
срока захвата в константу.
|
||||
- Миграции — шаги `pressly/goose/v3` на Go
|
||||
(`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия — число в
|
||||
начале имени): таблицы, их колонки и индексы заводятся кодом. При изменении
|
||||
структуры обновляем схему [../database.md](../database.md) тем же изменением.
|
||||
- Время в запросе кладётся и сравнивается тем же видом, каким оно лежит в
|
||||
колонке. Сравнение строк побайтово, и разошедшийся вид обращает условие в
|
||||
постоянную истину или ложь — молча.
|
||||
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
|
||||
сравнение по неуникальному значению делает порядок обработки
|
||||
невоспроизводимым.
|
||||
|
||||
+23
-16
@@ -100,15 +100,19 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
|
||||
| Доменная ошибка | Статус | `error_code` | Сообщение |
|
||||
| --- | --- | --- | --- |
|
||||
| сессии нет | 401 | `unauthorized` | «требуется вход» |
|
||||
| предъявитель узнан, учётной записи пользователя нет | 403 | `forbidden` | «у вашей сессии нет учётной записи» |
|
||||
| пришедший не узнан | 401 | `unauthorized` | «сервис вас не узнал» |
|
||||
| запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
|
||||
| файл не приложен, формат не распознан, негодное значение параметра | 400 | `bad_request` | «некорректный ввод» |
|
||||
| файл не приложен, формат не распознан, негодное значение параметра, негодный диапазон | 400 | `bad_request` | «некорректный ввод» |
|
||||
| запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
|
||||
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
|
||||
| текста запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
|
||||
| текста или копии файла запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
|
||||
| прочее | 500 | `internal` | «внутренняя ошибка» |
|
||||
|
||||
Ветвь `403`/`forbidden` ушла отсюда 2026-08-22 вместе со своим единственным
|
||||
случаем: им был владелец панели, предъявивший собственный токен хранилища.
|
||||
Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку
|
||||
учётную запись заводит само.
|
||||
|
||||
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
|
||||
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
|
||||
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
|
||||
@@ -124,10 +128,13 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
`json-api-for-spa` 2026-08-15.
|
||||
|
||||
**Часть отказов рождается не в обработчике** — предел тела, ограничитель
|
||||
частоты, неизвестный путь под корнем приложения — и до этой точки не доходит
|
||||
вовсе. Их приводит к той же форме слой `OneErrorForm`, стоящий снаружи всех
|
||||
прочих. Без него формы отказа было бы две, и отказ у человека на мобильной сети
|
||||
приходил бы телом библиотеки.
|
||||
частоты, неизвестный путь под корнем приложения, негодный диапазон в запросе
|
||||
файла — и до этой точки не доходит вовсе. С 2026-08-22 отдельного слоя
|
||||
перевода им не нужно: маршрутизатор и слои написаны нами, и каждый из них
|
||||
отвечает **своей доменной ошибкой** через ту же точку. Прежде их приводил к
|
||||
общей форме слой `OneErrorForm`, стоявший снаружи всех прочих и переводивший
|
||||
тело чужой библиотеки; библиотеки не осталось, и второй формы отказа взяться
|
||||
неоткуда.
|
||||
|
||||
### Разовый ответ и сохранённая диагностика
|
||||
|
||||
@@ -135,7 +142,7 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
|
||||
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
||||
полная ошибка остаётся в логах по идентификатору задачи.
|
||||
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
|
||||
- **Сохранённая диагностика состояния** — колонка `error_text` аудиозаписи. Это
|
||||
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
|
||||
и полезен. Но:
|
||||
- **секреты запрещены** — токены, ключи, пароли, заголовок
|
||||
@@ -146,8 +153,8 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
числом рядом**: без этого непонятно, насколько сокращать.
|
||||
|
||||
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
|
||||
Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без
|
||||
машинного текста — эту часть правила держит спека `intake`.
|
||||
Наружу он при этом не выходит: карточка записи отдаёт причину остановки без
|
||||
машинного текста — эту часть правила держит спека `archive`.
|
||||
|
||||
## panic
|
||||
|
||||
@@ -156,11 +163,11 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
|
||||
это значения `error`.
|
||||
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
|
||||
ронял процесс. В transcriber его вешает роутер хранилища сам
|
||||
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
|
||||
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
|
||||
живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в
|
||||
шаге конвейера роняет процесс целиком.
|
||||
ронял процесс. В transcriber его ставит свой слой `http.Recover`: паникующий
|
||||
обработчик отдаёт `500` нашей формой тела, а строка о панике идёт в журнал
|
||||
владельца. Слой стал своим 2026-08-22 вместе с роутером — прежде его вешала
|
||||
чужая библиотека. У воркеров такой границы **нет**: паника в шаге конвейера
|
||||
роняет процесс целиком.
|
||||
|
||||
## Несколько ошибок
|
||||
|
||||
|
||||
@@ -78,10 +78,11 @@
|
||||
| --- | --- |
|
||||
| Сравнение ошибок через `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` |
|
||||
| Непроверенное возвращаемое значение ошибки | `.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`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
|
||||
| Отказ выборки из базы не теряется (`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` |
|
||||
|
||||
### Структура и границы
|
||||
@@ -90,8 +91,9 @@
|
||||
| --- | --- |
|
||||
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
|
||||
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` |
|
||||
| Транспорты не знают адаптеров | `internal/archrules` → `TestТранспортыНеЗнаютАдаптеров`. Правило заведено 2026-08-22: изъятие, разрешавшее транспорту знать адаптер хранилища, снято вместе с предметом |
|
||||
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
|
||||
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком |
|
||||
| Колонки записи согласованы: что пишет отображение ↔ что спрошено чтением ↔ что доезжает до сущности ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках (`TestКолонкиЗаписиПишутсяИЧитаются`, `TestПрочитанныеКолонкиДоезжаютДоСущности`, `TestКолонкиЗаписиЗаведеныШагомСхемы`). Закрывает инвариант «колонки записи правятся в трёх местах» (CLAUDE.md, major), которого компилятор не держит. Имя колонки ищется в телах нужных функций, а не в файле целиком |
|
||||
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
|
||||
|
||||
### Отмена и внешний собеседник
|
||||
@@ -138,7 +140,7 @@
|
||||
|
||||
| Правило | Где механизировано |
|
||||
| --- | --- |
|
||||
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
|
||||
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `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` |
|
||||
@@ -188,11 +190,9 @@
|
||||
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
|
||||
молчит на всём каталоге, и на подозрении база задаётся руками
|
||||
(`task migrations BASE=<rev>`);
|
||||
- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно —
|
||||
`controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и
|
||||
есть роутер этого хранилища. Изъятие названо в
|
||||
[../architecture.md](../architecture.md), «Принципы», и правила на это направление
|
||||
нет.
|
||||
- чистота домена: правила смотрят ядро, входы и адаптеры, а импорт внешней
|
||||
библиотеки в `internal/entity` сегодня пройдёт молча. Названо в
|
||||
[../architecture.md](../architecture.md), «Слои и модель домена».
|
||||
|
||||
Отдельно названы **правила, чей подъём отклонён**:
|
||||
|
||||
|
||||
@@ -105,12 +105,12 @@ stdlib-логом в поток ошибок. Это выбор, а не дол
|
||||
|
||||
| Когда добавляем | Поля |
|
||||
| --- | --- |
|
||||
| на входящий HTTP-запрос | `transport` (`http`), `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` идёт маршрут из закрытого перечня — точный адрес наблюдения либо образец адреса приложения, — а всё прочее обозначается одним общим значением |
|
||||
| на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
|
||||
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
|
||||
| на запись об ошибке | `error` |
|
||||
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
||||
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт), `http.path_length`. Самого пути в строке нет: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. Вместо пути в `http.route` стоит `<приложение>` |
|
||||
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт). Правило о пути — строкой выше, общее: вместо пути в `http.route` стоит `<приложение>` |
|
||||
| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |
|
||||
|
||||
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
|
||||
@@ -205,7 +205,7 @@ Object Storage и опрос операции не логируются ника
|
||||
## HTTP и проверка здоровья
|
||||
|
||||
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
|
||||
`http.status_code`, `duration_ms`, `transport`.
|
||||
`http.status_code`, `duration_ms`, `http.path_length`, `transport`.
|
||||
- **Поле, которое уже даёт логгер с подставленным ключом, руками не
|
||||
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
|
||||
потребитель молча оставит одно из значений. Правило проверяется чтением,
|
||||
@@ -214,13 +214,12 @@ Object Storage и опрос операции не логируются ника
|
||||
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
|
||||
`INFO` они не пишутся.
|
||||
|
||||
Расхождения здесь больше нет: слой журналирования запросов свой,
|
||||
`cmd/transcriber`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics`
|
||||
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе.
|
||||
Расхождения здесь больше нет: слой журналирования запросов свой —
|
||||
`internal/controller/http`, `journal.go`. `/health` и `/metrics` идут на `DEBUG`,
|
||||
то есть при боевом `INFO` не пишутся вовсе.
|
||||
|
||||
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден
|
||||
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера,
|
||||
по которому разбирают отказы, эта таблица не попадает.
|
||||
**Журнал у сервиса один.** Второй, куда встроенное хранилище клало путь целиком
|
||||
вместе с адресом отправителя, ушёл вместе с самим хранилищем 2026-08-22.
|
||||
|
||||
## Безопасность: что не логируем
|
||||
|
||||
@@ -257,7 +256,7 @@ Object Storage и опрос операции не логируются ника
|
||||
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
||||
расширением. В журнал оно идёт **собственным полем** строки приёма — это
|
||||
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
|
||||
«Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе
|
||||
«Инварианты»); ни имени файла на диске, ни пути к нему в журнале нет вовсе
|
||||
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
|
||||
там расширение приводится к перечню известных форматов. Остаток описан в
|
||||
[../security.md](../security.md).
|
||||
|
||||
@@ -74,15 +74,16 @@
|
||||
[webapp](../../openspec/specs/webapp/spec.md).
|
||||
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
|
||||
к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
|
||||
`404`; путь внутри корня в приложение не проваливается никогда. Корней
|
||||
сегодня три — `/api/` у хранилища, `/app/` у приложения, `/_/` у панели, —
|
||||
плюс `/health` и `/metrics` отдельными адресами. Корень `/auth/` снят
|
||||
2026-08-22 вместе с собственным входом, и пути под ним стали обычными путями
|
||||
вне корней. Приложение
|
||||
уехало из общего `/api/` решением владельца 2026-08-15: пространство
|
||||
принадлежит хранилищу, и обновление библиотеки вправе занять там имя рядом с
|
||||
нашим. Перечень корней сервису не описывают, а из него **порождают**
|
||||
регистрацию маршрутов: описанный порознь, он разошёлся бы с ними молча.
|
||||
`404`; путь внутри корня в приложение не проваливается никогда. Корень
|
||||
сегодня **один** — `/app/` у приложения, — плюс `/health` и `/metrics`
|
||||
отдельными адресами. Корни `/auth/`, `/api/` и `/_/` сняты 2026-08-22: первый
|
||||
ушёл с собственным входом, два других — со встроенным хранилищем и его
|
||||
панелью, и пути под ними стали обычными путями вне корней. Приложение уехало
|
||||
из общего `/api/` решением владельца 2026-08-15, и корень свой сохранило:
|
||||
соседа, ради которого выбирался, больше нет, а формы запросов и ответов от
|
||||
смены хранилища не изменились ни одним полем. Перечень корней сервису не
|
||||
описывают, а из него **порождают** регистрацию маршрутов: описанный порознь,
|
||||
он разошёлся бы с ними молча.
|
||||
- **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика,
|
||||
которому не нашлось файла, отвечает `404`. Правило — вторая половина
|
||||
предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и
|
||||
|
||||
+276
-217
@@ -1,62 +1,118 @@
|
||||
# Схема хранилища
|
||||
|
||||
Хранилище, коллекции, правило времени и идентификаторов.
|
||||
База, таблицы, раскладка файлов, правило времени и идентификаторов.
|
||||
|
||||
Хранилище — **встроенная PocketBase 0.39.10**: она держит и базу, и файлы
|
||||
записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`,
|
||||
умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому
|
||||
CGO сборке не нужен.
|
||||
Хранилище **своё**: база SQLite через `modernc.org/sqlite` (CGO сборке не нужен)
|
||||
и файлы записей своим каталогом рядом с ней. Ключ конфигурации один —
|
||||
`[storage] data_dir`, умолчание `data`. Встроенная PocketBase, державшая до
|
||||
2026-08-22 и базу, и файлы, и панель, и маршрутизатор, ушла из проекта целиком —
|
||||
задача `storage-without-pocketbase`,
|
||||
[ADR](adr/ADR-2026-08-22-storage-without-pocketbase.md).
|
||||
|
||||
Схему двигают **шаги миграций PocketBase** на Go, каталог
|
||||
`internal/adapter/repo/pocketbase/migrations`, файл на шаг и имя файла — имя
|
||||
шага. Шаг регистрируется при загрузке пакета, а накатывается при подъёме
|
||||
хранилища (`pocketbase.New`), прежде чем стартуют воркеры и сервер. Применённый
|
||||
шаг не переписывается — изменение только новым шагом: применённое хранилище
|
||||
считает по имени шага.
|
||||
**База принимает одного писателя.** Пишущий пул держит одно соединение — драйвер
|
||||
пишет единственным, и несколько воркеров, пришедших писать разом мимо этого
|
||||
правила, получают отказ по занятости на записи результата шага, то есть после
|
||||
оплаченной работы. Чтение идёт отдельным пулом: в журнале упреждающей записи
|
||||
читатели не мешают писателю.
|
||||
|
||||
Журнал упреждающей записи, соблюдение внешних ключей и ожидание занятой базы
|
||||
задаются **строкой подключения обоих пулов**, а не запросом после открытия: две
|
||||
из трёх настроек в SQLite принадлежат соединению, а не базе, а пул заводит новые
|
||||
соединения по мере надобности — запрос настроил бы одно из многих. Операция,
|
||||
которая читает и следом пишет, идёт целиком по пишущему соединению: читающую
|
||||
транзакцию SQLite до пишущей не повышает и отказывает по занятости немедленно.
|
||||
|
||||
Схему двигают **шаги `github.com/pressly/goose/v3`** — библиотекой, а не
|
||||
командной строкой. Каталог `internal/adapter/repo/sqlite/migrations`, файл на
|
||||
шаг, версия шага — число в начале имени файла. Перечень шагов приходит
|
||||
провайдеру доводом, провайдер заводится в точке входа и получает пишущий пул,
|
||||
накат идёт **до подъёма входов и до старта воркеров**, а отказ шага роняет старт.
|
||||
Применённый шаг не переписывается — изменение только новым шагом.
|
||||
|
||||
Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
|
||||
соединении. Порядок шагов детерминирован и выводится из версии, а не из порядка
|
||||
чтения каталога; две одинаковых версии дают отказ сбора.
|
||||
|
||||
**Исключающую блокировку наката держим сами.** Библиотека под SQLite её не
|
||||
поставляет вовсе — её запиратели объявлены только для PostgreSQL, а провайдер без
|
||||
запирателя накатывает без всякой блокировки. Замок берётся на файле
|
||||
`data/migrate.lock` (`syscall.Flock`, `LOCK_EX`) и снимается закрытием
|
||||
дескриптора; с умершим процессом его снимает ядро, поэтому просроченного замка,
|
||||
который надо чистить руками, не остаётся.
|
||||
|
||||
Каталог у шагов свой, а не файл внутри пакета репозитория, и причина внешняя:
|
||||
шаг гейта сверяет изменённые шаги схемы с правкой этого документа по **префиксу
|
||||
пути**, а префикс наводится только на каталог. Где этот префикс задан —
|
||||
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано». Имена коллекций живут там же, рядом с шагом, который их заводит; пакет
|
||||
репозитория берёт их оттуда.
|
||||
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано».
|
||||
|
||||
**Идентификаторы** записей выдаёт хранилище — 15 знаков собственного алфавита.
|
||||
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в
|
||||
хранилище, задаёт сервис, и это `<uuid><расширение>`.
|
||||
**Идентификаторы** — ULID в нижнем регистре, `TEXT`, 26 знаков алфавита
|
||||
Crockford. Выдаёт их приложение единой точкой `internal/ident`; внутри одной
|
||||
миллисекунды выдача монотонна, потому что колонка времени несёт секунды и
|
||||
порядок записей одной секунды задаёт ключ. Идентификатор, пришедший снаружи,
|
||||
разбирается на границе: разбор проверяет вид и приводит регистр, а негодный
|
||||
считается несуществующей записью и до базы не доходит.
|
||||
|
||||
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки
|
||||
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе
|
||||
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
|
||||
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
|
||||
постоянную ложь молча.
|
||||
Тем же идентификатором зовётся **подкаталог записи** в каталоге данных, а имя
|
||||
файла внутри него — `<ULID><расширение>`.
|
||||
|
||||
Того, что единой точки генерации идентификатора и времени нет, здесь не
|
||||
повторяем: перечень единых точек и их отсутствий держит
|
||||
[architecture.md](architecture.md), «Единые точки проекта».
|
||||
**Время** — `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`
|
||||
|
||||
Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
|
||||
Одна строка на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
|
||||
приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
|
||||
считается — она существует только потому, что провайдер распознавания читает
|
||||
аудио по адресу, и её ключ живёт в строке попытки распознавания.
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `file` | file | Сам файл |
|
||||
| `owner` | relation → `users` | Владелец файла; пустого значения не принимает |
|
||||
| `location` | select | `local` или `s3` |
|
||||
| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется |
|
||||
| `size` | INTEGER | Размер в байтах |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `owner_id` | TEXT → `users(id)` | Владелец копии; пустого значения не принимает |
|
||||
| `record_id` | TEXT | Запись, которой копия принадлежит: имя её подкаталога |
|
||||
| `file_name` | TEXT | Имя файла в этом подкаталоге; задаёт сервис |
|
||||
| `size_bytes` | INTEGER | Размер копии в байтах |
|
||||
| `format` | TEXT | Расширение без точки, в нижнем регистре |
|
||||
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
|
||||
| `created`, `updated` | DATETIME | Проставляет хранилище |
|
||||
| `created_at` | TEXT | Время |
|
||||
|
||||
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и
|
||||
capability, и третий смысл развёл бы одно слово по разным вещам.
|
||||
**Внешнего ключа на аудиозапись у `record_id` нет намеренно.** Приём заводит
|
||||
файл **до** самой записи — подкаталог назван её идентификатором, и знать его надо
|
||||
раньше, — и обязательная связь отвергала бы первую же принятую запись. Владелец
|
||||
при этом лежит своей колонкой, а не выводится через запись: файл переживает свою
|
||||
запись, и заведённый шагом до её сохранения остаётся с владельцем и без ссылки.
|
||||
|
||||
### `audio_records`
|
||||
|
||||
@@ -65,36 +121,37 @@ capability, и третий смысл развёл бы одно слово п
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `owner` | relation → `users` | Владелец записи; пустого значения не принимает |
|
||||
| `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `owner_id` | TEXT → `users(id)` | Владелец записи; пустого значения не принимает |
|
||||
| `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` |
|
||||
| `original_filename` | TEXT | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
|
||||
| `duration_ms` | INTEGER, обязателен | Длительность **принятого**, миллисекунды; ставит приём и всегда |
|
||||
| `size_bytes` | INTEGER, обязателен | Размер **принятого**, байты |
|
||||
| `state` | TEXT | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done` |
|
||||
| `state_entered_at` | TEXT | Время входа в рубеж — сторож застревания |
|
||||
| `halted_at` | TEXT | Признак остановки; рубеж при ней не стирается |
|
||||
| `halt_reason` | TEXT | `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 | Проставляет хранилище |
|
||||
| `acquire_expires_at` | TEXT | Срок протухания захвата; приезжает с рубежом |
|
||||
| `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 | Время |
|
||||
|
||||
Индексов три. Первый — по паре «рубеж и признак остановки»: по ним, паузе и
|
||||
сроку протухания идёт выборка захвата. Два других завела страница списка приложения:
|
||||
`(owner, created DESC, id DESC)` под страницу «новыми сверху» и
|
||||
`(owner, state, halted_at)` под отбор тремя состояниями.
|
||||
Индексов два. `idx_audio_records_acquire` — `(state, halted_at, created_at, id)`:
|
||||
по нему идёт отбор захвата, и по нему же он берёт запись в определённом порядке.
|
||||
`idx_audio_records_owner_page` — `(owner_id, created_at, id)`: под страницу
|
||||
списка, сужаемую владельцем и режущуюся полным ключом сортировки.
|
||||
|
||||
Оба индекса заведены **начальным шагом**, а не отложены: применённый шаг схемы не
|
||||
переписывается, и добавление индекса стоило бы отдельного шага. Проверено
|
||||
`EXPLAIN QUERY PLAN`: ни отбор захвата, ни страница списка не показывают полного
|
||||
сканирования таблицы.
|
||||
|
||||
**Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает
|
||||
ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса
|
||||
@@ -109,21 +166,20 @@ capability, и третий смысл развёл бы одно слово п
|
||||
узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное
|
||||
название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит
|
||||
извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя
|
||||
файла в хранилище и в журнал оно по-прежнему не идёт.
|
||||
файла на диске и в журнал оно по-прежнему не идёт.
|
||||
|
||||
**Длительность и размер лежат и на записи, и на её файле, и равенство между ними
|
||||
не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый
|
||||
приёмом один раз; на файле — величины нынешней копии файла.
|
||||
Уточнение длительности меняет вторые и не трогает первые: это разные вопросы —
|
||||
«что человек прислал» и «что лежит сейчас». Колонками записи они нужны потому,
|
||||
что показываются в списке, а список читается без содержимого. Решение владельца
|
||||
от 2026-08-15.
|
||||
приёмом один раз; на файле — величины нынешней копии. Уточнение длительности
|
||||
меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и
|
||||
«что лежит сейчас». Колонками записи они нужны потому, что показываются в списке,
|
||||
а список читается без содержимого. Решение владельца от 2026-08-15.
|
||||
|
||||
**«Неизвестно» эти колонки не выражают, и это решение владельца от 2026-08-15.**
|
||||
Числовая колонка хранилища пустого значения не держит: пустое она кладёт нулём.
|
||||
Платить за отличимость четвёртой колонкой-признаком или текстовым типом у чисел
|
||||
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными
|
||||
метаданными отвергается отказом и не заводится вовсе.
|
||||
**«Неизвестно» эти колонки не выражают**, и это то же решение владельца: обе
|
||||
величины ставит приём и ставит всегда — запись с непрочитанными метаданными
|
||||
отвергается отказом и не заводится вовсе. Обе объявлены обязательными: пустое
|
||||
значение, которое схема теперь допустить может, завело бы третий смысл, которого
|
||||
никто не читает.
|
||||
|
||||
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
|
||||
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
|
||||
@@ -131,20 +187,32 @@ capability, и третий смысл развёл бы одно слово п
|
||||
|
||||
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
|
||||
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
|
||||
признака, — и различие между ними перестало быть структурным. Рубеж при
|
||||
остановке сохраняется, поэтому запись продолжает с места остановки.
|
||||
признака, — и различие между ними перестало быть структурным.
|
||||
|
||||
**Сторожей двое.** `attempts` ограничивает повторы внутри шага,
|
||||
`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не
|
||||
справлялось ни с одной.
|
||||
|
||||
### `record_topics`
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record_id` | TEXT → `audio_records(id)` | Запись |
|
||||
| `topic_id` | TEXT → `topics(id)` | Тема |
|
||||
|
||||
Первичный ключ — пара целиком. Потолок в пять тем на запись держит **триггер**:
|
||||
без него часовой разговор даёт два десятка тем, и словарь распухает за неделю.
|
||||
Число берётся у домена — то же самое, которое сервис объявляет приложению.
|
||||
|
||||
### `texts`
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record` | relation → `audio_records` | Чья это расшифровка |
|
||||
| `kind` | select | `transcript` или `literary` |
|
||||
| `contents` | editor | Сам текст |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `record_id` | TEXT → `audio_records(id)` | Чья это расшифровка |
|
||||
| `kind` | TEXT | `transcript` или `literary` |
|
||||
| `contents` | TEXT | Сам текст |
|
||||
| `created_at`, `updated_at` | TEXT | Время |
|
||||
|
||||
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
|
||||
Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат
|
||||
@@ -154,9 +222,11 @@ capability, и третий смысл развёл бы одно слово п
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record` | relation → `audio_records` | Чья это структура |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `record_id` | TEXT → `audio_records(id)` | Чья это структура |
|
||||
| `version` | INTEGER | Версия вида разбора |
|
||||
| `contents` | JSON | Реплики со временем |
|
||||
| `contents` | TEXT | Реплики со временем, JSON |
|
||||
| `created_at`, `updated_at` | TEXT | Время |
|
||||
|
||||
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
|
||||
сохранённого ответа изменится раньше, чем архив пересчитают.
|
||||
@@ -167,92 +237,86 @@ capability, и третий смысл развёл бы одно слово п
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record` | relation → `audio_records` | Чья это попытка |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `record_id` | TEXT → `audio_records(id)` | Чья это попытка |
|
||||
| `provider`, `model` | TEXT | Кем и какой моделью считано |
|
||||
| `external_id` | TEXT | Идентификатор операции у провайдера |
|
||||
| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио |
|
||||
| `payload` | file, **защищённое** | Сырой ответ провайдера целиком |
|
||||
| `started_at`, `finished_at` | DATETIME | Границы операции |
|
||||
| `payload_file` | TEXT | Имя файла с сохранённым ответом провайдера |
|
||||
| `started_at`, `finished_at` | TEXT | Границы операции |
|
||||
| `created_at`, `updated_at` | TEXT | Время |
|
||||
|
||||
**Сырой ответ лежит вложением, а не колонкой.** Шаг опроса читает эту строку раз
|
||||
в несколько секунд, а хранилище читает запись целиком: ответ на многочасовую
|
||||
запись ехал бы в память при каждом опросе. Хранится он потому, что результат
|
||||
операции у провайдера не переспрашивается.
|
||||
|
||||
Поле вложения помечено защищённым: сырой ответ — это полный текст речи, и
|
||||
умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
|
||||
**Сохранённый ответ лежит третьим файлом в подкаталоге записи, а не колонкой.**
|
||||
Шаг опроса читает эту строку раз в несколько секунд, а репозиторий читает строку
|
||||
целиком: ответ на многочасовую запись, положенный колонкой, ехал бы в память при
|
||||
каждом опросе. Хранится он потому, что результат операции у провайдера не
|
||||
переспрашивается. Копией аудио он при этом не считается — их у записи по-прежнему
|
||||
две, — и адреса, которым его читают снаружи, у сервиса нет вовсе.
|
||||
|
||||
### `record_events`
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `record` | relation → `audio_records` | Чьё это событие |
|
||||
| `origin` | select | `pipeline` или `human` |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `record_id` | TEXT → `audio_records(id)` | Чьё это событие |
|
||||
| `origin` | TEXT | `pipeline` или `human` |
|
||||
| `step` | TEXT | Имя шага |
|
||||
| `outcome` | select | `done`, `failed`, `halted`, `resumed` |
|
||||
| `outcome` | TEXT | `done`, `failed`, `halted`, `resumed` |
|
||||
| `outcome_text` | TEXT | Причина, если она есть |
|
||||
| `duration_ms` | INTEGER | Сколько шаг занял |
|
||||
| `created_at` | TEXT | Время |
|
||||
|
||||
Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо
|
||||
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
|
||||
двусмысленным.
|
||||
|
||||
Журнал пишется на смену рубежа, на остановку и на снятие остановки — не на
|
||||
Журнал пишется на смену рубежа, на остановку и на возврат в работу — не на
|
||||
каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить,
|
||||
что делать дальше.
|
||||
что делать дальше. Происхождение `human` пишет сегодня подкоманда оснастки,
|
||||
возвращающая остановленную запись в работу: другого писателя, кроме конвейера, у
|
||||
журнала не осталось.
|
||||
|
||||
### `topics`
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `owner` | relation → `users` | Чей это словарь |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `owner_id` | TEXT → `users(id)` | Чей это словарь |
|
||||
| `name` | TEXT | Название темы |
|
||||
| `created_at`, `updated_at` | TEXT | Время |
|
||||
|
||||
Пара «владелец и название» уникальна: словарь тем свой у каждого человека.
|
||||
Коллекцией, а не набором строк в записи, потому что перечень тем нужен целиком
|
||||
перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем
|
||||
не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не
|
||||
платила вторым необратимым шагом схемы.
|
||||
Отдельной таблицей, а не набором строк в записи, потому что перечень тем нужен
|
||||
целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего
|
||||
сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача,
|
||||
считающая темы, не платила вторым необратимым шагом схемы.
|
||||
|
||||
### Чего в схеме больше нет
|
||||
|
||||
Коллекция `transcribe_jobs` удалена шагом `202608140002`. Данных под ней не было:
|
||||
сервис на сервере остановлен, а прежние записи удалены решением владельца
|
||||
2026-08-14 — переноса это изменение не делало. Оставленная пустая коллекция
|
||||
висела бы в панели вторым домом для понятия, которого больше нет.
|
||||
**Каталог шагов PocketBase удалён целиком, и на его месте стоит один шаг
|
||||
начальной схемы** — `202608220002_init.go`. Это разовое снятие инварианта
|
||||
«применённая миграция не переписывается», решением владельца от 2026-08-22:
|
||||
стадия проекта — стройка, на сервере данных нет, сервис остановлен, а новая база
|
||||
ведёт учёт применённого своей таблицей, которой отметки прежнего каталога не
|
||||
годятся вовсе. Снятие кончается этим шагом.
|
||||
|
||||
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в
|
||||
обеих таблицах, — и шагом `202608140003` пустого значения больше не принимает.
|
||||
Прежде принимал, и цену за это платили записи входа Telegram: связи чата с
|
||||
учётной записью сервис не вёл. Вход убран 2026-08-14, ничью запись заводить стало
|
||||
некому, и обязательность переехала из приёма в схему — туда, где её держит
|
||||
хранилище, а не договорённость.
|
||||
**Колонок `location` и `source` в новой схеме нет.** Обе писались одним значением
|
||||
и не читались никем: в `location` уходило `local`, второго значения (`s3`) не
|
||||
писал ни один шаг; в `source` всякий приём писал `api`, а второе значение
|
||||
(`telegram`) держалось ссылкой из применённого шага, а не потребителем. Шаги
|
||||
ушли, и держать их стало нечем. Поле, у которого появится читатель, вернётся
|
||||
одним новым шагом схемы.
|
||||
|
||||
**Колонки `tg_chat_id` и `tg_reply_message_id`** остались от убранного входа и
|
||||
кодом больше не читаются. Из схемы они не убираются: заводили их применённые
|
||||
шаги `202608110001` и `202608140002`, а применённый шаг не переписывается.
|
||||
**Колонок `tg_chat_id`, `tg_reply_message_id` и `object_key` нет по той же
|
||||
причине:** их держал применённый шаг, которого больше не существует.
|
||||
|
||||
Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая
|
||||
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер
|
||||
обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции `files`
|
||||
владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора
|
||||
файловой записи равнялось праву скачать чужое аудио.
|
||||
|
||||
**Учётная запись с записями не удаляется.** Каскадное удаление у связи выключено,
|
||||
но одного этого мало: при выключенном каскаде хранилище снимает ссылку и
|
||||
сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья
|
||||
запись не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
|
||||
а не правило коллекции: панель ходит правами суперпользователя, и правило её не
|
||||
судит. Считаются все коллекции с колонкой владельца — `audio_records`, `files` и
|
||||
`topics`, — и перечень живёт одним местом: пропущенная коллекция пропускает
|
||||
удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь
|
||||
вместо нашего отказа с причиной.
|
||||
|
||||
**Правила доступа новых коллекций пусты**, то есть перечислять и читать их может
|
||||
только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не
|
||||
поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок.
|
||||
Проверено прогоном: анонимный запрос к `/api/collections/*/records` отвечает
|
||||
`403`, к `/api/logs`, `/api/backups`, `/api/settings` и `/api/crons` — `401`.
|
||||
**Учётная запись с записями не удаляется**, и держит это схема обязательной
|
||||
связью, а не проверка вызывающего: `audio_records`, `files` и `topics` ссылаются
|
||||
на `users(id)` без каскада, а соблюдение внешних ключей включено на каждом
|
||||
соединении обоих пулов. Прежде запрет ставил слой приложения — сборка, забывшая
|
||||
его позвать, теряла защиту молча, и теряла. Адреса, которым учётную запись
|
||||
удаляют, у сервиса нет вовсе; способа удалить записи тоже нет, и это осознанный
|
||||
тупик до задачи про удаление записи.
|
||||
|
||||
## Представление данных
|
||||
|
||||
@@ -260,70 +324,63 @@ capability, и третий смысл развёл бы одно слово п
|
||||
|
||||
- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
|
||||
её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
|
||||
колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той
|
||||
же строки и читалась при каждом опросе очереди.
|
||||
- **Аудио лежит в раскладке хранилища:**
|
||||
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
|
||||
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
|
||||
потому что умолчание, строящее имя из имени отправителя, не применяется. Ни
|
||||
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
|
||||
каталог и бакет растут неограниченно.
|
||||
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||
помечено защищённым шагом `202608120001`, а правило просмотра коллекции
|
||||
пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
|
||||
файла, который берёт узнанный. Прежнее решение — «право прочитать запись даёт
|
||||
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
|
||||
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
|
||||
- **Коллекция `users`** заводится самой библиотекой, а два наших шага её сужают.
|
||||
`202608120001` выключил вход по паролю и одноразовый код; `202608220001`
|
||||
довершил: снял настройки OAuth2 и **все пять правил доступа** — перечисление,
|
||||
чтение, создание, правку и удаление, — оставив их пустыми, что у хранилища
|
||||
означает «только владелец панели».
|
||||
|
||||
Правку и удаление умолчание библиотеки открывало владельцу записи
|
||||
(`id = @request.auth.id`), и до переезда входа это ничему не мешало: слой
|
||||
предъявления жил под корнем приложения, и браузер до поверхности хранилища не
|
||||
дотягивался. С узнаванием по заголовку она достижима, а ключ учётной записи
|
||||
лежит теперь обычной колонкой — правка своей записи была бы присвоением чужого
|
||||
имени. Наш код читает и заводит запись мимо правил, панель работает
|
||||
суперпользователем, своих экранов профиля сервис не заводит.
|
||||
|
||||
- **Ключ учётной записи — колонка `provider_login`** с уникальным индексом,
|
||||
заведена шагом `202608220001`. В ней логин человека **у провайдера** — то
|
||||
значение, которым его называет обратный прокси заголовком. По нему запись
|
||||
ищется и по нему же заводится при первом обращении.
|
||||
|
||||
Почта в той же коллекции переведена в необязательную тем же шагом: провайдер
|
||||
не обязан её приносить, а ключом она не служит. Уникальность почты держится
|
||||
**частичным** индексом (`WHERE email != ''`), поэтому записи без почты
|
||||
уживаются друг с другом; уникальность логина — обычным, поэтому двух записей с
|
||||
пустым ключом схема не примет вовсе.
|
||||
- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
|
||||
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
||||
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
|
||||
невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания
|
||||
захвата и отсутствию признака остановки; срок протухания выбирается по рубежу
|
||||
самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить
|
||||
его не может.
|
||||
колонки шаг читает отдельным чтением.
|
||||
- **Файлы записи лежат подкаталогом на запись:**
|
||||
`data/records/<ULID записи>/<имя>`. Внутри — принятая копия, приведённая копия
|
||||
и сохранённый ответ провайдера. Так копии одной записи лежат вместе, а запись
|
||||
убирается целиком одним движением; плоский каталог, где копии различаются
|
||||
приставкой в имени, обращал бы уборку в перебор по маске. Имя, данное
|
||||
отправителем, не попадает ни в имя файла, ни в путь к нему. Ни файлы, ни
|
||||
объекты в Object Storage не удаляются после завершения записи: каталог и бакет
|
||||
растут неограниченно.
|
||||
- **Укладка атомарна:** содержимое пишется во временное имя **в том же
|
||||
подкаталоге записи** и переименовывается в рабочее только после того, как поток
|
||||
дочитан до конца без отказа. Строка о файле заводится **после** этого;
|
||||
содержимое легло, а строка не сохранилась — уложенный файл убирается.
|
||||
- **Файл отдаётся адресом приложения** —
|
||||
`GET /app/audiorecords/{id}/file?copy=original|normalized`, — и право пройти по
|
||||
нему даёт узнавание пришедшего и владение записью. Значений на предъявителя
|
||||
сервис не выдаёт вовсе: ни короткого токена файла, ни подписанной ссылки со
|
||||
сроком. Отзыв доступа доходит до файла сразу, а не через срок жизни выданного
|
||||
значения. Имя файла на диске в журнал не пишется и в ответ не идёт.
|
||||
- **Учётная запись заводится первым обращением** — поиск по `provider_login` и
|
||||
вставка идут одной транзакцией на пишущем соединении. Два отказа уникальности
|
||||
различаются повторным поиском по ключу: нашёлся — гонка двух первых обращений
|
||||
одним логином, не нашёлся — занятая почта, и запись заводится без неё.
|
||||
- **Захват записи — один запрос `UPDATE … RETURNING`** по пишущему соединению:
|
||||
выбор подходящей записи и пометка её захваченной идут вместе. Порядок выборки —
|
||||
по времени заведения **и по ключу**: время неуникально, и без ключа порядок
|
||||
обработки невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку
|
||||
протухания захвата и отсутствию признака остановки; срок протухания выбирается
|
||||
по рубежу самой записи прямо в запросе — воркер, ещё не знающий, что вытянет,
|
||||
подставить его не может.
|
||||
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет
|
||||
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
|
||||
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому,
|
||||
что условие проверяется тем же запросом, что и сам захват.
|
||||
- **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с
|
||||
`applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре,
|
||||
пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и
|
||||
перечень перестал расти с моделью. Правило правки и его серьёзность —
|
||||
инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила
|
||||
норма — [pipeline](../openspec/specs/pipeline/spec.md). Условие стоит в самом
|
||||
запросе правки, поэтому между проверкой и записью не остаётся окна.
|
||||
- **Колонки записи отображаются по имени**: именованные параметры запроса и место
|
||||
назначения, найденное по имени колонки. У аудиозаписи поля одного типа идут
|
||||
длинным непрерывным рядом, и позиционный список дал бы сдвиг на одно поле,
|
||||
который компилируется молча и кладёт идентификатор файла в колонку текста.
|
||||
Перечень задан двумя местами — `writeOwnedByPipeline` вместе с `writeRecord` и
|
||||
`readRecordColumns`, — плюс шагом схемы; правило правки и его серьёзность —
|
||||
инвариант в [CLAUDE.md](../CLAUDE.md), сверку держат правила
|
||||
`internal/archrules`.
|
||||
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
|
||||
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
|
||||
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
|
||||
по инварианту проекта не пишется в журнал и не считается в метрику.
|
||||
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
|
||||
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
|
||||
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
|
||||
в Object Storage: отказ SDK несёт полный URL объекта.
|
||||
- **Отказ базы наружу не выходит дословно.** Отказы чтения и укладки называют
|
||||
запись её идентификатором и не несут ни имени файла, ни пути к нему: имя —
|
||||
часть пути к чужому аудио. То же у выгрузки в Object Storage: отказ SDK несёт
|
||||
полный URL объекта.
|
||||
- **Обращения к базе идут с собственным контекстом**, а не с контекстом запроса.
|
||||
Отменять там нечего: операции местные и короткие, а единственное ожидание —
|
||||
занятая база — задано числом. За отмену платили бы дважды: шаг, прерванный
|
||||
остановкой сервиса, перестал бы освобождать захват и писать причину остановки —
|
||||
то есть отмена ломала бы ровно ту уборку, ради которой она и делается. Отмена,
|
||||
которой сервис распоряжается по-настоящему, доходит до `ffmpeg` и до платного
|
||||
распознавания.
|
||||
|
||||
## Настройки с числовым значением
|
||||
|
||||
@@ -336,41 +393,46 @@ capability, и третий смысл развёл бы одно слово п
|
||||
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
|
||||
| Срок захвата, завершение | 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` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
|
||||
|
||||
**Адрес спрашивающего берётся из `X-Forwarded-For`, и это назначается кодом при
|
||||
подъёме** — `controller/http.ApplyTrustedProxyHeaders`. Без этого хранилище
|
||||
ключует счётчик адресом пира, а пир с переездом входа на заголовок всегда один и
|
||||
тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и восемь
|
||||
одновременно открытых карточек выбирают его целиком. Требование к контуру,
|
||||
которое отсюда следует, записано в [security.md](security.md), «Периметр»:
|
||||
`X-Forwarded-For` прокси обязан перезаписывать, а не дописывать.
|
||||
| Срок жизни неиспользуемого счётчика ограничителя | 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` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново |
|
||||
| Потолок сохранённого ответа провайдера | 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` | расчётный потолок в шесть часов с запасом на видео |
|
||||
| Предел длины логина у провайдера | 255 знаков | `pbrepo.MaxProviderLoginLength` и колонка `provider_login` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени в умолчании библиотеки |
|
||||
| Предел длины логина у провайдера | 255 знаков | `entity.MaxProviderLoginLength` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени, пригодного к показу |
|
||||
| Предел длины имени, пригодного к показу | 255 знаков | `entity.MaxDisplayNameLength` | то же |
|
||||
| Длина идентификатора | 26 знаков | `ident.Len` | ширина записи ULID |
|
||||
|
||||
Три числа отсюда ушли 2026-08-22 вместе с собственным входом: срок жизни сессии,
|
||||
потолок времени на вход у провайдера и таймаут обмена кода. Сессия не выдаётся
|
||||
вовсе, обменивать код не на что, а отзыв доступа судит провайдер на каждом
|
||||
запросе — задержке, которую измерял срок сессии, теперь неоткуда взяться.
|
||||
**Адрес спрашивающего ограничитель берёт из `X-Forwarded-For` — и только тогда,
|
||||
когда соединение пришло с адреса из объявленного перечня доверенных.** Без этого
|
||||
счётчик ведётся по адресу пира, а пир с переездом входа на заголовок всегда один
|
||||
и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и
|
||||
восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка —
|
||||
верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
|
||||
ограничивает. Сама цепочка читается справа налево с отбрасыванием доверенных
|
||||
адресов, поэтому дописывающий прокси правилом покрыт; почему так —
|
||||
[security.md](security.md), «Периметр».
|
||||
|
||||
Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа
|
||||
провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ
|
||||
лежит файлом, а структура текстовой колонкой; жизнь приглашения завести владельца
|
||||
панели — панели нет. Прежде, вместе с собственным входом, ушли срок жизни сессии,
|
||||
потолок времени на вход у провайдера и таймаут обмена кода.
|
||||
|
||||
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
|
||||
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
|
||||
@@ -381,15 +443,12 @@ capability, и третий смысл развёл бы одно слово п
|
||||
остановка «застряла» наступает только после него. Мягкая остановка сюда не
|
||||
подпадает: она снимает захват сама.
|
||||
|
||||
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
||||
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
||||
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
|
||||
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
|
||||
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
|
||||
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
|
||||
нагрузке объявлена вне модели угроз.
|
||||
**Потолок размера назван числом там, где иначе действует умолчание** — у тела
|
||||
запроса приёма, и назван дважды: объявленная длина судится заранее, а
|
||||
необъявленная и солгавшая ловятся на чтении. Умолчания здесь не «без предела», а
|
||||
величины на два-три порядка меньше нужного. Таймаут чтения запроса снят: шесть
|
||||
часов записи по медленному каналу переживают любой фиксированный, а стойкость к
|
||||
целенаправленной нагрузке объявлена вне модели угроз.
|
||||
|
||||
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
|
||||
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
|
||||
файлов и объектов нет вовсе. Таймаутов у
|
||||
обращений к S3 и SpeechKit тоже нет — ни одного.
|
||||
Чего среди настроек **нет**: срока хранения файлов и объектов нет вовсе.
|
||||
Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного.
|
||||
|
||||
+11
-10
@@ -65,9 +65,9 @@ Telegram.
|
||||
записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
|
||||
имя, названное провайдером, — заводит строку при первом обращении под новым
|
||||
именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
|
||||
сервис не решает никогда. Одно исключение появилось 2026-08-11 вместе с
|
||||
решением про PocketBase: в панель администратора владелец входит своим
|
||||
паролем, потому что подпустить к ней внешнего провайдера PocketBase не даёт.
|
||||
сервис не решает никогда. Исключений у этого больше нет: панель администратора
|
||||
со своим паролем владельца жила здесь с 2026-08-11 по 2026-08-22 и ушла вместе
|
||||
со встроенным хранилищем — своего входа сервис не ведёт вовсе.
|
||||
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
|
||||
обрабатываем.
|
||||
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
|
||||
@@ -117,10 +117,11 @@ Telegram.
|
||||
которой пользуемся: она и задаёт потолок по длине записи и формату.
|
||||
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
|
||||
перестанет устраивать по цене или по качеству русской речи.
|
||||
**PocketBase** из референсов ушла: она больше не кандидат — в стек её перевела
|
||||
задача `pocketbase-storage` 2026-08-12
|
||||
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она
|
||||
держит хранилище, файлы и панель владельца. Схема и
|
||||
раскладка — [database.md](database.md). Учётные записи она хранит, а заводит их
|
||||
сервис по имени, названному Authelia; источником людей она при этом не
|
||||
становится: заводит и проверяет их по-прежнему Authelia.
|
||||
**PocketBase** побывала и референсом, и стеком, и ушла из проекта целиком.
|
||||
Референсом она быть перестала 2026-08-12, когда задача `pocketbase-storage`
|
||||
перевела её в стек; стеком — 2026-08-22, когда задача
|
||||
`storage-without-pocketbase`
|
||||
([adr](adr/ADR-2026-08-22-storage-without-pocketbase.md)) убрала её вместе с
|
||||
панелью владельца и собственным адресным пространством. Хранилище у сервиса своё:
|
||||
SQLite напрямую и файлы записей своим каталогом. Схема и раскладка —
|
||||
[database.md](database.md).
|
||||
|
||||
@@ -20,6 +20,11 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
|
||||
|
||||
## Записи
|
||||
|
||||
Две записи о PocketBase — [pocketbase.md](pocketbase.md) и
|
||||
[pocketbase-defaults.md](pocketbase-defaults.md) — описывают библиотеку, ушедшую
|
||||
из проекта 2026-08-22. Они остаются записями о прошлом, и строкой в каждой это
|
||||
сказано.
|
||||
|
||||
| Дата | Запись | О чём |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё |
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 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.md) отличаются предметом: та мерила, **что даёт
|
||||
панель**, эта — **что библиотека делает молча**, если её не переубедить.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 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`: что панель показывает и
|
||||
правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы
|
||||
держать перевод хранилища в планах.
|
||||
|
||||
+86
-18
@@ -72,11 +72,11 @@
|
||||
- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
|
||||
- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он
|
||||
**отказывает, а не висит**;
|
||||
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала — и
|
||||
журналов **два**: свой, в вывод контейнера, и журнал хранилища, куда
|
||||
библиотека кладёт путь целиком вместе с адресом отправителя. Второй молчит
|
||||
только на успехе и только потому, что признак отказа от записи поставлен
|
||||
руками: готовая раздача статики ставит его сама, своя — нет.
|
||||
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала.
|
||||
Журнал у сервиса с 2026-08-22 **один** — свой, в вывод контейнера: второй
|
||||
ушёл вместе со встроенным хранилищем, которое клало путь целиком вместе с
|
||||
адресом отправителя. Правило при этом расширилось, а не сузилось: путь не
|
||||
пишется дословно ни под каким корнем, включая корень приложения.
|
||||
|
||||
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
|
||||
|
||||
@@ -86,17 +86,20 @@
|
||||
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
|
||||
успех молча.
|
||||
|
||||
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы —
|
||||
**Репозиторий хранилища** (`internal/adapter/repo/sqlite`; шаги схемы —
|
||||
подпакетом `migrations`):
|
||||
|
||||
- список колонок совпадает в обоих местах — `applyOwnedByPipeline` вместе с
|
||||
`applyToRecord` и `recordToAudioRecord` — и в шаге схемы (инвариант
|
||||
[CLAUDE.md](../CLAUDE.md), «Инварианты»);
|
||||
- список колонок совпадает во всех трёх местах — `writeOwnedByPipeline` вместе с
|
||||
`writeRecord`, `readRecordColumns` и `rowToAudioRecord` — и в шаге схемы
|
||||
(инвариант [CLAUDE.md](../CLAUDE.md), «Инварианты»). Колонки называются
|
||||
**именами**: именованный параметр запроса и место назначения по имени, а не
|
||||
позиция в списке;
|
||||
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
|
||||
держатель захвата;
|
||||
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет
|
||||
свои `created`/`updated` ([database.md](database.md), «Представление данных»);
|
||||
- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком.
|
||||
держатель захвата, и держатель узнаётся значением признака;
|
||||
- репозиторий кладёт время тем же видом, каким его кладут остальные, и берёт его
|
||||
из единой точки ([database.md](database.md), «Представление данных»);
|
||||
- отказ хранилища не выходит наружу дословно: он несёт ключ файла и путь к нему
|
||||
целиком.
|
||||
|
||||
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
|
||||
`adapter/metaviewer/ffmpeg`):
|
||||
@@ -256,7 +259,7 @@
|
||||
|
||||
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
|
||||
колонку разом;
|
||||
- замена хранилища или переход на PocketBase — любой её кусок;
|
||||
- замена хранилища — любой её кусок;
|
||||
- смена модели очереди: захват, повторы и воркеры разом;
|
||||
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
|
||||
- изменение, убирающее или возвращающее вход приёма целиком.
|
||||
@@ -301,9 +304,10 @@ API и имя не откатываются обратной правкой по
|
||||
**весь** барьер: он обязан заголовки `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), и класс сузился до кук, которые ставит панель хранилища; браузера
|
||||
в прогоне нет, и находки этого рода остаются гипотезами.
|
||||
- `security`: поведение браузера с куками. Своих кук сервис не ставит с
|
||||
2026-08-22, а вместе со встроенным хранилищем ушли и те, что ставила его
|
||||
панель. Класс опустел, и строка стоит здесь затем, чтобы возврат кук читался
|
||||
как возврат недоступного проверке, а не как обычная работа.
|
||||
|
||||
**Перестали проверять сознательно:**
|
||||
|
||||
@@ -315,7 +319,8 @@ API и имя не откатываются обратной правкой по
|
||||
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
|
||||
можно: он встаёт своим единственным входом на выдуманных непустых ключах
|
||||
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
|
||||
осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче.
|
||||
осмотр HTTP, журнала, метрик и остановки — доступен любой задаче; панели среди
|
||||
предметов осмотра нет с 2026-08-22.
|
||||
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
|
||||
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
|
||||
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
|
||||
@@ -341,6 +346,69 @@ API и имя не откатываются обратной правкой по
|
||||
истории 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 для локального входа;
|
||||
|
||||
+103
-109
@@ -43,27 +43,22 @@ Telegram — связи чата с учётной записью сервис
|
||||
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
|
||||
уходит содержимое записи» и «Что вне модели».
|
||||
|
||||
**Третий сдвиг — панель администратора.** Решением от 2026-08-11
|
||||
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)) хранилищем
|
||||
становится PocketBase, и вместе с ним на том же порту появляется панель по
|
||||
адресу `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом.
|
||||
Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в
|
||||
панель через Authelia не пускает — у неё свой пароль суперпользователя.
|
||||
**Закрывает панель контур, а не приложение:** решением владельца от 2026-08-11
|
||||
адрес `/_/` закрывает Authelia на обратном прокси, пропуская только группу
|
||||
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
|
||||
она вне модели («Что вне модели», строка про контур).
|
||||
**Третьего сдвига — панели администратора — больше нет, и это снятие.** Решением
|
||||
от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту
|
||||
появлялась панель `/_/`: доступ ко всем записям, всем файлам и всем пользователям
|
||||
разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22,
|
||||
задачей `storage-without-pocketbase`, встроенное хранилище убрано целиком:
|
||||
панели не существует, второго периметра на порту сервиса не осталось, и правилу
|
||||
прокси нечего закрывать.
|
||||
|
||||
**И этот барьер обходится подменой одного знака.** Маршрутизатор сравнивает
|
||||
сегменты пути **после** раскодирования, поэтому `/%5f/` попадает в ту же группу,
|
||||
что и `/_/`, а правило прокси написано на литерал и такой формы не видит.
|
||||
Проверено прогоном 2026-08-15 ревью задачи `spa-skeleton`: обе формы отвечают
|
||||
байт в байт, и весь клиент панели грузится анониму. Вход в приложение при этом
|
||||
не обходится — `/%61pp/me` отвечает `401`. Дефект старше задачи, которая его
|
||||
нашла, и **сегодня не закрыт**: лечение — приведение пути к канонической форме на
|
||||
стороне сервиса, и глухая проверка тут не годится, потому что сломает скачивание
|
||||
файлов с пробелами и не-латиницей в имени. Половину пути проверить нечем: правило
|
||||
прокси живёт в `pet-project-server`, вне этого репозитория.
|
||||
**Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал
|
||||
сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и
|
||||
`/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь
|
||||
клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи
|
||||
`spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего:
|
||||
оба попадают под общее правило неизвестного пути и отдают разметку приложения.
|
||||
Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают
|
||||
байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса.
|
||||
|
||||
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
|
||||
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
|
||||
@@ -82,14 +77,17 @@ Telegram — связи чата с учётной записью сервис
|
||||
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
|
||||
человек.
|
||||
|
||||
**То же требование распространяется на `X-Forwarded-For`, и по другой причине.**
|
||||
С 2026-08-22 сервис называет этот заголовок хранилищу источником адреса
|
||||
спрашивающего — иначе счётчик ограничителя частоты ключуется адресом пира, а
|
||||
пир теперь всегда один, и бюджет становится общим на весь сервис. Прокси,
|
||||
дописывающий `X-Forwarded-For` к присланному вместо замены, отдаёт ключ счётчика
|
||||
самому спрашивающему: тот меняет значение и обходит ограничитель. Барьером
|
||||
узнавания этот заголовок при этом не служит — кто пришёл, решает адрес самого
|
||||
соединения.
|
||||
**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
|
||||
С 2026-08-22 адрес спрашивающего ограничитель частоты берёт из этого заголовка:
|
||||
иначе счётчик ведётся по адресу пира, а пир теперь всегда один — прокси, — и
|
||||
бюджет становится общим на весь сервис. Цепочка читается **справа налево**,
|
||||
доверенные адреса отбрасываются, и ключом становится первый недоверенный: левым
|
||||
значением распоряжается сам спрашивающий, а правое приписал ближайший к нам
|
||||
прокси. Заголовок читается всеми строками, а не одной: цепочка законно приходит
|
||||
несколькими. Прокси, дописывающий `X-Forwarded-For` к присланному, этим правилом
|
||||
покрыт, и требования «перезаписывать, а не дописывать» у сервиса к нему нет — в
|
||||
отличие от `Remote-*`. Барьером узнавания заголовок при этом не служит: кто
|
||||
пришёл, решает адрес самого соединения.
|
||||
|
||||
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
|
||||
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
|
||||
@@ -150,7 +148,7 @@ Telegram — связи чата с учётной записью сервис
|
||||
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
|
||||
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
|
||||
2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
|
||||
и в панели владельца.
|
||||
приложения.
|
||||
|
||||
Целевой периметр добавляет три пути, каждый — своей задачей:
|
||||
|
||||
@@ -170,44 +168,40 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
|
||||
путь не предполагался.
|
||||
|
||||
- **Путь на диске** выбирает хранилище:
|
||||
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис** —
|
||||
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
|
||||
отправителя, не применяется: имя отправителя в хранилище не попадает.
|
||||
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
|
||||
- **Путь на диске** выбирает сервис: `data/records/<ULID записи>/<имя>`. Обе
|
||||
части задаёт он сам — подкаталог назван идентификатором записи, имя файла это
|
||||
`<ULID><расширение>`, — и имя, данное отправителем, не попадает ни в одну из
|
||||
них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки
|
||||
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
|
||||
каталогов, но это единственное, что стоит между входом и именем файла.
|
||||
каталогов, но это единственное, что стоит между входом и именем файла. Длина
|
||||
расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками
|
||||
роняет заведение временного файла.
|
||||
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
||||
расширением. Бакет один на все записи, префикса по пользователю нет. С
|
||||
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
|
||||
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
|
||||
распознавания.
|
||||
- **Вторая раскладка файла на диске** появилась 2026-08-14 вместе с сохранённым
|
||||
ответом провайдера: `data/storage/<recognitions>/<попытка>/<имя>.payload`. Имя
|
||||
задаёт сервис, как и у аудио. Содержимое там — **полный текст речи**, а не
|
||||
метаданные, поэтому поле помечено защищённым, правило просмотра коллекции
|
||||
оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и
|
||||
ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с
|
||||
чужим и со своим токеном файла ссылка отвечает «не найдено».
|
||||
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
|
||||
можно только с коротким токеном файла, который выдаётся по сессии, и запрос
|
||||
без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет —
|
||||
токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет
|
||||
остаётся: **имя файла в хранилище в журнал не пишется**
|
||||
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
|
||||
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
|
||||
- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное,
|
||||
что защищает карточку записи и её текст.
|
||||
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
|
||||
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
|
||||
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
|
||||
есть доступны они только владельцу панели, — и коллекции, заведённые
|
||||
2026-08-14, тоже: содержимое записи отдаёт собственный адрес сервиса, а не
|
||||
поверхность хранилища. Коды, снятые прогоном, —
|
||||
[database.md](database.md), «Коллекции», норма —
|
||||
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
|
||||
то, что заказано».
|
||||
- **Сохранённый ответ провайдера** лежит третьим файлом в том же подкаталоге
|
||||
записи, под именем, которое задаёт сервис. Содержимое там — **полный текст
|
||||
речи**, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса,
|
||||
которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в
|
||||
журнал, ни в метку метрики, ни в ответ.
|
||||
- **Адрес файла** — `GET /app/audiorecords/{id}/file?copy=original|normalized`.
|
||||
Право пройти по нему даёт **узнавание пришедшего и владение записью**, и
|
||||
судится оно там же, где отдаётся файл. Значений на предъявителя сервис не
|
||||
выдаёт вовсе: короткий токен файла ушёл 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).
|
||||
|
||||
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
|
||||
|
||||
@@ -227,33 +221,34 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
|
||||
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
|
||||
отзыв доступа у Authelia действует со следующего обращения.
|
||||
Предъявленный собственный токен хранилища побеждает заголовок: им работает
|
||||
владелец панели, и подмена его учётной записью пользователя отобрала бы у него
|
||||
панель. Протухший и негодный токен предъявленными не считаются.
|
||||
**Область узнавания сужена** до корня приложения и адреса выдачи файлового
|
||||
токена: собственная поверхность хранилища под неё не подпадает, иначе узнанный
|
||||
переписал бы себе ключ учётной записи на чужое имя.
|
||||
Собственных токенов сервис не принимает вовсе: значения, предъявленного
|
||||
запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое
|
||||
значение било заголовок — им работал владелец панели; панели нет, и правило
|
||||
приоритета осталось бы правилом без предмета.
|
||||
**Область узнавания — корень приложения**, и выводится она из объявленного
|
||||
адресного пространства сервиса: слои одеты на корень целиком, вторым списком
|
||||
адресов область не описывается. Проба здоровья, метрики и ресурсы приложения
|
||||
под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к
|
||||
базе, а первый такой запрос с новым именем — записи в неё.
|
||||
- **Учётная запись** — заводится первым обращением с новым логином и находится
|
||||
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
|
||||
снаружи нельзя, все пять правил доступа коллекции пользователей закрыты шагом
|
||||
схемы `202608220001`.
|
||||
- **Файл записи** — короткий токен файла, который берёт узнанный. Поле файла
|
||||
помечено защищённым, правило просмотра коллекции пускает только владельца
|
||||
файла, и ссылка `/api/files/...` перестала быть правом пройти по ней. Одного
|
||||
заголовка мало: порядок здесь «узнавание → токен файла → ссылка». **Это
|
||||
единственное значение, переживающее запрос**, и на его срок отзыв доступа до
|
||||
файловой ссылки не доходит.
|
||||
снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе:
|
||||
своих экранов профиля он не заводит, а поверхности хранилища, правившей запись
|
||||
библиотечным правилом, не осталось.
|
||||
- **Файл записи** — узнавание пришедшего и владение записью, судимые в самом
|
||||
обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места,
|
||||
где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос,
|
||||
сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла.
|
||||
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
|
||||
приложение не делает: кого пускать, определяет правило провайдера на этого
|
||||
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
|
||||
его не проверить. Клиент, настроенный слишком широко, открывает сервис
|
||||
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
|
||||
2026-08-12.
|
||||
- **Собственный вход хранилища закрыт целиком.** Создание записи, вход по
|
||||
паролю, одноразовый код, обмен кода у внешнего провайдера, восстановление
|
||||
доступа и продление — ни один не даёт доступа и не меняет учётной записи:
|
||||
хранилище заводит коллекцию пользователей открытой, и без этого закрытия
|
||||
узнавание обходилось бы двумя запросами.
|
||||
- **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю,
|
||||
одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и
|
||||
продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать
|
||||
больше нечего, и адресов этих не существует.
|
||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному:
|
||||
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
|
||||
учётной записи на них не заводит. Наружу их закрывает правило обратного
|
||||
@@ -284,23 +279,27 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
Откуда он берётся — из группы OIDC или из конфигурации — не решено
|
||||
(`admin-stats-screen`).
|
||||
|
||||
**Панель администратора в эту таблицу не входит и разграничению не подчиняется.**
|
||||
Суперпользователь PocketBase видит все записи, все файлы и всех пользователей
|
||||
мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia.
|
||||
Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя:
|
||||
обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение
|
||||
по списку адресов (`superuserIPs`), и оно же запирает владельца, если список
|
||||
задан неверно: сброса в наборе команд нет.
|
||||
**Панели администратора в этой таблице нет, и это снятие, а не пропуск.** До
|
||||
2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и
|
||||
всех пользователей мимо любого из механизмов разграничения, а пускал его свой
|
||||
пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у
|
||||
сервиса осталось одно — владение записью.
|
||||
|
||||
Владелец сервиса взамен получил одно действие и один инструмент: подкоманда
|
||||
`cmd/devtools resume` возвращает остановленную запись в работу. Она ходит **в тот
|
||||
же каталог данных**, то есть требует доступа к файлам сервера, а не к сети:
|
||||
поверхности, открытой в интернет, у неё нет вовсе.
|
||||
|
||||
## Что чувствительнее чего
|
||||
|
||||
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
|
||||
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
|
||||
колонкой, а шестью коллекциями: сама запись (заголовок и краткое описание),
|
||||
колонкой, а шестью таблицами: сама запись (заголовок и краткое описание),
|
||||
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
|
||||
`recognitions` (**сырой ответ провайдера вложением — полный текст речи**),
|
||||
`recognitions` (попытка распознавания; **сохранённый ответ провайдера —
|
||||
полный текст речи — лежит файлом в подкаталоге записи**),
|
||||
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
|
||||
тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается
|
||||
тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается
|
||||
наравне с записью — норму держит спека `storage`.
|
||||
2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||||
Утечка оплачивается деньгами и доступом к бакету.
|
||||
@@ -325,14 +324,10 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
|
||||
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
|
||||
открыта только владельцу.
|
||||
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех
|
||||
пользователей разом, то есть стоит вровень с самым чувствительным из списка
|
||||
выше. Второй секрет после токенов пользователей, который лежит **не в
|
||||
конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец
|
||||
по приглашению, которое сервис печатает в журнал при первом запуске. У
|
||||
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
|
||||
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
|
||||
читателю навсегда.
|
||||
Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с
|
||||
самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище,
|
||||
пропал, и ключа под него в конфигурации не заводится по той простой причине, что
|
||||
заводить нечего.
|
||||
|
||||
Тексты расшифровок в логи не пишутся — логируется длина текста и
|
||||
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
|
||||
@@ -355,7 +350,7 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
|
||||
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
|
||||
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
|
||||
никогда. Ряды, собранные до выкладки, перестают пополняться: панель, отобранная
|
||||
никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный
|
||||
по старому значению, покажет пустоту, и это не поломка.
|
||||
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
|
||||
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
|
||||
@@ -422,14 +417,13 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
|
||||
строки потребления текста не содержат.
|
||||
|
||||
**Руками запись сегодня не удаляется, и прежняя строка об этом была неверна.**
|
||||
Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных
|
||||
выше («Что чувствительнее чего»), связи приложений с записью обязательны и
|
||||
каскада не имеют, поэтому удаление самой
|
||||
строки записи отвергается хранилищем, а удаление её файлов проходит молча.
|
||||
Владелец, выполнивший прежнюю процедуру, стирает аудио и **оставляет полный
|
||||
текст речи** — расшифровку, разбивку по репликам и сырой ответ провайдера
|
||||
файлом на диске. Порядок, которым запись убирается на самом деле: сперва
|
||||
строки приложений — журнал событий, попытка распознавания вместе с её
|
||||
вложением, структура, тексты, — потом сама запись, потом её файлы. До
|
||||
**Руками запись сегодня убирается только запросом к базе, и порядок в нём
|
||||
несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее
|
||||
чего»), связи приложений с записью обязательны и каскада не имеют, поэтому
|
||||
удаление самой строки отвергается базой, пока живы приложения. Порядок такой:
|
||||
сперва строки приложений — журнал событий, попытка распознавания, структура,
|
||||
тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом
|
||||
убираются **одним движением**: подкаталог записи под её идентификатором. Тот,
|
||||
кто убрал только файлы, стирает аудио и **оставляет полный текст речи** —
|
||||
расшифровку, разбивку по репликам и сохранённый ответ провайдера. До
|
||||
`delete-record` это единственный способ, и он ручной целиком.
|
||||
|
||||
Reference in New Issue
Block a user