# План: 016 — глобально уникальные ID для всех сущностей и журнал событий ## Context Сейчас у сущностей числовые ID «по таблицам» (`INTEGER PRIMARY KEY`): у устройства №1, задачи №1 и бэкапа №1 одинаковый ID, а без `AUTOINCREMENT` SQLite выдаёт `max(id)+1`, то есть **ID удалённой последней записи повторно используется**. Журнала событий нет (есть только задачи), а метаданные бэкапа (`backups`) со списком файлов в бакете не связаны — страница берёт файлы прямо из S3. Данные сейчас: 6 устройств, 4 группы, 32 задачи, 23 бэкапа. Цель: у каждой сущности — группа, устройство, задача, бэкап, запись журнала — свой ID, который не пересекается с ID других сущностей и никогда не повторяется. Решения (согласованы): формат **префикс типа + UUIDv7**; **журнал событий в БД**; числовые ID **заменяются везде** (с миграцией данных). ## ID: как гарантируется уникальность `app/ids.py` (новый), формат `<префикс>_`: `dev_`, `grp_`, `job_`, `bkp_`, `evt_`; например `dev_0192f3a1-7c4e-7b1d-9a3f-5e2c1d0b8a47`. - **Между типами** — префикс: ID разных типов не пересекаются по построению, а не «по вероятности». - **Внутри типа** — UUIDv7 (RFC 9562): время в мс + 12-битный счётчик + 62 случайных бита (в исходном плане было ошибочно указано 74 случайных бита); собственный генератор (в Python 3.12 `uuid.uuid7` нет), монотонный внутри процесса (счётчик в пределах миллисекунды, при откате часов время не уменьшается). Сортировка по ID = сортировка по времени создания. - **Не переиспользуются**: ID случайный/временной, удаление записи ничего не «освобождает». - **БД**: `PRIMARY KEY` в каждой таблице — дубликат не сохранится, а вызовет явную ошибку, а не тихую перезапись. - **Проверка входа**: `parse_id(value, prefix)` — ID чужого типа в пути API/UI (например `job_…` в `/devices/…`) отклоняется (404 с пояснением). ## Схема и связи (`app/models.py`) Все PK — `String(40)`, генерируются на стороне приложения (`default=lambda: new_id("dev")`), все ссылки — строки с тем же форматом. - `device_groups.id` = `grp_…`; `devices.id` = `dev_…`, `devices.group_id` → `grp_…`. - `jobs.id` = `job_…`, `jobs.device_id` → `dev_…` (без FK, как сейчас: история переживает удаление устройства; имя хранится в `device_name`). - `backups.id` = `bkp_…` — **один бэкап = пара файлов** (`.backup` + `.rsc`); новые поля: `job_id` (задача, создавшая бэкап), `device_name` (снимок имени для истории), `deleted_at`. Удаление файлов из бакета помечает бэкап удалённым, строка и ID остаются. - **Связь с бакетом**: при загрузке файлы получают S3 user-metadata `backup-id`, `device-id` (`s3.upload_file(..., metadata=…)`), ключи остаются прежними (`backups/<имя>/<метка>.<расширение>`). `services/backups.search` сопоставляет объекты с `backups` по ключу и отдаёт `backup_id` в API. - **`events` — новая таблица** (журнал): `id evt_…`, `ts` (UTC, индекс), `type`, `entity_type`, `entity_id` (индекс), `device_id`, `job_id`, `actor` (`ui:<пользователь>` / `api` / `poller` / `system`), `message`, `data` (JSON). ## Журнал событий (`app/services/events.py`, новый) `events.record(type, entity_type, entity_id, message, *, device_id=None, job_id=None, data=None)`; актор берётся из `ContextVar`, который выставляют зависимости `require_login` (UI), `require_api_token` (API) и опрос/задачи (`poller`, `system`; задачи наследуют контекст). Типы: `device.created|updated|deleted`, `device.online|offline` (**только смены состояния**, не каждый опрос), `group.created|renamed|deleted`, `devices.moved`, `job.created|started|done|failed`, `backup.created|done|failed|deleted`, `auth.login|failed`, `system.migrated`. API (только чтение): `GET /api/v1/events` (`entity_id`, `type`, `device_id`, `job_id`, `limit`, `before` — курсор по ID, т.к. ID сортируется по времени), `GET /api/v1/events/{id}`. UI-страницы журнала в этом изменении нет (требует макета — отдельная задача). ## Миграция существующих данных (`app/migrations.py`, новый; вызывается из `db.init_db`) Версионируется через `PRAGMA user_version` (0 → 1), идемпотентна. 1. Перед изменениями — копия файла БД (`sqlite3` online backup) → `ros_control.db.bak-<метка>` рядом с БД (в томе). 2. Для каждой таблицы строится соответствие «старый числовой ID → новый»; временная часть UUIDv7 берётся из `created_at`/`requested_at`, поэтому порядок записей сохраняется; таблицы пересоздаются (`*_new` → копирование с заменой ссылок `group_id`, `device_id` → `drop` → `rename`) одной транзакцией. 3. Запись события `system.migrated` (сколько строк по таблицам). 4. `backups.reconcile()` при старте (best-effort, без S3 не падает): файлы бакета без строки в `backups` (старые/загруженные вручную) получают строку `bkp_…` по паре `<имя>/<метка>`. У уже лежащих в бакете объектов S3-metadata не проставляется (переписывать объекты не будем) — связь через БД. ## Затрагиваемый код (паттерн повторяется) `int`-ID заменяется на `str` с проверкой префикса: `app/api/v1.py` (пути, `device_ids: list[str]`, `group_id`), `app/ui/routes.py` (`_opt_int` → `_opt_id(prefix)`, `isdigit` в фильтре `f_group` → `is_id("grp", …)`), `app/services/{devices,groups,jobs,ops,backups,poller}.py`, шаблоны (`d.id`, `g.id`, `j.id` подставляются в URL как есть). В таблице «Задачи» колонка «#» становится «ID» с коротким видом (последние 8 символов, моноширинным шрифтом; полный ID в подсказке) — это единственное видимое отличие от утверждённого макета; UUIDv7 в таблицах целиком не помещается. Запись событий добавляется в места изменений: `services/devices.py`, `groups.py`, `jobs.py` (`_finish`), `ops.py` (`run_backup`, `poll_device`/`_save_status` — смена online↔offline), `ui/routes.py` (логин), `backups.delete_many`. ## Проверка - **Тесты** (`pytest`, сейчас 12): существующие обновляются под строковые ID; новые (минимум): (1) `ids`: 100 тыс. ID по всем префиксам — без дублей, строго возрастают внутри типа, префиксы не пересекаются, `parse_id` отклоняет чужой тип; (2) миграция: временная БД в **старой схеме** → после миграции ID с префиксами, ссылки (`group_id`, `device_id`) сохранены, число строк совпадает, повторный запуск ничего не меняет; (3) события: создание устройства, жизненный цикл задачи, смена online→offline, вход/отказ записываются со ссылками на ID и актором. - **Репетиция на копии реальной БД**: копия файла из тома → миграция во временном каталоге → сверка числа строк и связей (устройство ↔ группа ↔ задачи ↔ бэкапы) до обращения к боевой БД. - **Боевая миграция**: пересборка контейнера; проверка `GET /api/v1/devices|groups|jobs|events` (все ID с префиксами, число записей прежнее, есть `.bak`); UI в браузере (Playwright): вкладки групп, окна устройства/групп, выбор строк, меню, фильтры, групповое удаление бэкапов (на собственных тестовых файлах), «Задачи» с коротким ID; опрос продолжает работать. - **S3-metadata**: загрузка тестового файла через `s3.upload_file` с метаданными в `backups/qa-id/…`, `head_object` подтверждает `backup-id`, файл удаляется. Полный бэкап на реальном устройстве (создаёт файлы, `.rsc` с секретами) — **только после вашего подтверждения**. - Документы: `docs/changes/016-unique-ids-and-event-log/{plan,summary}.md`, README (раздел «Идентификаторы», API событий, примеры с `dev_…`), история изменений. ## Риски и оговорки - **Ломающее изменение API/URL**: старые curl-скрипты с числовыми ID перестанут работать. Откат — из `.bak` файла БД и предыдущего коммита. - Пересоздание таблиц SQLite при работающем приложении: миграция выполняется на старте до запуска опроса; в БД одновременно пишет один процесс. - Уникальность между несколькими процессами обеспечена случайной частью (62 бита) и PK, а не общим счётчиком; монотонность порядка гарантируется в пределах процесса. - Журнал растёт: пишутся только смены состояния (не каждый опрос); срок хранения/очистка — отдельная доработка при необходимости.