4 changed files with 656 additions and 28 deletions
82
README.md
82
README.md
|
|
@ -8,9 +8,9 @@
|
|||
|
||||
---
|
||||
|
||||
## Текущий статус (май 2026)
|
||||
## Текущий статус (июнь 2026)
|
||||
|
||||
**Live:** [https://gendsgn.ru/](https://gendsgn.ru/) — HTTP/2+HTTP/3, валидный TLS, [/health](https://gendsgn.ru/health) `{"status":"ok"}`. Полный CI/CD: GitHub Actions → GHCR → SSH-deploy на Beget VPS. Sentry активен (release tracking привязан к git-sha).
|
||||
**Live:** [https://gendsgn.ru/](https://gendsgn.ru/) — HTTP/2+HTTP/3, валидный TLS, [/health](https://gendsgn.ru/health) `{"status":"ok"}`. Полный CI/CD: **Forgejo Actions** ([git.gendsgn.ru](https://git.gendsgn.ru)) → GHCR → SSH-deploy на Beget VPS (миграция с GitHub Actions 16 мая 2026). Sentry/GlitchTip активен (release tracking привязан к git-sha).
|
||||
|
||||
| Слой | Статус |
|
||||
|---|---|
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
| Concept page (`/concept`) — Stage 1 Generative | 🟡 skeleton |
|
||||
| Site Finder (`/site-finder`) — Stage 2 | 🔴 заглушка |
|
||||
|
||||
**Backlog:** [GitHub Issues](https://github.com/lekss361/-gendesign/issues) (трекаемые задачи с PR-связью) + [Obsidian vault](https://obsidian.gendsgn.ru) (планы, ADR, research — см. ниже про разделение).
|
||||
**Backlog:** [Forgejo Issues](https://git.gendsgn.ru/lekss361/gendesign/issues) (трекаемые задачи с PR-связью) + [Obsidian vault](https://obsidian.gendsgn.ru) (планы, ADR, research — см. ниже про разделение).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -65,8 +65,12 @@ docs/ стратегические снапшоты (акту
|
|||
memory/ legacy JSONL knowledge graph (deprecated, не обновляется — данные в Obsidian)
|
||||
docker-compose.prod.yml main стек (backend, frontend, postgres, redis, worker, beat, caddy)
|
||||
docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится отдельно
|
||||
.github/workflows/
|
||||
├── deploy.yml main → пересборка backend/frontend образов + SSH deploy
|
||||
.forgejo/workflows/ (Forgejo Actions — основной CI/CD после миграции 16.05.2026)
|
||||
├── ci.yml lint (ruff) + mypy + pytest на PR
|
||||
├── deploy.yml main → пересборка backend/frontend образов + auto-apply data/sql/*.sql + SSH deploy
|
||||
├── deploy-tradein.yml tradein-mvp стек (отдельный пайплайн)
|
||||
└── stale-claims.yml авто-снятие протухших claim-меток в bot-пайплайне
|
||||
.github/workflows/ (остаточные — только obsidian-стек на GitHub)
|
||||
└── deploy-obsidian.yml obsidian-стек (CouchDB compose changes + bootstrap)
|
||||
```
|
||||
|
||||
|
|
@ -104,12 +108,18 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится
|
|||
|
||||
**SSH-only по ключу к non-root пользователю `gendesign`**, fail2ban активен. UFW: 22/80/443.
|
||||
|
||||
**GitHub Actions deploys:**
|
||||
**Forgejo Actions deploys** (self-hosted `git.gendsgn.ru`, мигрировано с GitHub Actions 16.05.2026):
|
||||
|
||||
- [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`.
|
||||
- [`.github/workflows/deploy-obsidian.yml`](.github/workflows/deploy-obsidian.yml) — obsidian: триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты).
|
||||
- [`.forgejo/workflows/ci.yml`](.forgejo/workflows/ci.yml) — на PR: ruff lint + mypy (selective strict) + pytest. Блокирует merge при провале.
|
||||
- [`.forgejo/workflows/deploy.yml`](.forgejo/workflows/deploy.yml) — main: триггер на `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/**`. Build backend lean + worker-with-chromium + frontend → push в приватный GHCR → SSH `git reset --hard`, **auto-apply pending `data/sql/NN_*.sql` через `_schema_migrations`** (idempotent, см. ниже про миграции), sed `SENTRY_RELEASE=$IMAGE_TAG` в `backend/.env.runtime`, `compose pull && up -d`, `caddy reload`, `curl /health`.
|
||||
- [`.forgejo/workflows/deploy-tradein.yml`](.forgejo/workflows/deploy-tradein.yml) — tradein-mvp стек (отдельный пайплайн).
|
||||
- [`.github/workflows/deploy-obsidian.yml`](.github/workflows/deploy-obsidian.yml) — obsidian (**остался на GitHub**): триггер на `docker-compose.obsidian.yml`, `scripts/setup-couchdb.sh`, `docs/obsidian-livesync.md`. Без сборки образов (couchdb:3 с DockerHub), SSH `compose up -d` + idempotent bootstrap (CORS, DB, лимиты).
|
||||
|
||||
**GitHub Secrets:** `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_PORT`. Сервер авторизуется в GHCR однократно через PAT с `read:packages`. `COUCHDB_USER`/`COUCHDB_PASSWORD` — в `backend/.env.runtime` на VPS (не в репе).
|
||||
**Forgejo Secrets / Variables:** `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_PORT`. Сервер авторизуется в GHCR однократно через PAT с `read:packages`. `COUCHDB_USER`/`COUCHDB_PASSWORD` — в `backend/.env.runtime` на VPS (не в репе).
|
||||
|
||||
### Миграции БД — `_schema_migrations`, не Alembic
|
||||
|
||||
Схема развивается через **идемпотентные `data/sql/NN_*.sql`** (`CREATE … IF NOT EXISTS`, guarded `DO`-блоки). `deploy.yml` перед `compose up` прогоняет файлы в лексикографическом порядке, отмечая применённые в таблице-трекере `_schema_migrations` (применяется ровно один раз каждый). Каталог `backend/alembic/versions/` **пуст** — Alembic в проде не используется. Подробности и граница «raw SQL vs ORM» — [`docs/Schema_Migrations_vs_Alembic.md`](docs/Schema_Migrations_vs_Alembic.md).
|
||||
|
||||
**Триггеры миграции на Yandex Cloud:** 3-й платящий пилот / Раунд А / госзаказ (нужен реестр ПО) / БД >10 GB / >50 RPS. На YC ~10–15 тыс ₽/мес.
|
||||
|
||||
|
|
@ -128,14 +138,14 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится
|
|||
|---|---|---|---|
|
||||
| **0** ✅ | нед. 1 | Скелет монорепо, CI, прод | curl /health = ok |
|
||||
| **Sprint 1** ✅ | конец апр 2026 | NSPD-скрейпер + per-bucket эластичность + кадастр cross-check + Sentry release tracking | recommend_mix Tier 3 в проде |
|
||||
| **1a** | нед. 2–3 | Shapely-сервис, отступы, API-контракт `/concept` ([#5](https://github.com/lekss361/-gendesign/issues/5)) | GeoJSON → 3 заглушки |
|
||||
| **1b** | нед. 4–5 | Greedy-алгоритм, 3 стратегии, STRtree ([#12](https://github.com/lekss361/-gendesign/issues/12)) | 3 варианта ≤15с на 1 га |
|
||||
| **1c** | нед. 6–7 | ТЭП + финмодель + PDF/Excel/DXF ([#6](https://github.com/lekss361/-gendesign/issues/6)) | Продаваемый артефакт |
|
||||
| **2a** | нед. 8–9 | ПКК-парсер + sales_tracker ([#7](https://github.com/lekss361/-gendesign/issues/7)) | ≥1000 участков ЕКБ |
|
||||
| **2b** | нед. 10–11 | Скоринг + API + UI Site Finder ([#8](https://github.com/lekss361/-gendesign/issues/8)) | Карта с цветовой градацией |
|
||||
| **2c** | нед. 12 | Site Finder ↔ Generative + реестр ПО ([#9](https://github.com/lekss361/-gendesign/issues/9)) | 3 платящих пилота |
|
||||
| **1a** | нед. 2–3 | Shapely-сервис, отступы, API-контракт `/concept` ([#5](https://git.gendsgn.ru/lekss361/gendesign/issues/5)) | GeoJSON → 3 заглушки |
|
||||
| **1b** | нед. 4–5 | Greedy-алгоритм, 3 стратегии, STRtree ([#12](https://git.gendsgn.ru/lekss361/gendesign/issues/12)) | 3 варианта ≤15с на 1 га |
|
||||
| **1c** | нед. 6–7 | ТЭП + финмодель + PDF/Excel/DXF ([#6](https://git.gendsgn.ru/lekss361/gendesign/issues/6)) | Продаваемый артефакт |
|
||||
| **2a** | нед. 8–9 | ПКК-парсер + sales_tracker ([#7](https://git.gendsgn.ru/lekss361/gendesign/issues/7)) | ≥1000 участков ЕКБ |
|
||||
| **2b** | нед. 10–11 | Скоринг + API + UI Site Finder ([#8](https://git.gendsgn.ru/lekss361/gendesign/issues/8)) | Карта с цветовой градацией |
|
||||
| **2c** | нед. 12 | Site Finder ↔ Generative + реестр ПО ([#9](https://git.gendsgn.ru/lekss361/gendesign/issues/9)) | 3 платящих пилота |
|
||||
|
||||
Аналитический backlog: [#17](https://github.com/lekss361/-gendesign/issues/17) velocity-anomaly, [#18](https://github.com/lekss361/-gendesign/issues/18) ekb_districts median refresh, [#19](https://github.com/lekss361/-gendesign/issues/19) competitor-360, [#20](https://github.com/lekss361/-gendesign/issues/20) mortgage-rate sensitivity, [#21](https://github.com/lekss361/-gendesign/issues/21) cad_buildings filters в Site Finder.
|
||||
Аналитический backlog: [#17](https://git.gendsgn.ru/lekss361/gendesign/issues/17) velocity-anomaly, [#18](https://git.gendsgn.ru/lekss361/gendesign/issues/18) ekb_districts median refresh, [#19](https://git.gendsgn.ru/lekss361/gendesign/issues/19) competitor-360, [#20](https://git.gendsgn.ru/lekss361/gendesign/issues/20) mortgage-rate sensitivity, [#21](https://git.gendsgn.ru/lekss361/gendesign/issues/21) cad_buildings filters в Site Finder.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -164,28 +174,43 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится
|
|||
|
||||
**Legacy:** старый knowledge graph JSONL (`memory/memory-gendesign.jsonl`) — снапшот на момент 2026-05-11, не обновляется. Все новые факты пишутся в Obsidian.
|
||||
|
||||
### Где вести задачи — GitHub Issues vs Obsidian
|
||||
### Где вести задачи — Forgejo Issues vs Obsidian
|
||||
|
||||
Используем **оба**, но с чёткой ролью:
|
||||
Используем **оба**, но с чёткой ролью (трекер мигрирован с GitHub на self-hosted Forgejo 16.05.2026):
|
||||
|
||||
| | GitHub Issues | Obsidian vault |
|
||||
| | Forgejo Issues ([git.gendsgn.ru](https://git.gendsgn.ru/lekss361/gendesign/issues)) | Obsidian vault |
|
||||
|---|---|---|
|
||||
| Что сюда | Bug + Feature + Refactor — всё с **связью с кодом / PR** | Knowledge: research, ADR, planning, draft-ideas |
|
||||
| Lifecycle | Open → Assigned → PR → Merged → Closed | Append-only журнал решений и контекста |
|
||||
| Уведомления | GitHub email/mobile + integrations | Никаких (читаешь когда сам пришёл) |
|
||||
| Поиск | GitHub search + labels + milestones | BM25 + tags + Graph + MOC |
|
||||
| Уведомления | Forgejo email/webhook | Никаких (читаешь когда сам пришёл) |
|
||||
| Поиск | Forgejo search + labels + milestones | BM25 + tags + Graph + MOC |
|
||||
| Видимость | Команде в reviewer'ах | Всем у кого есть vault password |
|
||||
| Сроки | Milestones (12-week roadmap) | Frontmatter `date:`, `status:`, `due:` |
|
||||
|
||||
**Правило перехода:** Obsidian `fixes/Bug_X.md` со `status: OPEN` → когда требует PR — создаём **GitHub Issue**, ссылку прописываем во frontmatter `.md` (`github_issue: 42`). Bug-исследование живёт в vault'е, action item — в Issues.
|
||||
**Правило перехода:** Obsidian `fixes/Bug_X.md` со `status: OPEN` → когда требует PR — создаём **Forgejo Issue**, ссылку прописываем во frontmatter `.md` (`issue: 42`). Bug-исследование живёт в vault'е, action item — в Issues.
|
||||
|
||||
**Что куда:**
|
||||
|
||||
- `Bug_*` (vault) + GitHub Issue с тегом `bug` → обнаружен баг, фикс через PR
|
||||
- `decisions/*` (vault) — ADR, не нужен GitHub Issue
|
||||
- `Bug_*` (vault) + Forgejo Issue с тегом `bug` → обнаружен баг, фикс через PR
|
||||
- `decisions/*` (vault) — ADR, не нужен Issue
|
||||
- `research/*` (vault) — анализ источников, конкуренты, метрики
|
||||
- `sessions/*` (vault) — журналы рабочих сессий
|
||||
- GitHub Issues `feat/*` — фичи + PR на 1+ files, видимые в milestones
|
||||
- Forgejo Issues `feat/*` — фичи + PR на 1+ files, видимые в milestones
|
||||
|
||||
### Claude Code subagents (`.claude/agents/`)
|
||||
|
||||
Разработка ведётся через Claude Code с **6 кастомными subagent'ами** (path-scoped, со своими MCP-«учётками»: прод-PostgreSQL, Obsidian vault, Forgejo, GlitchTip). Полные правила — `.claude/rules/` + корневой [`CLAUDE.md`](CLAUDE.md).
|
||||
|
||||
| Agent | Scope / зона ответственности |
|
||||
|---|---|
|
||||
| `tech-analyst` | Нечёткая / cross-domain задача → план перед кодом |
|
||||
| `backend-engineer` | `backend/app/**` — FastAPI, Celery, scrapers, services |
|
||||
| `frontend-engineer` | `frontend/src/**` — Next.js 15, React 19, hooks |
|
||||
| `database-expert` | `data/sql/**.sql`, схема, EXPLAIN ANALYZE, прод-БД через MCP |
|
||||
| `devops-engineer` | `docker-compose*.yml`, `Caddyfile`, `.forgejo/workflows/**` |
|
||||
| `code-reviewer` | Pre-push lint: security, correctness, conventions |
|
||||
|
||||
**Workflow:** тривиально (typo, 1-line) → main session; single-domain → профильный worker; cross-domain → `tech-analyst` сначала. Worker → `code-reviewer` → коммит → push → PR в Forgejo. Branch + PR обязательны, никаких direct push в main.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -195,10 +220,11 @@ docker-compose.obsidian.yml obsidian-стек (CouchDB) — деплоится
|
|||
|---|---|
|
||||
| 🌐 Production | https://gendsgn.ru/ |
|
||||
| 🩺 Health | https://gendsgn.ru/health |
|
||||
| 📚 OpenAPI | https://gendsgn.ru/openapi.json · [Swagger UI](https://gendsgn.ru/docs) |
|
||||
| 📚 OpenAPI | [Swagger UI](https://gendsgn.ru/docs) · https://gendsgn.ru/openapi.json (за Caddy basic_auth) |
|
||||
| 🗂 Knowledge vault | https://obsidian.gendsgn.ru (требуется Obsidian + Self-hosted LiveSync, см. vault `meta/01_connect_devices.md`) |
|
||||
| Backlog | [Issues](https://github.com/lekss361/-gendesign/issues) · [Milestones](https://github.com/lekss361/-gendesign/milestones) |
|
||||
| Деплой | [GitHub Actions](https://github.com/lekss361/-gendesign/actions) · [GHCR пакеты](https://github.com/lekss361?tab=packages) |
|
||||
| 🧰 Git / CI | [git.gendsgn.ru](https://git.gendsgn.ru/lekss361/gendesign) (Forgejo) · [`CLAUDE.md`](CLAUDE.md) — правила разработки |
|
||||
| Backlog | [Issues](https://git.gendsgn.ru/lekss361/gendesign/issues) · [Milestones](https://git.gendsgn.ru/lekss361/gendesign/milestones) |
|
||||
| Деплой | [Forgejo Actions](https://git.gendsgn.ru/lekss361/gendesign/actions) · [GHCR пакеты](https://github.com/lekss361?tab=packages) |
|
||||
| Мониторинг | [Sentry](https://qqq-5y.sentry.io/insights/projects/gendesign-backend/) |
|
||||
| Инфра | Beget VPS [`cp.beget.com/cloud/...`](https://cp.beget.com/cloud/servers/jolly-yakov-3) · [DNS](https://cp.beget.com/dns/14193206) |
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ Returns the Celery task id; poll /api/v1/admin/scrape/runs/{run_id} to track DB-
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
|
@ -24,6 +25,12 @@ from app.core.db import get_db
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
"""Timezone-aware UTC now. timestamptz-колонки приходят aware, поэтому naive
|
||||
datetime.utcnow() дал бы TypeError при вычитании — всегда aware."""
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class TriggerKnRequest(BaseModel):
|
||||
region_code: int = Field(..., ge=1, le=99)
|
||||
developers: list[str] | None = Field(default=None, max_length=20)
|
||||
|
|
@ -1166,6 +1173,228 @@ def list_all_logs(
|
|||
]
|
||||
|
||||
|
||||
# ── Data-freshness monitoring (#73 B5-3) ─────────────────────────────────────
|
||||
#
|
||||
# GET /api/v1/admin/scrape/freshness — для каждого источника данных:
|
||||
# last_success_at / last_attempt_at / last_status / objects_updated_24h|7d /
|
||||
# age_days / freshness (fresh|stale|critical) / status (ok|stale|failed).
|
||||
#
|
||||
# Источник истины — run-таблицы (НЕ контент-таблицы): на проде проверено, что
|
||||
# идемпотентный re-scrape НЕ бампит complexes.updated_at (newest 2026-05-11 при
|
||||
# kn-run 3 дня назад), поэтому "objects_updated" считаем по счётчикам прогонов
|
||||
# (objects_count / quarters_ok / rows_lots / targets_done) за done-прогоны в окне.
|
||||
#
|
||||
# Пороги (fresh/stale/critical в днях) выведены из beat-расписания каждого job'а
|
||||
# (app/workers/beat_schedule.py). Sentry-алёрты по критичным job'ам шлёт отдельный
|
||||
# beat-task scrape_freshness_check (tasks.scrape_freshness_check) — endpoint только
|
||||
# читает, не алертит, чтобы UI-поллинг не спамил issue.
|
||||
|
||||
|
||||
class FreshnessSource(BaseModel):
|
||||
"""Описание одного источника данных в реестре freshness-мониторинга."""
|
||||
|
||||
source: str # стабильный ключ (kn / nspd / objective / nspd_geo / cadastre)
|
||||
label: str # человекочитаемое имя для UI
|
||||
table: str # run-таблица
|
||||
# Колонка-счётчик "сколько объектов обновил done-прогон". Агрегируется как
|
||||
# COALESCE(SUM(<work_col>) FILTER (...), 0) — FILTER применяется к SUM, не к COALESCE.
|
||||
work_col: str
|
||||
started_col: str = "started_at"
|
||||
# Колонка-fallback для last_attempt когда started_at NULL (queued-but-not-run).
|
||||
attempt_fallback_col: str | None = None
|
||||
fresh_days: float = 7.0 # < fresh_days → fresh
|
||||
stale_days: float = 30.0 # < stale_days → stale, иначе critical
|
||||
critical: bool = False # критичный источник (для Sentry-алёртов в beat-таске)
|
||||
|
||||
|
||||
# Реестр источников. Все 5 таблиц проверены на проде (information_schema) —
|
||||
# статусы done присутствуют у kn/nspd_geo/objective/cadastre; nspd только failed.
|
||||
# Пороги:
|
||||
# kn — beat ~ежедневно (scrape_kn cron) → fresh<2d, stale<5d
|
||||
# objective — beat ~еженедельно (objective_sync) + критичный → fresh<7d, stale<14d
|
||||
# nspd — harvest_stale_quarters (пн) → fresh<8d, stale<30d
|
||||
# nspd_geo — ручной bulk geo-job → fresh<7d, stale<30d
|
||||
# cadastre — ручной cadastre-job → fresh<14d, stale<45d
|
||||
_FRESHNESS_SOURCES: list[FreshnessSource] = [
|
||||
FreshnessSource(
|
||||
source="kn",
|
||||
label="DOM.РФ kn-API (ЖК)",
|
||||
table="kn_scrape_runs",
|
||||
work_col="objects_count",
|
||||
fresh_days=2.0,
|
||||
stale_days=5.0,
|
||||
critical=True,
|
||||
),
|
||||
FreshnessSource(
|
||||
source="objective",
|
||||
label="Объектив (лоты/цены)",
|
||||
table="objective_scrape_runs",
|
||||
work_col="rows_lots",
|
||||
fresh_days=7.0,
|
||||
stale_days=14.0,
|
||||
critical=True,
|
||||
),
|
||||
FreshnessSource(
|
||||
source="nspd",
|
||||
label="NSPD кварталы/здания (harvest)",
|
||||
table="nspd_scrape_runs",
|
||||
work_col="quarters_ok",
|
||||
fresh_days=8.0,
|
||||
stale_days=30.0,
|
||||
),
|
||||
FreshnessSource(
|
||||
source="nspd_geo",
|
||||
label="NSPD geo bulk-fetcher",
|
||||
table="nspd_geo_jobs",
|
||||
work_col="targets_done",
|
||||
attempt_fallback_col="created_at",
|
||||
fresh_days=7.0,
|
||||
stale_days=30.0,
|
||||
),
|
||||
FreshnessSource(
|
||||
source="cadastre",
|
||||
label="Кадастровые jobs",
|
||||
table="cadastre_jobs",
|
||||
work_col="targets_done",
|
||||
attempt_fallback_col="created_at",
|
||||
fresh_days=14.0,
|
||||
stale_days=45.0,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _classify_freshness(
|
||||
age_days: float | None, fresh_days: float, stale_days: float
|
||||
) -> str:
|
||||
"""fresh / stale / critical / unknown по возрасту последнего успешного прогона."""
|
||||
if age_days is None:
|
||||
return "unknown"
|
||||
if age_days < fresh_days:
|
||||
return "fresh"
|
||||
if age_days < stale_days:
|
||||
return "stale"
|
||||
return "critical"
|
||||
|
||||
|
||||
@router.get("/freshness")
|
||||
def scrape_freshness(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> dict[str, Any]:
|
||||
"""Свежесть данных по каждому источнику (для dashboard + alerting).
|
||||
|
||||
Для каждого источника один проход по его run-таблице агрегирует:
|
||||
- last_success_at — max(COALESCE(finished_at, started_at)) WHERE status='done'
|
||||
- last_attempt_at — max(started_at) (или created_at fallback) по всем прогонам
|
||||
- last_status — status последнего прогона
|
||||
- objects_updated_24h / _7d — сумма run-счётчика по done-прогонам в окне
|
||||
- age_days — возраст last_success в днях (NULL если успехов не было)
|
||||
- freshness — fresh / stale / critical / unknown (по порогам источника)
|
||||
- status — ok / stale / failed (для UI-светофора)
|
||||
|
||||
`overall_status` агрегирует худший статус по критичным источникам.
|
||||
"""
|
||||
sources_out: list[dict[str, Any]] = []
|
||||
worst_critical = "ok"
|
||||
severity = {"ok": 0, "stale": 1, "failed": 2}
|
||||
|
||||
for src in _FRESHNESS_SOURCES:
|
||||
started = src.started_col
|
||||
attempt_expr = (
|
||||
f"MAX(COALESCE({started}, {src.attempt_fallback_col}))"
|
||||
if src.attempt_fallback_col
|
||||
else f"MAX({started})"
|
||||
)
|
||||
# last_status — отдельным скаляром: status последнего по времени прогона.
|
||||
order_col = (
|
||||
f"COALESCE({started}, {src.attempt_fallback_col})"
|
||||
if src.attempt_fallback_col
|
||||
else started
|
||||
)
|
||||
# work_col / table / column-имена — из доверенного серверного реестра
|
||||
# (НЕ из пользовательского ввода), поэтому f-string в SQL здесь безопасен;
|
||||
# все временные границы передаются параметрами (:d1/:d7).
|
||||
# FILTER применяется к SUM(...), а COALESCE(...,0) оборачивает результат —
|
||||
# COALESCE(...) FILTER(...) — синтаксическая ошибка в PostgreSQL.
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
MAX(COALESCE(finished_at, {started}))
|
||||
FILTER (WHERE status = 'done') AS last_success_at,
|
||||
{attempt_expr} AS last_attempt_at,
|
||||
COALESCE(SUM({src.work_col}) FILTER (
|
||||
WHERE status = 'done'
|
||||
AND COALESCE(finished_at, {started})
|
||||
> NOW() - CAST(:d1 AS interval)
|
||||
), 0) AS upd_24h,
|
||||
COALESCE(SUM({src.work_col}) FILTER (
|
||||
WHERE status = 'done'
|
||||
AND COALESCE(finished_at, {started})
|
||||
> NOW() - CAST(:d7 AS interval)
|
||||
), 0) AS upd_7d,
|
||||
(
|
||||
SELECT status FROM {src.table}
|
||||
ORDER BY {order_col} DESC NULLS LAST
|
||||
LIMIT 1
|
||||
) AS last_status
|
||||
FROM {src.table}
|
||||
"""
|
||||
),
|
||||
{"d1": "24 hours", "d7": "7 days"},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
|
||||
last_success = row["last_success_at"] if row else None
|
||||
last_attempt = row["last_attempt_at"] if row else None
|
||||
last_status = row["last_status"] if row else None
|
||||
|
||||
age_days: float | None = None
|
||||
if last_success is not None:
|
||||
age_seconds = (_utcnow() - last_success).total_seconds()
|
||||
age_days = round(age_seconds / 86400.0, 2)
|
||||
|
||||
freshness = _classify_freshness(age_days, src.fresh_days, src.stale_days)
|
||||
|
||||
# status (UI-светофор): нет успехов вообще → failed; иначе мапим freshness.
|
||||
if last_success is None:
|
||||
status = "failed"
|
||||
elif freshness == "fresh":
|
||||
status = "ok"
|
||||
elif freshness == "stale":
|
||||
status = "stale"
|
||||
else: # critical
|
||||
status = "failed"
|
||||
|
||||
if src.critical and severity[status] > severity[worst_critical]:
|
||||
worst_critical = status
|
||||
|
||||
sources_out.append(
|
||||
{
|
||||
"source": src.source,
|
||||
"label": src.label,
|
||||
"critical": src.critical,
|
||||
"last_success_at": last_success.isoformat() if last_success else None,
|
||||
"last_attempt_at": last_attempt.isoformat() if last_attempt else None,
|
||||
"last_status": last_status,
|
||||
"objects_updated_24h": int(row["upd_24h"] or 0) if row else 0,
|
||||
"objects_updated_7d": int(row["upd_7d"] or 0) if row else 0,
|
||||
"age_days": age_days,
|
||||
"thresholds": {"fresh_days": src.fresh_days, "stale_days": src.stale_days},
|
||||
"freshness": freshness,
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"generated_at": _utcnow().isoformat(),
|
||||
"overall_status": worst_critical,
|
||||
"sources": sources_out,
|
||||
}
|
||||
|
||||
|
||||
# ── DomRF (kn) — runs list ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
197
backend/tests/api/v1/test_admin_scrape_freshness.py
Normal file
197
backend/tests/api/v1/test_admin_scrape_freshness.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""Тесты для GET /api/v1/admin/scrape/freshness (#73 B5-3).
|
||||
|
||||
Эндпоинт читает 5 run-таблиц (kn / objective / nspd / nspd_geo / cadastre) и
|
||||
для каждой считает свежесть. Здесь DB замокан: на каждый per-source SELECT
|
||||
возвращаем заранее заданную строку, чтобы проверить классификацию
|
||||
fresh/stale/critical, маппинг status (ok/stale/failed) и агрегат overall_status
|
||||
по критичным источникам.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import admin_scrape
|
||||
from app.core.db import get_db
|
||||
from app.main import app
|
||||
|
||||
ENDPOINT = "/api/v1/admin/scrape/freshness"
|
||||
|
||||
|
||||
def _ago(days: float) -> datetime:
|
||||
return datetime.now(UTC) - timedelta(days=days)
|
||||
|
||||
|
||||
def _make_db(per_source: dict[str, dict[str, Any]]) -> MagicMock:
|
||||
"""Db-mock: каждый execute()'s SELECT относится к одной run-таблице; матчим по
|
||||
имени таблицы в SQL и возвращаем соответствующую заранее заданную строку.
|
||||
|
||||
per_source: ключ — имя run-таблицы, значение — mapping-строка (как .first()).
|
||||
"""
|
||||
db = MagicMock()
|
||||
|
||||
def execute(stmt: Any, params: Any = None) -> Any:
|
||||
sql = str(stmt)
|
||||
matched: dict[str, Any] | None = None
|
||||
# Матчим по FROM <table> — самый длинный матч (nspd_geo_jobs vs nspd_*).
|
||||
for table in sorted(per_source, key=len, reverse=True):
|
||||
if f"FROM {table}" in sql:
|
||||
matched = per_source[table]
|
||||
break
|
||||
result = MagicMock()
|
||||
result.mappings.return_value.first.return_value = matched
|
||||
return result
|
||||
|
||||
db.execute = execute
|
||||
return db
|
||||
|
||||
|
||||
def _override_db(db: MagicMock) -> None:
|
||||
def _factory():
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = _factory
|
||||
|
||||
|
||||
def test_freshness_mixed_statuses() -> None:
|
||||
"""kn свежий (ok), objective stale, nspd без успехов (failed), nspd_geo critical,
|
||||
cadastre fresh. overall_status = failed (по критичному nspd_geo→failed)."""
|
||||
rows = {
|
||||
"kn_scrape_runs": {
|
||||
"last_success_at": _ago(1.0), # < fresh_days=2 → fresh → ok
|
||||
"last_attempt_at": _ago(1.0),
|
||||
"upd_24h": 1500,
|
||||
"upd_7d": 1500,
|
||||
"last_status": "done",
|
||||
},
|
||||
"objective_scrape_runs": {
|
||||
"last_success_at": _ago(10.0), # fresh<7, stale<14 → stale
|
||||
"last_attempt_at": _ago(10.0),
|
||||
"upd_24h": 0,
|
||||
"upd_7d": 325000,
|
||||
"last_status": "done",
|
||||
},
|
||||
"nspd_scrape_runs": {
|
||||
"last_success_at": None, # нет успехов → failed
|
||||
"last_attempt_at": _ago(20.0),
|
||||
"upd_24h": 0,
|
||||
"upd_7d": 0,
|
||||
"last_status": "failed",
|
||||
},
|
||||
"nspd_geo_jobs": {
|
||||
"last_success_at": _ago(40.0), # >= stale_days=30 → critical → failed
|
||||
"last_attempt_at": _ago(40.0),
|
||||
"upd_24h": 0,
|
||||
"upd_7d": 0,
|
||||
"last_status": "done",
|
||||
},
|
||||
"cadastre_jobs": {
|
||||
"last_success_at": _ago(3.0), # < fresh_days=14 → fresh → ok
|
||||
"last_attempt_at": _ago(3.0),
|
||||
"upd_24h": 5,
|
||||
"upd_7d": 5,
|
||||
"last_status": "done",
|
||||
},
|
||||
}
|
||||
_override_db(_make_db(rows))
|
||||
try:
|
||||
resp = TestClient(app).get(ENDPOINT)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert "generated_at" in body
|
||||
assert len(body["sources"]) == 5
|
||||
by_src = {s["source"]: s for s in body["sources"]}
|
||||
|
||||
assert by_src["kn"]["status"] == "ok"
|
||||
assert by_src["kn"]["freshness"] == "fresh"
|
||||
assert by_src["kn"]["objects_updated_7d"] == 1500
|
||||
assert by_src["kn"]["critical"] is True
|
||||
|
||||
assert by_src["objective"]["status"] == "stale"
|
||||
assert by_src["objective"]["freshness"] == "stale"
|
||||
|
||||
assert by_src["nspd"]["status"] == "failed"
|
||||
assert by_src["nspd"]["last_success_at"] is None
|
||||
assert by_src["nspd"]["age_days"] is None
|
||||
|
||||
assert by_src["nspd_geo"]["status"] == "failed"
|
||||
assert by_src["nspd_geo"]["freshness"] == "critical"
|
||||
|
||||
assert by_src["cadastre"]["status"] == "ok"
|
||||
|
||||
# overall_status — худший по критичным (kn=ok, objective=stale) → stale.
|
||||
# nspd_geo критичным НЕ помечен, поэтому его failed в overall не попадает.
|
||||
assert by_src["nspd_geo"]["critical"] is False
|
||||
assert body["overall_status"] == "stale"
|
||||
|
||||
|
||||
def test_freshness_all_healthy() -> None:
|
||||
"""Все источники свежие → overall_status=ok."""
|
||||
fresh_row = {
|
||||
"last_success_at": _ago(0.5),
|
||||
"last_attempt_at": _ago(0.5),
|
||||
"upd_24h": 10,
|
||||
"upd_7d": 100,
|
||||
"last_status": "done",
|
||||
}
|
||||
rows = {
|
||||
s.table: dict(fresh_row) for s in admin_scrape._FRESHNESS_SOURCES
|
||||
}
|
||||
_override_db(_make_db(rows))
|
||||
try:
|
||||
resp = TestClient(app).get(ENDPOINT)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["overall_status"] == "ok"
|
||||
assert all(s["status"] == "ok" for s in body["sources"])
|
||||
assert all(s["age_days"] is not None for s in body["sources"])
|
||||
|
||||
|
||||
def test_freshness_critical_source_failed_drives_overall() -> None:
|
||||
"""Критичный источник (objective) без успехов → overall_status=failed."""
|
||||
base = {
|
||||
"last_success_at": _ago(0.5),
|
||||
"last_attempt_at": _ago(0.5),
|
||||
"upd_24h": 1,
|
||||
"upd_7d": 1,
|
||||
"last_status": "done",
|
||||
}
|
||||
rows = {s.table: dict(base) for s in admin_scrape._FRESHNESS_SOURCES}
|
||||
# objective критичный — обнулим успех.
|
||||
rows["objective_scrape_runs"] = {
|
||||
"last_success_at": None,
|
||||
"last_attempt_at": _ago(5.0),
|
||||
"upd_24h": 0,
|
||||
"upd_7d": 0,
|
||||
"last_status": "running",
|
||||
}
|
||||
_override_db(_make_db(rows))
|
||||
try:
|
||||
resp = TestClient(app).get(ENDPOINT)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["overall_status"] == "failed"
|
||||
|
||||
|
||||
def test_classify_freshness_boundaries() -> None:
|
||||
"""Юнит на пороги классификатора: границы fresh/stale/critical, unknown."""
|
||||
f = admin_scrape._classify_freshness
|
||||
assert f(None, 2.0, 5.0) == "unknown"
|
||||
assert f(1.9, 2.0, 5.0) == "fresh"
|
||||
assert f(2.0, 2.0, 5.0) == "stale" # ровно fresh_days → уже stale
|
||||
assert f(4.9, 2.0, 5.0) == "stale"
|
||||
assert f(5.0, 2.0, 5.0) == "critical" # ровно stale_days → critical
|
||||
assert f(100.0, 2.0, 5.0) == "critical"
|
||||
176
docs/Schema_Migrations_vs_Alembic.md
Normal file
176
docs/Schema_Migrations_vs_Alembic.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# Миграции схемы БД: `_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`
|
||||
Loading…
Add table
Reference in a new issue