Compare commits
No commits in common. "a617ec79394740acee088627706fdedbeb6f8a2d" and "22a1b3aff4233a581791375a49e1ed84b7c6cf32" have entirely different histories.
a617ec7939
...
22a1b3aff4
8 changed files with 406 additions and 269 deletions
|
|
@ -41,21 +41,9 @@ In-app scheduler (`scrape_schedules`, tick 60s, `python -m app.scheduler_main`,
|
||||||
|
|
||||||
`tradein-mvp/backend/data/sql/NN_*.sql` применяется автоматически на деплое через `_schema_migrations`
|
`tradein-mvp/backend/data/sql/NN_*.sql` применяется автоматически на деплое через `_schema_migrations`
|
||||||
в `.forgejo/workflows/deploy-tradein.yml` (НЕ init-only, strict exit-1). Idempotency критична —
|
в `.forgejo/workflows/deploy-tradein.yml` (НЕ init-only, strict exit-1). Idempotency критична —
|
||||||
деструктивный DDL хитит прод на деплое.
|
деструктивный DDL хитит прод на деплое. NN-нумерация уже 3-значная и ИМЕЕТ коллизии (`108_*` ×2,
|
||||||
|
`084_*` ×2) → перед новым файлом `ls tradein-mvp/backend/data/sql | grep '^NN'` на дубль basename,
|
||||||
**Номер новой миграции сверяй с `origin/main`, не с локальным `ls`** — локальное дерево не видит
|
не доверяй `tail`.
|
||||||
миграций, смерженных после ветвления (так разъехались 212 в #2682 и 234 в #2754):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git fetch origin main
|
|
||||||
git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail
|
|
||||||
```
|
|
||||||
|
|
||||||
`-r` обязателен — без него `ls-tree` печатает сам каталог одной строкой, а не файлы.
|
|
||||||
|
|
||||||
Правило целиком — в докстринге `tradein-mvp/backend/tests/test_migration_numbering.py` (единственная
|
|
||||||
формулировка контракта, #2683); он же гейтит его в CI. Дописывать имя в какой-либо список НЕ надо:
|
|
||||||
`_manifest_applied.txt` удалён — он отставал и по построению не мог покраснеть.
|
|
||||||
|
|
||||||
## Rapid-merge trap
|
## Rapid-merge trap
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,21 +85,6 @@ jobs:
|
||||||
CI_PG: ci-pg-tradein-${{ github.run_id }}
|
CI_PG: ci-pg-tradein-${{ github.run_id }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
# ПОЛНАЯ история, а не дефолтный depth=1 (#2683).
|
|
||||||
# tests/test_migration_numbering.py сверяет номер новой миграции с
|
|
||||||
# origin/main и с точкой ветвления. Ровно этот флаг их и даёт: при
|
|
||||||
# depth=0 checkout идёт refspec'ом `+refs/heads/*:refs/remotes/origin/*`
|
|
||||||
# (видно в логе прогона), при depth=1 — только `+<sha>:refs/remotes/
|
|
||||||
# pull/N/head`, то есть ни ветки main, ни общего предка в клоне нет.
|
|
||||||
# Дотянуть main отдельным `git fetch` НЕЛЬЗЯ: из job-контейнера
|
|
||||||
# git.gendsgn.ru:443 недостижим (проверено, run 6977 — connection
|
|
||||||
# refused), сеть есть только у самого checkout.
|
|
||||||
#
|
|
||||||
# Гейт при отсутствии эталона краснеет, а не пропускается: молча
|
|
||||||
# пропущенная проверка и есть тот зелёный, который ничего не проверяет.
|
|
||||||
# Пак репозитория ~33 MiB — полный fetch дешевле разбора коллизии на проде.
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Поднять Postgres и собрать схему tradein
|
- name: Поднять Postgres и собрать схему tradein
|
||||||
working-directory: .
|
working-directory: .
|
||||||
|
|
|
||||||
|
|
@ -176,11 +176,6 @@ jobs:
|
||||||
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
|
DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
# Как в ci-tradein.yml: tests/test_migration_numbering.py (#2683) требует
|
|
||||||
# origin/main и общего предка с HEAD, а даёт их именно depth=0 — при
|
|
||||||
# depth=1 checkout тянет один sha и ветки main в клоне нет.
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
# Официальный standalone-инсталлер: системный `pip install uv` на
|
# Официальный standalone-инсталлер: системный `pip install uv` на
|
||||||
|
|
@ -684,9 +679,8 @@ jobs:
|
||||||
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
|
# Tracking через _schema_migrations (порт паттерна из deploy.yml):
|
||||||
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
|
# каждый .sql применяется РОВНО один раз, failed migration → exit 1
|
||||||
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
|
# (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp.
|
||||||
# ИМЕННО ЭТОТ цикл делает main эталоном применённого: всё, что доехало
|
# NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант
|
||||||
# до main, здесь и применяется, а имя закрепляется в _schema_migrations.
|
# #2216) glob'ом не подхватывается.
|
||||||
# На этом стоит гейт номеров — tests/test_migration_numbering.py (#2683).
|
|
||||||
|
|
||||||
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
|
# Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это
|
||||||
# первый deploy после внедрения tracking на уже-наполненной prod-БД
|
# первый deploy после внедрения tracking на уже-наполненной prod-БД
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql).
|
"""Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql).
|
||||||
|
|
||||||
Снимка «уже применённого» здесь нет и не нужно: у соседнего стека такой файл-список был
|
Прецедента manifest-теста для КОРНЕВОГО data/sql в этом репозитории нет (он есть только
|
||||||
(tradein data/sql/_manifest_applied.txt) и его удалили в #2683 — он отставал от каталога
|
в tradein: tradein-mvp/backend/tests/test_migrations_manifest.py по
|
||||||
и по построению не мог покраснеть. Аналог гейта для tradein теперь берёт эталон из git:
|
tradein-mvp/backend/data/sql/_manifest_applied.txt). Заводить manifest на 154 legacy-файла
|
||||||
tradein-mvp/backend/tests/test_migration_numbering.py. Здесь же проверяются инварианты,
|
корневого каталога — не задача этого PR, поэтому здесь проверяются инварианты, которые
|
||||||
выполнимые БЕЗ всякого эталона: они верны на новом каталоге с первого дня и ловят
|
можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого
|
||||||
регрессии, которые иначе всплывают только на проде во время деплоя.
|
дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя.
|
||||||
|
|
||||||
Тест не требует БД — только чтение файлов.
|
Тест не требует БД — только чтение файлов.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
257
tradein-mvp/backend/data/sql/_manifest_applied.txt
Normal file
257
tradein-mvp/backend/data/sql/_manifest_applied.txt
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
# _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
|
||||||
|
162_seed_deals_freshness_monitor.sql
|
||||||
|
163_disable_deactivate_stale_domklik.sql
|
||||||
|
164_yandex_url_canonicalize_active_dups.sql
|
||||||
|
165_remove_n1_source.sql
|
||||||
|
166_purge_listings_phones.sql
|
||||||
|
167_drop_client_pii.sql
|
||||||
|
168_fdw_osm_poi_ekb.sql
|
||||||
|
169_osm_poi_ekb_local.sql
|
||||||
|
170_scrape_schedules_seed_osm_poi_ekb_refresh.sql
|
||||||
|
171_scrape_schedules_seed_geoportal_coords_backfill.sql
|
||||||
|
172_trade_in_leads.sql
|
||||||
|
173_scrape_proxies_add_domclick_affinity.sql
|
||||||
|
174_domclick_session_cookies.sql
|
||||||
|
175_scrape_schedules_seed_domclick_detail_backfill.sql
|
||||||
|
176_domrf_kapremont.sql
|
||||||
|
177_deals_city_region.sql
|
||||||
|
178_deal_city_price_bands.sql
|
||||||
|
179_scrape_schedules_seed_oblast_city_sweeps.sql
|
||||||
|
180_seed_sber_freshness_monitor.sql
|
||||||
|
181_clamp_bad_listing_dates.sql
|
||||||
|
182_trade_in_leads_consent_proof.sql
|
||||||
|
183_reenable_deactivate_stale_domklik.sql
|
||||||
|
184_user_events.sql
|
||||||
|
185_account_quota_overrides.sql
|
||||||
|
186_tg_support.sql
|
||||||
|
187_web_support_chat.sql
|
||||||
|
188_tg_support_chat_id_scope.sql
|
||||||
|
189_account_estimate_usage_nonnegative.sql
|
||||||
|
190_sale_share_price_bucket_signature.sql
|
||||||
|
191_account_quota_unlimited_flag.sql
|
||||||
|
192_tradein_users_auth.sql
|
||||||
|
193_tradein_users_seed.sql
|
||||||
|
194_deal_city_price_bands_tiers.sql
|
||||||
|
195_scrape_schedules_seed_deal_city_price_bands_refresh.sql
|
||||||
|
196_listings_city.sql
|
||||||
|
197_backfill_listings_city_from_url.sql
|
||||||
|
198_scrape_proxy_rotations.sql
|
||||||
|
199_scrape_proxies_asocks_rotate_url.sql
|
||||||
|
200_region_code_foreign_cities.sql
|
||||||
|
201_purge_dead_mobileproxy_proxies.sql
|
||||||
|
202_listing_source_snapshot_budget_sec.sql
|
||||||
|
203_purge_geocode_cache_house_letter.sql
|
||||||
|
204_cian_oblast_sweeps_secondary.sql
|
||||||
|
205_sales_vs_listings_city_filter.sql
|
||||||
|
206_scrape_schedules_cut_wasteful_load.sql
|
||||||
|
207_backfill_yandex_cian_city_geo_cleanup.sql
|
||||||
|
208_reenable_domclick_detail_backfill.sql
|
||||||
|
209_scrape_proxies_disabled_reason.sql
|
||||||
|
210_scrape_proxy_source_bans.sql
|
||||||
|
211_sales_vs_listings_segment_guard.sql
|
||||||
|
212_sber_index_pull_weekly.sql
|
||||||
|
213_listings_snapshots_status_vocab.sql
|
||||||
|
214_drop_dead_run_metrics.sql
|
||||||
|
215_avito_full_load_window_matches_cadence.sql
|
||||||
|
216_dead_code_sweep.sql
|
||||||
|
#
|
||||||
|
# 2026-08-06: список догнан до факта прода. Проверка перед правкой —
|
||||||
|
# _schema_migrations на tradein-postgres: 209 применённых имён, здесь было
|
||||||
|
# 178; расхождение — 31 имя, все в одну сторону (применено, но не заморожено).
|
||||||
|
# Обратного расхождения нет: ни одной строки, которой не было бы на проде.
|
||||||
|
#
|
||||||
|
# Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат
|
||||||
|
# поддержки, #2532/#2533) откладывались до подтверждения, что они осели на
|
||||||
|
# проде в финальном виде. Они в _schema_migrations — условие выполнено.
|
||||||
|
#
|
||||||
|
# 217-232 сюда намеренно не дописаны этой миграцией (222/225): в момент
|
||||||
|
# правки они уже слиты в main и применены на проде (см. _schema_migrations),
|
||||||
|
# но их авторы не дописали имена в тот же PR — это чужой пробел, не наш;
|
||||||
|
# self-maintenance-контракт (см. докстринг test_migrations_manifest.py)
|
||||||
|
# требует дописывать только СВОЙ файл в СВОЁМ PR, что и сделано ниже для
|
||||||
|
# 222/225 по прецеденту 233_payments.sql.
|
||||||
|
222_db_audit_cleanup.sql
|
||||||
|
225_listing_source_snapshots_run_id_idx.sql
|
||||||
|
233_payments.sql
|
||||||
|
234_scrape_runs_ban_kind_unknown.sql
|
||||||
|
240_trade_in_estimates_retain_until.sql
|
||||||
|
250_drop_duplicate_expires_at_index.sql
|
||||||
|
251_listings_drop_ceiling_height.sql
|
||||||
|
254_listings_backfill_avito_rating_glued_address.sql
|
||||||
|
257_listings_backfill_yandex_source_url.sql
|
||||||
|
258_houses_imv_transient_attempts.sql
|
||||||
|
259_data_quality_drop_pct_cadastr.sql
|
||||||
|
260_houses_drop_has_panorama.sql
|
||||||
|
261_listings_search_mv_drop_placeholder_columns.sql
|
||||||
|
262_scrape_schedules_seed_oblast_city_sweeps_wave2.sql
|
||||||
|
263_scrape_schedules_wave2_cian_newbuilding_only_false.sql
|
||||||
|
264_deactivate_stale_avito_cap_mult.sql
|
||||||
|
265_deactivate_stale_yandex_cap_mult.sql
|
||||||
|
266_seed_deactivate_stale_null_segment_yandex_cian.sql
|
||||||
|
|
@ -60,16 +60,6 @@ tests/test_purge_expired_trade_in_data.py::test_real_purge_not_wedged_by_healthy
|
||||||
# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
|
# на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе.
|
||||||
tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint
|
tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint
|
||||||
|
|
||||||
# Гейт номеров миграций (#2683) сверяется с origin/main и точкой ветвления. Где
|
|
||||||
# git-эталона нет — прогон внутри prod-образа, экспорт исходников без .git —
|
|
||||||
# проверять не с чем, и тест это ГОВОРИТ вслух вместо тихого зелёного.
|
|
||||||
# В CI пропуска не бывает: при CI/GITHUB_ACTIONS та же ветка делает pytest.fail
|
|
||||||
# (отсутствие эталона в пайплайне — сломанный гейт, а не «нечего проверять»),
|
|
||||||
# а ci-tradein.yml/deploy-tradein.yml берут checkout с fetch-depth: 0 — при нём
|
|
||||||
# checkout сам приносит refs/remotes/origin/*, отдельный git fetch не нужен и
|
|
||||||
# из job-контейнера всё равно не проходит (run 6977, connection refused).
|
|
||||||
tests/test_migration_numbering.py::test_applied_migration_is_not_renamed_or_deleted
|
|
||||||
tests/test_migration_numbering.py::test_new_migration_takes_a_free_number
|
|
||||||
# Повтор застрявших transient_error (#2674, PR #2843) — тот же `_live_session()`.
|
# Повтор застрявших transient_error (#2674, PR #2843) — тот же `_live_session()`.
|
||||||
# Проверяют ВЫБОРКУ очереди на живой схеме (кто попал в пакет прогона), а не текст
|
# Проверяют ВЫБОРКУ очереди на живой схеме (кто попал в пакет прогона), а не текст
|
||||||
# SQL: на мок-лэйне deploy-tradein.yml БД нет вовсе. В ci-tradein.yml они бегут
|
# SQL: на мок-лэйне deploy-tradein.yml БД нет вовсе. В ci-tradein.yml они бегут
|
||||||
|
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
"""Инварианты нумерации миграций trade-in (issues #2216, #2683).
|
|
||||||
|
|
||||||
КОНТРАКТ. ОДНА ФОРМУЛИРОВКА, И ОНА ЗДЕСЬ — больше нигде её дублировать не надо.
|
|
||||||
|
|
||||||
Эталон «что уже закреплено на проде» — origin/main, а не файл-список.
|
|
||||||
deploy-tradein.yml прогоняет КАЖДЫЙ data/sql/*.sql из main под ON_ERROR_STOP
|
|
||||||
(падение миграции => красный деплой) и трекает применённое по bare filename в
|
|
||||||
public._schema_migrations. То есть «файл доехал до main» == «имя закреплено на
|
|
||||||
проде», и вести это знание отдельно от git незачем: git и есть журнал.
|
|
||||||
|
|
||||||
Отсюда ровно два инварианта, и ниже проверяются именно они.
|
|
||||||
|
|
||||||
1. Имя, существовавшее в точке ветвления, нельзя переименовать или удалить.
|
|
||||||
Прод помнит СТАРОЕ имя; новое считается неприменённым и прогоняется
|
|
||||||
повторно — дубль-INSERT / повторный DDL / PK violation под ON_ERROR_STOP,
|
|
||||||
то есть либо красный деплой, либо тихо задвоенные данные. Нужно изменить
|
|
||||||
уже применённую миграцию — заводи НОВЫЙ файл, старый оставь как есть.
|
|
||||||
|
|
||||||
2. НОВЫЙ файл обязан нести NN-префикс, свободный не только в рабочем дереве,
|
|
||||||
но и в origin/main. Порядок применения — `ls | sort`, два файла с одним NN
|
|
||||||
дают неопределённый порядок. Шесть исторических дублей (084/108/113/121/
|
|
||||||
124/130) не «новые» и не флагаются.
|
|
||||||
|
|
||||||
ДОПИСЫВАТЬ НИЧЕГО НЕ НАДО. Автор миграции кладёт файл со свободным номером — и
|
|
||||||
всё. Списка, который можно забыть обновить, здесь больше нет: до #2683 таким
|
|
||||||
списком был data/sql/_manifest_applied.txt, и он по построению не мог
|
|
||||||
покраснеть — «файл, которого нет в списке» и «новый файл этого PR» были для
|
|
||||||
теста одним и тем же, поэтому забытое имя навсегда оставалось зелёным
|
|
||||||
(замер 2026-08-07: 15 забытых имён, сьют зелёный).
|
|
||||||
|
|
||||||
ПОЧЕМУ ТОЧКА ВЕТВЛЕНИЯ, А НЕ САМ origin/main. Ветка, отведённая неделю назад,
|
|
||||||
не содержит миграций, смерженных после неё. Правило «origin/main ⊆ рабочее
|
|
||||||
дерево» красило бы каждую такую ветку без вины автора — и его отключили бы
|
|
||||||
через неделю. Сверка с merge-base ловит ровно то, что удалила или
|
|
||||||
переименовала ЭТА ветка, а номера при этом сверяются с ПОЛНЫМ origin/main,
|
|
||||||
чтобы коллизия с миграцией, смерженной после ветвления, всё-таки нашлась.
|
|
||||||
|
|
||||||
КАК УБЕДИТЬСЯ, ЧТО СТОРОЖ УМЕЕТ КРАСНЕТЬ (не на слово):
|
|
||||||
test_collision_rule_flags_a_taken_number ниже проверяет само правило на
|
|
||||||
литеральных входах, а сквозной прогон воспроизводится так —
|
|
||||||
git worktree add --detach /tmp/wt <sha до нужной миграции>
|
|
||||||
touch /tmp/wt/tradein-mvp/backend/data/sql/<занятый-NN>_probe.sql
|
|
||||||
(cd /tmp/wt/tradein-mvp/backend && pytest tests/test_migration_numbering.py)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from pathlib import Path, PurePosixPath
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
_TESTS_DIR = Path(__file__).resolve().parent
|
|
||||||
_SQL_DIR = _TESTS_DIR.parent / "data" / "sql"
|
|
||||||
_REPO_ROOT = _TESTS_DIR.parents[2]
|
|
||||||
# Путь каталога ОТ КОРНЯ РЕПО — им адресуем дерево коммита через git ls-tree.
|
|
||||||
_SQL_PATHSPEC = "tradein-mvp/backend/data/sql"
|
|
||||||
|
|
||||||
_NN_PREFIX = re.compile(r"^(\d+)_")
|
|
||||||
# origin — штатный remote; forgejo/main оставлен как исторический алиас.
|
|
||||||
_MAIN_REFS = ("origin/main", "forgejo/main", "main")
|
|
||||||
|
|
||||||
|
|
||||||
def _git(*args: str) -> str | None:
|
|
||||||
"""stdout git-команды, либо None если git недоступен/команда упала."""
|
|
||||||
try:
|
|
||||||
done = subprocess.run(
|
|
||||||
# Фиксированный argv, без shell — args приходят только из этого модуля.
|
|
||||||
["git", "-C", str(_REPO_ROOT), *args],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=30,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
except (OSError, subprocess.SubprocessError):
|
|
||||||
return None
|
|
||||||
return done.stdout if done.returncode == 0 else None
|
|
||||||
|
|
||||||
|
|
||||||
def _sql_names_at(rev: str) -> set[str]:
|
|
||||||
"""Bare-имена *.sql в data/sql на ревизии rev."""
|
|
||||||
out = _git("ls-tree", "-r", "-z", "--name-only", rev, "--", _SQL_PATHSPEC) or ""
|
|
||||||
return {PurePosixPath(p).name for p in out.split("\0") if p.endswith(".sql")}
|
|
||||||
|
|
||||||
|
|
||||||
def _sql_names_on_disk() -> set[str]:
|
|
||||||
"""Bare-имена *.sql в рабочем дереве — включая ещё не закоммиченные."""
|
|
||||||
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 _collisions(new_names: Iterable[str], universe: Iterable[str]) -> list[str]:
|
|
||||||
"""Для каждого НОВОГО имени — чужие имена с тем же NN-префиксом."""
|
|
||||||
by_prefix: dict[str, set[str]] = {}
|
|
||||||
for name in universe:
|
|
||||||
p = _prefix(name)
|
|
||||||
if p is not None:
|
|
||||||
by_prefix.setdefault(p, set()).add(name)
|
|
||||||
|
|
||||||
found: list[str] = []
|
|
||||||
for name in sorted(new_names):
|
|
||||||
p = _prefix(name)
|
|
||||||
if p is None:
|
|
||||||
continue
|
|
||||||
others = sorted(by_prefix.get(p, set()) - {name})
|
|
||||||
if others:
|
|
||||||
found.append(f"{name} — номер {p} уже занят: {', '.join(others)}")
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def _baseline() -> tuple[set[str], set[str]]:
|
|
||||||
"""(имена в точке ветвления, имена в main). Без git-эталона проверять нечего."""
|
|
||||||
main_ref = next(
|
|
||||||
(r for r in _MAIN_REFS if _git("rev-parse", "--verify", "--quiet", f"{r}^{{commit}}")),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
merge_base = None
|
|
||||||
if main_ref is not None:
|
|
||||||
out = _git("merge-base", main_ref, "HEAD")
|
|
||||||
merge_base = out.strip() if out else None
|
|
||||||
|
|
||||||
if main_ref is None or merge_base is None:
|
|
||||||
why = (
|
|
||||||
f"нет git-эталона миграций: ни один из {_MAIN_REFS} не резолвится либо у него "
|
|
||||||
f"нет общего предка с HEAD (repo={_REPO_ROOT}). Лечится "
|
|
||||||
"`git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main` "
|
|
||||||
"и НЕ shallow-клоном (нужен общий предок)."
|
|
||||||
)
|
|
||||||
# В CI это не «нечего проверять», а сломанный гейт: пропуск здесь и есть
|
|
||||||
# тот зелёный, который ничего не проверяет. Поэтому красим.
|
|
||||||
if os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"):
|
|
||||||
pytest.fail(why)
|
|
||||||
pytest.skip(why)
|
|
||||||
|
|
||||||
base_names = _sql_names_at(merge_base)
|
|
||||||
main_names = _sql_names_at(main_ref)
|
|
||||||
# Анти-вакуум: пустой эталон сделал бы обе проверки зелёными всегда.
|
|
||||||
# Ровно так ломается сторож, если _SQL_PATHSPEC разъедется с раскладкой репо.
|
|
||||||
assert base_names, (
|
|
||||||
f"эталон пуст: git ls-tree {merge_base} -- {_SQL_PATHSPEC} не вернул ни одного "
|
|
||||||
"*.sql. Проверка номеров была бы вакуумно-зелёной — почини путь."
|
|
||||||
)
|
|
||||||
assert main_names, f"в {main_ref} не найдено *.sql по пути {_SQL_PATHSPEC} — то же самое."
|
|
||||||
return base_names, main_names
|
|
||||||
|
|
||||||
|
|
||||||
def test_applied_migration_is_not_renamed_or_deleted() -> None:
|
|
||||||
"""Файл, существовавший в точке ветвления, обязан существовать и сейчас.
|
|
||||||
|
|
||||||
Red => эта ветка переименовала или удалила миграцию, которую прод уже
|
|
||||||
применил и помнит по СТАРОМУ имени. Верни исходное имя; нужно поправить
|
|
||||||
поведение — заводи новый файл с новым номером.
|
|
||||||
"""
|
|
||||||
on_disk = _sql_names_on_disk()
|
|
||||||
assert on_disk, f"не найдено *.sql в {_SQL_DIR}"
|
|
||||||
base_names, _ = _baseline()
|
|
||||||
|
|
||||||
gone = sorted(base_names - on_disk)
|
|
||||||
assert not gone, (
|
|
||||||
f"миграции пропали из {_SQL_PATHSPEC}/ (переименованы или удалены): {gone}. "
|
|
||||||
"Прод трекает их по bare-filename в _schema_migrations — под новым именем "
|
|
||||||
"миграция прогонится повторно. Верни имена как были."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_new_migration_takes_a_free_number() -> None:
|
|
||||||
"""Новый файл не переиспользует NN, занятый в origin/main или в этой ветке.
|
|
||||||
|
|
||||||
Red => номер уже занят. Возьми следующий свободный, сверяясь с origin/main:
|
|
||||||
|
|
||||||
git fetch origin main
|
|
||||||
git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail
|
|
||||||
|
|
||||||
`-r` обязателен: без него ls-tree печатает сам каталог, а не файлы (в этом
|
|
||||||
виде рецепт и ходил по issue #2683 — и молча возвращал одну строку).
|
|
||||||
Локального `ls` недостаточно: он не видит миграций, смерженных после
|
|
||||||
ветвления — ровно так разъехались 212 в #2682 и 234 в #2754.
|
|
||||||
"""
|
|
||||||
on_disk = _sql_names_on_disk()
|
|
||||||
base_names, main_names = _baseline()
|
|
||||||
|
|
||||||
new_names = on_disk - base_names
|
|
||||||
problems = _collisions(new_names, main_names | on_disk)
|
|
||||||
assert not problems, "коллизия номеров миграций: " + "; ".join(problems)
|
|
||||||
|
|
||||||
|
|
||||||
def test_collision_rule_flags_a_taken_number() -> None:
|
|
||||||
"""Проверка самого правила — сторож обязан уметь краснеть (#2683 п.6).
|
|
||||||
|
|
||||||
Ожидания здесь — ЛИТЕРАЛЫ, а не производные от содержимого data/sql: тест,
|
|
||||||
который берёт ожидание из охраняемой настройки, зелен при любой настройке.
|
|
||||||
"""
|
|
||||||
# Реальный случай #2754: ветка отвелась до того, как в main приехал 234_scrape.
|
|
||||||
assert _collisions(
|
|
||||||
["234_trade_in_estimates_retain_until.sql"],
|
|
||||||
{
|
|
||||||
"233_payments.sql",
|
|
||||||
"234_scrape_runs_ban_kind_unknown.sql",
|
|
||||||
"234_trade_in_estimates_retain_until.sql",
|
|
||||||
},
|
|
||||||
) == [
|
|
||||||
"234_trade_in_estimates_retain_until.sql — номер 234 уже занят: "
|
|
||||||
"234_scrape_runs_ban_kind_unknown.sql"
|
|
||||||
]
|
|
||||||
# Два новых файла с одним номером внутри одной ветки — оба названы.
|
|
||||||
assert len(_collisions(["300_a.sql", "300_b.sql"], {"300_a.sql", "300_b.sql"})) == 2
|
|
||||||
# Свободный номер — тишина; исторические дубли не новые и не флагаются.
|
|
||||||
assert _collisions(["300_a.sql"], {"084_x.sql", "084_y.sql", "300_a.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