fix(tradein/deploy): миграции ДО подъёма app-контейнеров + manifest-инвариант (#2216) (#2228)
All checks were successful
Deploy Trade-In / test (push) Successful in 1m35s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 49s
Deploy Trade-In / changes (push) Successful in 20s
Deploy Trade-In / build-browser (push) Successful in 29s
Deploy Trade-In / build-frontend (push) Successful in 30s
All checks were successful
Deploy Trade-In / test (push) Successful in 1m35s
Deploy Trade-In / build-backend (push) Successful in 29s
Deploy Trade-In / deploy (push) Successful in 49s
Deploy Trade-In / changes (push) Successful in 20s
Deploy Trade-In / build-browser (push) Successful in 29s
Deploy Trade-In / build-frontend (push) Successful in 30s
This commit is contained in:
parent
19ecd941f3
commit
0793571c4a
3 changed files with 359 additions and 22 deletions
|
|
@ -339,24 +339,44 @@ jobs:
|
|||
export IMAGE_TAG="$IMAGE_TAG"
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml pull
|
||||
|
||||
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
|
||||
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
|
||||
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres browser backend frontend
|
||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
||||
echo "→ scraper paths changed — recreating tradein-scraper"
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
|
||||
else
|
||||
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
|
||||
fi
|
||||
# ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ──────────
|
||||
# Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое
|
||||
# миграции новый код уже крутился на СТАРОЙ схеме (рассинхрон код↔схема).
|
||||
# Теперь строго: (1) только postgres → (2) ждём готовности БД →
|
||||
# (3) ВЕСЬ блок миграций → (4) app-контейнеры → (5) Caddy + health.
|
||||
# ИНВАРИАНТ ПРИ СБОЕ МИГРАЦИИ: строгий gate делает exit 1 ДО подъёма
|
||||
# нового кода → старые контейнеры продолжают работать на СТАРОМ коде +
|
||||
# СТАРОЙ схеме (консистентная пара). Это и есть цель: никогда «новый
|
||||
# код на старой схеме». Откат = просто ничего не поднимали.
|
||||
|
||||
# Применяем SQL миграции (если есть backend/data/sql/*.sql)
|
||||
# (1) Только БД — чтобы прогнать миграции до нового app-кода.
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps postgres
|
||||
|
||||
# (2) Ждём готовности postgres (pg_isready в цикле, НЕ тупой sleep).
|
||||
echo "→ Ожидание готовности postgres..."
|
||||
pg_ready=""
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
|
||||
pg_isready -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then
|
||||
pg_ready="yes"; break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$pg_ready" ]; then
|
||||
echo "ERROR: postgres не стал ready за отведённое время — прерываю деплой."
|
||||
echo " Новый app-код НЕ поднят; старые контейнеры не тронуты."
|
||||
exit 1
|
||||
fi
|
||||
echo "→ postgres ready."
|
||||
|
||||
# (3) Применяем SQL миграции (если есть backend/data/sql/*.sql) — ДО app.
|
||||
# Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом
|
||||
# старте volume. Здесь — для повторных миграций после первого запуска.
|
||||
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
|
||||
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
|
||||
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
|
||||
sleep 5
|
||||
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
|
||||
# #2216) glob'ом не подхватывается.
|
||||
|
||||
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
|
||||
# первый deploy после внедрения tracking на уже-наполненной prod-БД
|
||||
|
|
@ -430,16 +450,27 @@ jobs:
|
|||
echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
|
||||
fi
|
||||
|
||||
# Retry backend lifespan hook AFTER migrations applied.
|
||||
# tradein-backend startup runs ensure_fdw_user_mapping which needs
|
||||
# FOREIGN SERVER gendesign_remote (created by 060_postgres_fdw_extension.sql).
|
||||
# Without restart, the first compose-up's startup hook failed with
|
||||
# "server gendesign_remote does not exist" because migrations hadn't run yet.
|
||||
# See PR #493 deploy/1156 for the incident details.
|
||||
echo "→ Restarting tradein-backend so lifespan hook retries USER MAPPING setup"
|
||||
docker restart tradein-backend
|
||||
# Give backend time to come up before Caddy reload + health check below
|
||||
sleep 5
|
||||
# (4) Теперь — новый app-код: схема уже актуальна.
|
||||
# Селективный up: scraper НЕ пересоздаём на каждый backend-деплой (#1182) —
|
||||
# один image :latest на backend+scraper, блочный up -d рестартовал бы scraper
|
||||
# и убивал бегущий sweep. Scraper поднимается только при scraper/infra изменениях.
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps browser backend frontend
|
||||
if [ "${SCRAPER_CHANGED:-true}" = "true" ]; then
|
||||
echo "→ scraper paths changed — recreating tradein-scraper"
|
||||
docker compose -p gendesign-tradein -f docker-compose.prod.yml up -d --no-deps scraper
|
||||
else
|
||||
echo "→ scraper unchanged — tradein-scraper left running (подхватит новый image при следующем своём рестарте)"
|
||||
fi
|
||||
|
||||
# (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216).
|
||||
# История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД
|
||||
# миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с
|
||||
# "server gendesign_remote does not exist" — FOREIGN SERVER создаёт
|
||||
# 060_postgres_fdw_extension.sql, ещё не прогнанная на тот момент.
|
||||
# Требовался рестарт для повторной попытки хука. Теперь backend
|
||||
# стартует на шаге (4), т.е. ПОСЛЕ применения миграций (шаг 3) →
|
||||
# lifespan-hook гарантированно видит применённые миграции (FOREIGN
|
||||
# SERVER gendesign_remote существует) уже с первого старта. Рестарт удалён.
|
||||
|
||||
# Caddy reload — основной Caddyfile содержит inline tradein routes
|
||||
# (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS
|
||||
|
|
|
|||
168
tradein-mvp/backend/data/sql/_manifest_applied.txt
Normal file
168
tradein-mvp/backend/data/sql/_manifest_applied.txt
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
# _manifest_applied.txt — CONTRACT (issue #2216)
|
||||
#
|
||||
# Отсортированный список ВСЕХ имён миграций (bare filename) в data/sql/,
|
||||
# которые на момент коммита уже применены/забейслайнены на проде.
|
||||
# Прод трекает миграции по bare-filename в public._schema_migrations —
|
||||
# переименование или удаление применённого файла => повторный прогон на
|
||||
# проде (новый filename считается неприменённым) => дубль-эффекты/ошибки.
|
||||
#
|
||||
# ПРАВИЛА (enforced tests/test_migrations_manifest.py):
|
||||
# 1. Каждое имя здесь ОБЯЗАНО существовать в data/sql/ (нельзя rename/rm applied).
|
||||
# 2. Новый .sql-файл => НЕ переиспользуй NN-префикс (кроме 6 grandfathered дублей).
|
||||
# 3. Добавляя новую миграцию — допиши её имя сюда В ТОМ ЖЕ PR (список отсортирован).
|
||||
#
|
||||
# Комментарии (# ...) и пустые строки тест игнорирует.
|
||||
001_trade_in_estimates.sql
|
||||
002_core_tables.sql
|
||||
003_seed_deals.sql
|
||||
004_extend_trade_in_estimates.sql
|
||||
005_geocode_tracking.sql
|
||||
007_estimate_photos.sql
|
||||
008_crm_fields.sql
|
||||
009_houses.sql
|
||||
010_houses_alter.sql
|
||||
011_listings_alter.sql
|
||||
012_sellers.sql
|
||||
013_listings_alter_seller.sql
|
||||
014_house_reviews.sql
|
||||
015_scrape_runs.sql
|
||||
016_listings_snapshots.sql
|
||||
017_house_placement_history.sql
|
||||
018_avito_imv_evaluations.sql
|
||||
019_listings_alter_cian.sql
|
||||
020_houses_alter_cian.sql
|
||||
021_management_companies.sql
|
||||
022_agents_table.sql
|
||||
023_offer_price_history.sql
|
||||
024_houses_price_dynamics.sql
|
||||
025_house_reliability_checks.sql
|
||||
026_external_valuations.sql
|
||||
027_cian_session_cookies.sql
|
||||
028_matching_tables.sql
|
||||
029_extend_matching_valuation_dynamics.sql
|
||||
030_avito_imv_cache_key_unique.sql
|
||||
031_houses_alter_yandex.sql
|
||||
032_yandex_history.sql
|
||||
033_listings_alter_yandex.sql
|
||||
034_trade_in_estimates_geom.sql
|
||||
035_drop_duplicate_indexes.sql
|
||||
040_houses_extend.sql
|
||||
041_house_sources_noop.sql
|
||||
042_listing_sources_price_divergence_idx.sql
|
||||
043_house_reviews_extend.sql
|
||||
044_external_valuations_link.sql
|
||||
045_house_placement_history_extend.sql
|
||||
046_views.sql
|
||||
047_cian_history_sanitize.sql
|
||||
050_search_optimization.sql
|
||||
051_scrape_runs_extend.sql
|
||||
052_scrape_schedules.sql
|
||||
053_scraper_settings.sql
|
||||
054_scraper_settings_global.sql
|
||||
060_postgres_fdw_extension.sql
|
||||
061_drop_legacy_cad_buildings.sql
|
||||
062_clean_avito_addresses.sql
|
||||
063_backfill_houses_and_link_listings.sql
|
||||
064_house_imv_phase_c.sql
|
||||
065_trade_in_estimates_floor_optional.sql
|
||||
066_address_mismatch_audit.sql
|
||||
067_v_street_sales_vs_listings.sql
|
||||
068_drop_v_street_sales_vs_listings.sql
|
||||
069_trade_in_estimates_dadata_fields.sql
|
||||
070_houses_dadata_enrichment.sql
|
||||
071_houses_cian_zhk_url.sql
|
||||
072_scrape_schedules_seed_cian_rosreestr.sql
|
||||
073_normalize_repair_state.sql
|
||||
075_backfill_repair_state_from_description.sql
|
||||
076_account_estimate_quota.sql
|
||||
077_dedup_hash_plain_key_backfill.sql
|
||||
078_scrape_schedules_seed_yandex_sweep.sql
|
||||
079_listing_source_history.sql
|
||||
080_asking_to_sold_ratios.sql
|
||||
081_trade_in_estimates_expected_sold.sql
|
||||
082_scrape_schedules_seed_ratio_refresh.sql
|
||||
083_trade_in_estimates_created_by.sql
|
||||
084_brand_praktika_fill.sql
|
||||
084_scrape_schedules_seed_n1_sweep.sql
|
||||
085_quarter_price_index_fdw.sql
|
||||
086_deals_address_trgm_index.sql
|
||||
087_fdw_server_options.sql
|
||||
088_scrape_schedules_seed_search_matview_refresh.sql
|
||||
089_listings_geo_precision.sql
|
||||
090_scrape_schedules_seed_deactivate_stale_avito.sql
|
||||
091_scrape_schedules_seed_yandex_address_backfill.sql
|
||||
092_sber_price_index.sql
|
||||
093_scrape_schedules_seed_sber_index_pull.sql
|
||||
094_cadastral_unify.sql
|
||||
095_dead_schema.sql
|
||||
096_scrape_schedules_seed_rosreestr_quarter_poll.sql
|
||||
097_index_hygiene.sql
|
||||
098_asking_to_sold_ratios_tiered.sql
|
||||
099_brand_praktika_logo_wordmark.sql
|
||||
100_enable_deactivate_stale_avito.sql
|
||||
101_gendesign_reader_role.sql
|
||||
102_grant_listings_gendesign_reader.sql
|
||||
103_scrape_schedules_seed_newbuilding_enrich.sql
|
||||
104_index_hygiene_geom_dedup.sql
|
||||
105_market_schema_yandex_enrichment.sql
|
||||
106_scrape_schedules_seed_yandex_newbuilding_sweep.sql
|
||||
107_scrape_schedules_seed_cian_city_sweep.sql
|
||||
108_clean_avito_addresses_v2.sql
|
||||
108_merge_duplicate_houses.sql
|
||||
109_asking_to_sold_ratio_segment_filter.sql
|
||||
110_scrape_schedules_seed_geocode_missing_listings.sql
|
||||
111_listings_avito_detail_fields.sql
|
||||
112_scrape_schedules_seed_avito_detail_backfill.sql
|
||||
113_deactivate_ghost_duplicate_listings.sql
|
||||
113_yandex_detail_backfill.sql
|
||||
114_disable_n1_sweep.sql
|
||||
115_scrape_schedules_seed_deactivate_stale_yandex_cian.sql
|
||||
116_offer_price_history_change_trigger.sql
|
||||
117_listings_last_seen_deactivate_index.sql
|
||||
118_enable_cian_city_sweep.sql
|
||||
119_yandex_city_sweep_center_combos.sql
|
||||
120_restore_partial_active_indexes.sql
|
||||
121_remove_brand_praktika.sql
|
||||
121_yandex_rich_fields.sql
|
||||
122_enable_domclick_city_sweep.sql
|
||||
123_avito_newbuilding_sweep_schedule.sql
|
||||
124_cad_buildings_local.sql
|
||||
124_deglue_avito_addresses.sql
|
||||
125_scrape_schedules_seed_cadastral_geo_match.sql
|
||||
126_scrape_schedules_seed_cian_full_load.sql
|
||||
127_scrape_schedules_seed_avito_full_load.sql
|
||||
128_listings_card_hash.sql
|
||||
129_avito_full_load_incremental_split.sql
|
||||
130_backfill_listings_house_id_fk.sql
|
||||
130_ekb_geoportal_buildings.sql
|
||||
131_fix_diff_percent_overflow.sql
|
||||
132_scrape_schedules_seed_house_imv.sql
|
||||
133_listings_uq_source_source_id.sql
|
||||
134_listings_geom_geography_gist.sql
|
||||
135_scrape_schedules_seed_house_dedup_merge.sql
|
||||
136_backfill_listings_house_id_fk_source_identity.sql
|
||||
137_listings_addr_norm_trgm.sql
|
||||
138_domclick_bff_rewrite_schedule.sql
|
||||
139_premium_houses.sql
|
||||
140_yandex_house_type_backfill.sql
|
||||
141_cian_promote_house_type.sql
|
||||
142_premium_buildings_curated.sql
|
||||
143_building_sale_share_schema.sql
|
||||
144_gar_canon_addr_match.sql
|
||||
145_building_sale_share_plausible_denom.sql
|
||||
146_sale_share_45d_and_zhkh_denom.sql
|
||||
147_canon_strip_geo_prefixes.sql
|
||||
148_dedup_apartments_in_sale_share.sql
|
||||
149_zhkh_priority_denominator.sql
|
||||
150_sale_share_listing_geo_filter.sql
|
||||
151_clean_bare_street_aliases.sql
|
||||
152_sale_share_floors_guard.sql
|
||||
153_sale_share_listings_floors_plausibility.sql
|
||||
154_market_contract_views.sql
|
||||
155_reader_grants_to_contract_views.sql
|
||||
156_revoke_raw_from_reader.sql
|
||||
157_scrape_proxies.sql
|
||||
158_seed_proxy_healthcheck_schedule.sql
|
||||
159_houses_fias_idx.sql
|
||||
160_seed_deactivate_stale_domklik_n1.sql
|
||||
161_backfill_scraped_at_active_recent.sql
|
||||
138
tradein-mvp/backend/tests/test_migrations_manifest.py
Normal file
138
tradein-mvp/backend/tests/test_migrations_manifest.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""Invariants over data/sql migrations (issue #2216).
|
||||
|
||||
Прод применяет миграции по BARE FILENAME: deploy-tradein.yml трекает каждый
|
||||
`data/sql/*.sql` в таблице `public._schema_migrations` (PRIMARY KEY = filename).
|
||||
Из этого следуют два хрупких инварианта, которые этот тест защищает от регрессии:
|
||||
|
||||
1. Переименование / удаление УЖЕ ПРИМЕНЁННОЙ миграции ломает прод: новый
|
||||
filename считается неприменённым и прогоняется повторно (дубль-INSERT,
|
||||
повторный DDL, PK violation под ON_ERROR_STOP => деплой падает или, хуже,
|
||||
молча дублирует данные). Manifest `data/sql/_manifest_applied.txt` — это
|
||||
слепок применённых имён на момент коммита; любой из них ОБЯЗАН существовать.
|
||||
|
||||
2. Два разных файла с одинаковым NN-префиксом ("дубль-префикс") — источник
|
||||
двусмысленного порядка применения (`ls | sort` детерминирован, но человек
|
||||
легко создаёт коллизию). 6 исторических дублей grandfathered'ы (оба в
|
||||
manifest). Любой НОВЫЙ файл обязан нести уникальный префикс.
|
||||
|
||||
Self-maintenance: добавляя новую миграцию, допиши её имя в manifest В ТОМ ЖЕ PR
|
||||
(см. assert-сообщения ниже). Тест требует data/sql ⊇ manifest и уникальность
|
||||
префикса у новых файлов; сам manifest дополняет автор миграции.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
_SQL_DIR = _BACKEND_ROOT / "data" / "sql"
|
||||
_MANIFEST = _SQL_DIR / "_manifest_applied.txt"
|
||||
|
||||
_NN_PREFIX = re.compile(r"^(\d+)_")
|
||||
|
||||
|
||||
def _read_manifest() -> list[str]:
|
||||
"""Имена миграций из manifest; # comments и пустые строки игнорируются."""
|
||||
names: list[str] = []
|
||||
for raw in _MANIFEST.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
names.append(line)
|
||||
return names
|
||||
|
||||
|
||||
def _actual_sql_files() -> set[str]:
|
||||
return {p.name for p in _SQL_DIR.glob("*.sql")}
|
||||
|
||||
|
||||
def _prefix(name: str) -> str | None:
|
||||
m = _NN_PREFIX.match(name)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def test_manifest_entries_all_exist() -> None:
|
||||
"""Каждый файл из manifest СУЩЕСТВУЕТ в data/sql/.
|
||||
|
||||
Red => применённая миграция переименована или удалена. Прод трекает по
|
||||
bare-filename: старое имя остаётся в _schema_migrations, НОВОЕ имя считается
|
||||
неприменённым и прогоняется повторно. Восстанови исходное имя файла (или,
|
||||
если переименование намеренное — так делать НЕЛЬЗЯ для уже-применённых
|
||||
миграций: заведи НОВЫЙ файл, а старый оставь как есть).
|
||||
"""
|
||||
actual = _actual_sql_files()
|
||||
manifest = _read_manifest()
|
||||
missing = sorted(n for n in manifest if n not in actual)
|
||||
assert not missing, (
|
||||
"Миграции из _manifest_applied.txt отсутствуют в data/sql/ "
|
||||
f"(переименованы/удалены?): {missing}. Эти имена уже применены на проде "
|
||||
"(tracking по bare-filename в _schema_migrations) — их нельзя "
|
||||
"переименовывать/удалять. Верни исходные имена файлов."
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_is_sorted_and_unique() -> None:
|
||||
"""Manifest отсортирован (codepoint) и без дублей — детерминированный слепок."""
|
||||
manifest = _read_manifest()
|
||||
assert manifest == sorted(manifest), (
|
||||
"_manifest_applied.txt не отсортирован. Пересортируй записи "
|
||||
"(LC_ALL=C sort / Python sorted())."
|
||||
)
|
||||
dupes = sorted({n for n in manifest if manifest.count(n) > 1})
|
||||
assert not dupes, f"Дублирующиеся строки в _manifest_applied.txt: {dupes}"
|
||||
|
||||
|
||||
def test_new_files_do_not_reuse_prefix() -> None:
|
||||
"""Новые (не в manifest) .sql-файлы НЕ переиспользуют существующий NN-префикс.
|
||||
|
||||
Grandfathered дубли (084/108/113/121/124/130) — оба файла в manifest, поэтому
|
||||
не флагаются: считаются "существующими", а не "новыми".
|
||||
|
||||
Red => новый файл взял префикс уже присутствующей миграции. Присвой
|
||||
следующий свободный NN и допиши имя в _manifest_applied.txt (тот же PR).
|
||||
"""
|
||||
actual = _actual_sql_files()
|
||||
manifest = set(_read_manifest())
|
||||
|
||||
# Префиксы, «занятые» уже-применёнными (manifest) миграциями.
|
||||
baseline_prefixes: set[str] = set()
|
||||
for name in manifest:
|
||||
p = _prefix(name)
|
||||
if p is not None:
|
||||
baseline_prefixes.add(p)
|
||||
|
||||
new_files = sorted(actual - manifest)
|
||||
collisions: list[str] = []
|
||||
# Внутри новых файлов префикс тоже обязан быть уникален (два новых с одним NN).
|
||||
seen_new_prefix: dict[str, str] = {}
|
||||
for name in new_files:
|
||||
p = _prefix(name)
|
||||
if p is None:
|
||||
continue
|
||||
if p in baseline_prefixes:
|
||||
collisions.append(f"{name} (префикс {p} занят применённой миграцией)")
|
||||
elif p in seen_new_prefix:
|
||||
collisions.append(f"{name} (префикс {p} уже у нового {seen_new_prefix[p]})")
|
||||
else:
|
||||
seen_new_prefix[p] = name
|
||||
|
||||
assert not collisions, (
|
||||
"Новые миграции переиспользуют NN-префикс: " + "; ".join(collisions) + ". "
|
||||
"Присвой следующий свободный номер и добавь имя файла в "
|
||||
"_manifest_applied.txt в ЭТОМ ЖЕ PR."
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_covers_all_but_new_files() -> None:
|
||||
"""data/sql ⊇ manifest, и каждый новый файл имеет уникальный префикс —
|
||||
напоминание о self-maintenance: manifest дополняется вместе с миграцией.
|
||||
|
||||
Этот тест НЕ требует, чтобы новый файл уже был в manifest (иначе PR с новой
|
||||
миграцией всегда красный). Он лишь гарантирует, что manifest не отстал от
|
||||
реальности В ЧАСТИ применённых имён (см. test_manifest_entries_all_exist) и
|
||||
что новые файлы не создают префикс-коллизий (см. предыдущий тест).
|
||||
"""
|
||||
# Sanity: manifest непустой и в data/sql есть файлы — защита от битых путей.
|
||||
assert _actual_sql_files(), f"Не найдено *.sql в {_SQL_DIR}"
|
||||
assert _read_manifest(), f"_manifest_applied.txt пуст: {_MANIFEST}"
|
||||
Loading…
Add table
Reference in a new issue