Канон документов, каталог задач и OpenSpec

docs/ по канону 12: паспорт с целью проекта, архитектура сегодняшнего
устройства, схема хранилища, модель угроз, конвенции кода, журнал ревью.
Конвенции перенесены из jellybit; места, где код им не следует, помечены
строкой «Расхождение» как объявленный долг.

tasks/ с роадмапом: две достигнутые цели, две запланированные (веб и
многопользовательский режим), два направления (все форматы, долгие
записи) и пять задач в беклоге.

openspec/config.yaml — маршрутизатор с адресами документов, спек пока нет.

CLAUDE.md переписан по форме канона: инварианты с severity, семантика
гейта, запреты с путями. Taskfile получил task gate.
This commit is contained in:
av
2026-08-10 21:19:07 +03:00
parent a4646c0930
commit 4d1c2bf44c
40 changed files with 3656 additions and 71 deletions
+101
View File
@@ -0,0 +1,101 @@
# Схема хранилища
СУБД, миграции, правило времени и идентификаторов.
СУБД — SQLite, драйвер `mattn/go-sqlite3` (нужен CGO). Запросы строит
`doug-martin/goqu` с диалектом `sqlite3`. Миграции — `pressly/goose`, каталог
`migrations/`, вшит в бинарник через `//go:embed migrations/*.sql` в `main.go` и
накатывается при старте. Новый файл достаточно положить в каталог.
**Идентификаторы** — UUID v4 строкой.
**Время** — локальная зона процесса, UTC не навязан. Колонки `created_at` и
`updated_at` проставляет приложение, а не СУБД; умолчание `CURRENT_TIMESTAMP`
стоит только у `files.created_at`.
Того, что единой точки генерации идентификатора и времени нет, здесь не
повторяем: перечень единых точек и их отсутствий держит
[architecture.md](architecture.md), «Единые точки проекта».
Переход на PocketBase запланирован, но не начат — здесь описано сегодняшнее
состояние. Открытые вопросы перехода — в
[architecture.md](architecture.md), раздел «Открытые вопросы».
## Таблицы
### `files`
Один файл на одну физическую копию: исходник, результат конвертации и копия в
Object Storage — три разные записи.
| Колонка | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | UUID файла |
| `storage` | TEXT | `local` или `s3` |
| `file_name` | TEXT | Имя в хранилище: UUID с расширением |
| `size` | INTEGER | Размер в байтах |
| `created_at` | DATETIME | Умолчание `CURRENT_TIMESTAMP` |
### `transcribe_jobs`
Задача расшифровки и она же очередь.
| Колонка | Тип | Что |
| --- | --- | --- |
| `id` | TEXT PK | UUID задачи |
| `state` | TEXT | `created`, `converted`, `transcribe`, `done`, `failed` |
| `source` | TEXT | `api`, `telegram`, `unknown`; умолчание `unknown` |
| `file_id` | TEXT FK → `files.id` | **Текущий** файл задачи: шаг конвейера переставляет ссылку на свой результат |
| `delay_time` | DATETIME | Не брать задачу раньше этого времени |
| `acquisition_id` | TEXT | Кто захватил задачу |
| `acquire_time` | DATETIME | Когда захватил; по нему считается протухание |
| `recognition_op_id` | TEXT | Идентификатор операции в Yandex Cloud |
| `transcription_text` | TEXT | Результат распознавания |
| `is_error` | BOOLEAN | Задача с `1` из выборки исключена навсегда |
| `error_text` | TEXT | Текст ошибки, машинный |
| `tg_chat_id` | INTEGER | Куда отправить результат |
| `tg_reply_message_id` | INTEGER | С каким сообщением связать |
| `created_at`, `updated_at` | DATETIME | Проставляет приложение |
Индексов, кроме первичных ключей, нет. Выборка воркера идёт полным перебором по
`state`, `is_error`, `delay_time` и `acquire_time`.
## Представление данных
Чем физически лежит запись и что происходит при чтении и записи.
- **Расшифровка лежит целиком в колонке `transcription_text`** одной строкой.
Запись длиной в час даёт десятки килобайт в одной ячейке; читается она
целиком при каждом `GetByID` и при каждом захвате задачи воркером.
- **Аудио в базе не лежит.** На диске — каталог `data/files`, плоский, имя файла
равно UUID с расширением. Ни файлы, ни объекты в Object Storage не удаляются
после завершения задачи: каталог и бакет растут неограниченно.
- **Захват задачи — два запроса подряд, не транзакция.** Сперва `UPDATE …
WHERE id = (SELECT … LIMIT 1)` проставляет `acquisition_id`, затем отдельный
`SELECT … WHERE acquisition_id = ?` читает строку. Репозиторий сверяет число
затронутых строк с ожидаемым, но между запросами задачу может перехватить
другой воркер с тем же значением — на одном процессе это не наблюдалось.
- **Список колонок задан не одним местом** — четырьмя запросами файла
`internal/adapter/repo/sqlite/transcript_job_repo.go`. Правило правки всех
четырёх и его severity — инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты».
## Настройки с числовым значением
| Настройка | Значение | Где |
| --- | --- | --- |
| Срок захвата, конвертация и распознавание | 1 час | `service/transcribe.go`, вызовы `findJob` |
| Срок захвата, проверка операции | 24 часа | там же |
| Пауза воркера между попытками | 1 секунда | `controller/worker/worker.go` |
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` |
| Задержка между проверками операции | 5 секунд | там же |
| Предел длины сообщения Telegram | 4000 символов | `adapter/telegram/sender.go` |
| Память под multipart-загрузку | 32 МиБ | `main.go`, `router.MaxMultipartMemory` |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` |
| Таймаут обновлений Telegram | 10 секунд | конфиг, `[telegram] update_timeout` |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` |
Чего среди настроек **нет**: режим журналирования SQLite не задан (значение по
умолчанию, не WAL), таймаут занятости не задан, размер пула соединений не задан,
срока хранения файлов и объектов нет вовсе. Таймаутов у обращений к Telegram, S3 и
SpeechKit тоже нет — ни одного.