gendesign/docs/Schema_Migrations_vs_Alembic.md
Light1YT d38417d8af
All checks were successful
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 2m39s
Deploy / build-worker (push) Successful in 7m20s
Deploy / deploy (push) Successful in 2m48s
feat(admin): freshness endpoint (#73) + README Forgejo refresh (#83) + migrations doc (#72)
#73: GET /admin/scrape/freshness — last_success/age/freshness/status для 5 источников
(kn/objective/nspd/nspd_geo/cadastre) + overall_status (worst по critical). Prod-verified
имена таблиц, FILTER/COALESCE корректны, CAST(:d AS interval). +4 теста.
#83: README — GitHub→Forgejo CI/CD, секция 6 subagents, ссылки перемаплены.
#72: docs/Schema_Migrations_vs_Alembic.md — _schema_migrations паттерн (не Alembic),
рекомендация снести пустой alembic-скелет (отдельный PR).

Closes #73
Closes #83
Closes #72
2026-06-13 18:25:32 +00:00

176 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Миграции схемы БД: `_schema_migrations`, не Alembic
> Decision-doc по issue [#72](https://git.gendsgn.ru/lekss361/gendesign/issues/72)
> (B5-2). Дата: 2026-06-13. Статус: **ACCEPTED**.
>
> Снапшот — источник правды по решениям живёт в Obsidian vault
> (`decisions/`). Здесь — версионированная в репозитории копия рядом с кодом,
> на который она ссылается.
## TL;DR
- Схема прод-БД развивается через **идемпотентные `data/sql/NN_*.sql`**, которые
`deploy.yml` (Forgejo Actions) прогоняет автоматически и ровно один раз каждый,
отмечая применённые в таблице-трекере **`_schema_migrations`**.
- **Alembic в проде НЕ используется.** `backend/alembic/versions/` содержит только
`.gitkeep` — за всё время не создано ни одной ревизии. `alembic upgrade head`
на проде — no-op (нет ревизий), а `alembic revision --autogenerate` рассинхронизирован
с реальной схемой (модели в `app/models/` описывают лишь крошечную часть из ~50 таблиц).
- **Рекомендация (принята): удалить пустой Alembic-скелет** (`backend/alembic/`,
`backend/alembic.ini`, dep `alembic` в `pyproject.toml`) — он не несёт функции и
вводит в заблуждение («есть Alembic → значит миграции через него»). Если в будущем
понадобится ORM-driven migration tool — переустановить осознанно.
- Формулировка из тела issue про «синхронизацию состояния Alembic с прод»
(`alembic current` / `alembic stamp head` / catch-up migration) **неактуальна**:
синхронизировать нечего — Alembic никогда не был источником схемы.
## Как реально работают миграции
### Артефакт: `data/sql/NN_*.sql`
~118 файлов (на 2026-06-13), нумерованных `NN_описание.sql` (`01_…`, `144_…`).
Каждый — самодостаточный idempotent DDL/ETL-фрагмент:
```sql
-- 144_audit_log.sql
-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз NN=144).
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + guarded DO-блок для CHECK.
BEGIN;
CREATE TABLE IF NOT EXISTS audit_log ( ... );
COMMENT ON TABLE audit_log IS '...';
COMMIT;
```
Правила для нового файла:
1. **Имя** — следующий свободный номер + snake_case описание: `NNN_<что>.sql`.
Порядок применения = лексикографическая сортировка `ls -1 | sort`, поэтому
зануляй ширину при необходимости (`089_…` vs `89_…` сортируются по-разному —
придерживайся существующей ширины в каталоге).
2. **Идемпотентность**`CREATE … IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`,
`CREATE OR REPLACE VIEW`, `ON CONFLICT DO NOTHING`, guarded `DO $$ … $$`-блоки
для того, что не поддерживает `IF NOT EXISTS` (CHECK-констрейнты, ENUM-значения).
Файл может выполниться повторно вручную без вреда (хотя deploy и так пропустит
уже применённый).
3. **psycopg v3 / SQLAlchemy** — в коде приложения только `CAST(:x AS type)`,
никогда `:x::type` (см. `.claude/rules/backend.md`). В самих `.sql`-файлах,
которые гоняет `psql`, `::type`-каст допустим — это чистый psql, не через
SQLAlchemy bind-params.
4. **Транзакционность** — оборачивай связанные изменения в `BEGIN; … COMMIT;`.
`psql -v ON_ERROR_STOP=on`: при ошибке деплой падает (`exit 1`) и контейнеры
НЕ обновляются (схема и образы остаются консистентны).
### Трекер: таблица `_schema_migrations`
```sql
CREATE TABLE IF NOT EXISTS _schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
`filename` — basename файла (`144_audit_log.sql`). Применённость = наличие строки.
### Раннер: `.forgejo/workflows/deploy.yml`
После `git reset --hard origin/main` и `compose pull`, ДО `compose up -d`:
```bash
# 1) гарантировать трекер
psql … -c "CREATE TABLE IF NOT EXISTS _schema_migrations ( filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );"
# 2) применить каждый ещё-не-применённый файл в порядке сортировки
for sql_file in $(ls -1 data/sql/*.sql 2>/dev/null | sort); do
fname=$(basename "$sql_file")
applied=$(psql … -tAc "SELECT COUNT(*) FROM _schema_migrations WHERE filename='$fname'")
if [ "$applied" = "0" ]; then
echo "→ Applying migration: $fname"
psql … -v ON_ERROR_STOP=on < "$sql_file" \
|| { echo "FAILED on migration: $fname"; exit 1; }
psql … -c "INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;"
else
echo "✓ Already applied: $fname"
fi
done
```
Ключевые свойства:
- **Только `*.sql`** трекаются/применяются. `data/sql/*.py` и `*.sh` (ad-hoc
загрузчики, напр. `02_load_all_quarters.sh`, `40_scrape_history.py`) раннер
игнорирует — они запускаются вручную.
- **Идемпотентность на двух уровнях**: трекер пропускает уже применённое; сам SQL
безопасен к повторному прогону (на случай ручного запуска или восстановления).
- **Fail-fast**: первый упавший файл останавливает деплой, образы не катятся.
- **Триггер деплоя**: путь `data/sql/**` в `on.push.paths` `deploy.yml` (правка
только `docs/**` деплой НЕ триггерит — см. `.claude/rules/deploy.md`).
### Bootstrap (исторический, разовый)
`scripts/bootstrap_schema_migrations.sh` сидировал `_schema_migrations` всеми
файлами, уже применёнными на проде ДО введения auto-apply (PR #150) — чтобы первый
auto-deploy не пытался переиграть всю историю. Запускался один раз вручную через
SSH-туннель. Для новых файлов больше не нужен — deploy.yml сам ведёт трекер.
## Почему raw SQL, а не Alembic
| | `data/sql/NN_*.sql` (используется) | Alembic (скелет, не используется) |
|---|---|---|
| Что описывает | Реальную прод-схему (~50 таблиц, partitions, GIST, materialized views, PostGIS, FDW, роли, гранты) | Только `Base.metadata` из `app/models/` — крошечная часть (1 модуль `parcel`) |
| PostGIS / big views / partitions | Нативно — пишем DDL как есть | autogenerate их не видит/ломает (geoalchemy типы, `CREATE MATERIALIZED VIEW`, partitioning) |
| Идемпотентность | Встроена (`IF NOT EXISTS`, guarded `DO`) | Линейная цепочка ревизий, down/up |
| Источник истины | Файлы в репе + `_schema_migrations` | `alembic_version` (в проде таблицы нет) |
| Применение | `deploy.yml` авто, fail-fast | Не подключён к деплою |
Проект на старте — data-heavy: партиционированный `rosreestr_deals` (~7M строк),
PostGIS-геометрии, materialized views, cross-DB FDW в tradein, роли/гранты. Это
**deployment artifacts**, которые естественно описывать прямым DDL. ORM-модели
покрывают лишь endpoints приложения, а не аналитический слой. Alembic autogenerate
на такой схеме генерирует мусор (drop неизвестных ему объектов) — поэтому им и не
пользовались.
## Граница: что куда
- **`data/sql/NN_*.sql`** — ВСЁ изменение схемы прод-БД сейчас идёт сюда:
- новые таблицы / колонки / индексы / констрейнты;
- materialized views и обычные views (`v_*`, `mv_*`);
- partitions `rosreestr_deals`;
- роли, гранты, FDW-bootstrap;
- data-backfill, который должен прогоняться ровно один раз.
- Владелец правок: **`database-expert`** subagent (см. `.claude/rules/sql.md`).
- **Alembic** — НЕ используется. (Гипотетически: если бы в будущем понадобилась
чистая ORM-driven эволюция небольшого набора app-таблиц с автогенерацией — это
был бы кандидат. Сейчас такой потребности нет, и смешение двух раннеров на одной
схеме опаснее единого пути.)
## Рекомендация по пустому Alembic-скелету
**Удалить**, чтобы убрать ложный сигнал «миграции через Alembic»:
- `backend/alembic/` (env.py, script.py.mako, versions/.gitkeep)
- `backend/alembic.ini`
- dep `alembic>=1.13.0` из `backend/pyproject.toml` + пересборка лок-файла
(`cd backend && uv lock`)
- таргеты `make migrate` (`alembic upgrade head`) и `make migration NAME=…`
(`alembic revision --autogenerate`) в корневом `Makefile`оба зовут Alembic
и сейчас вводят в заблуждение (на проде upgrade — no-op, autogenerate
рассинхронизирован со схемой). Заменить на `make migrate-status` поверх
`_schema_migrations` либо удалить.
> Это правка `pyproject.toml` + удаление файлов под `backend/` — выполняется
> отдельным PR (`chore(backend): drop unused Alembic skeleton`), вне scope чисто
> документационного #72, чтобы не смешивать docs-only изменение с правкой
> зависимостей/Dockerfile-кэша. Здесь — зафиксированное **решение**; реализацию
> сноса делает backend-engineer отдельным PR после approve этого decision-doc.
Альтернатива (если решат оставить Alembic «на будущее»): держать скелет, но
добавить в `backend/alembic/README` явную пометку «NOT WIRED — схема через
`data/sql` + `_schema_migrations`, см. `docs/Schema_Migrations_vs_Alembic.md`»,
чтобы новый разработчик не пытался `alembic upgrade head`.
## Ссылки
- Раннер: `.forgejo/workflows/deploy.yml` (шаг "Apply pending SQL migrations")
- Bootstrap: `scripts/bootstrap_schema_migrations.sh`
- Конвенции SQL/деплоя: `.claude/rules/sql.md`, `.claude/rules/deploy.md`, `.claude/rules/backend.md`
- Скелет (к удалению): `backend/alembic/`, `backend/alembic.ini`