# Миграции схемы БД: `_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`
