Region Cutover Plan — TEXT vocabulary → administrative_divisions FKs
Status: Active Last reviewed: 2026-08-10
MODE CHANGE 2026-08-10 (same day): the app is NOT live — cutover proceeds as a CLEAN BREAK per global-schema-overhaul.md S1: phases 2–4 collapse into one series that moves reads AND drops the TEXT columns/CHECKs outright; no dual-read ceremony, no revertibility windows. Phases 0–1 below remain accurate as shipped groundwork.
Owner-ratified 2026-08-10: "the cost of migration is high once we have Korean users — make the cutovers to the global models." Expand-and-contract cutover of every region read/write from the 17-시도 TEXT vocabulary to the
administrative_divisionsspine (ISO-3166-2; KR adm1 slugs === the TEXT keys by design, 00279). Grounding: globalization-model-review.md — this was the #1 structural debt.
Done (2026-08-10)
- Phase 0 — de-triplication (commit with this doc): the vocabulary now has ONE source —
REGION_KEYSinclub.entity.ts; theRegiontype andregionSchemaderive from it, andconfig/regions.tsSIDO_OPTIONS/SIDO_KEYSare compiler-checked against it. Three-way silent drift is impossible now. - Phase 1 — write-path parity (migration 00455, live):
club_play_regions(the one table that regressed in 00449) gainsadm1_id/adm2_id, the samesync_region_admBEFORE-trigger as clubs/court_venues/public_courts, and a resolver backfill. Every region-bearing table now dual-writes adm FKs on every write.
S1 COMPLETE (2026-08-10)
Chunk B shipped with chunk A: the Region literal union is retired (plain slug strings, DB composite FKs are the validity gate — the JP/US pins now assert 'tokyo'/'california' flow through), the hand-maintained SIDO/SIGUNGU tables are replaced by the generated divisions catalog (config/divisions.kr.ts, 17 adm1 + 228 adm2, byte-verified against the 00279 seeds) behind a thin regions.ts compat layer, and migration 00457 added country_defaults so auto-created spine rows derive tz/currency from their country instead of leaking Asia/Seoul+KRW. Still open from the original S1 list: profiles play_regions JSONB→junction (rides with a later pass); entity FIELD renames (cosmetic, deferred).
Superseded phase notes (historical)
(each one agent-sized; sequence matters)
- Phase 2 — read-path cutovers, one surface at a time. Order by risk: (a) club discovery filters (
get_club_discovery_summaries+ client filter params: region key → adm1_id/slug); (b)search-venuesfnmatchesBases/district scoping (compositebusan-중구slugs → adm2_id pairs); (c) wizard base derivation (deriveBasesFromPicksemits adm ids alongside keys; picks carryadm1_id/adm2_idfrom search results); (d) profile play-region readers move toprofile_play_regionsjunction. Each cutover keeps writing TEXT (triggers derive FKs), only READS move — instantly revertible. - Phase 3 — client vocabulary swap.
Regionfields in entities becomeregionCode: string(adm slug) + optional display resolution through the spine'snamesJSONB;coords-to-regionreplaced by nearest-adm lookup (or PostGIS later).SIDO_TO_ISO_3166_2crosswalk retires (slugs are already the join key). - Phase 4 — contract. Drop the 17-value CHECK constraints (keep TEXT columns as denormalized display cache or drop entirely), delete
SIGUNGU_BY_SIDOclient table in favor of spine adm2 queries, remove the legacyplay_regionsJSONB after junction cutover.
Invariants during the cutover
- TEXT and FKs never disagree: triggers own the FKs; clients never write adm ids directly.
- A read-path cutover ships only with a probe comparing old-vs-new result sets on live data (seeded scenarios +
yarn debug:logs). - New features MUST NOT add TEXT-vocabulary reads (standing rule, globalization review) — they read FKs from day one even before old surfaces cut over.
S1 EXECUTION CONTRACT (locked 2026-08-10 — clean break, app not live)
Key recon fact: adm1 slugs === today's 17 region TEXT keys, and adm2 slugs are {adm1slug}-{한글구명} composites (00279 seeds) — today's district 한글 maps to adm2_slug via region || '-' || district. So the cutover is a RENAME + FK, not a data rewrite. Environment-specific UUIDs make id-embedding client-side impossible → the stored reference becomes the SLUG with a composite FK.
Target shape (tables: clubs, court_venues, public_courts, club_play_regions, profiles home-region; spine venues stays as-is — already global):
country_code CHAR(2) NOT NULL DEFAULT 'KR'regionTEXT → RENAME toadm1_slug(values unchanged); CHECK(17) dropped; composite FK(country_code, adm1_slug) REFERENCES administrative_divisions(country_code, slug)districtTEXT (한글) →adm2_slug=adm1_slug || '-' || district, same composite FK- UUID
adm1_id/adm2_idscaffolding columns +sync_region_admtriggers DROPPED (expand-phase artifacts) profiles.play_regionsJSONB {sido,sigungu} → slug array or junction-by-slug (decide in-flight)
Client shape: Region enum type retires → adm1Slug: string (same values) + adm2Slug: string; display names resolve via a GENERATED divisions catalog (config/divisions.kr.ts, generated from the 00279 seed VALUES — slugs + ko/en names, no UUIDs); SIDO_OPTIONS/SIGUNGU_BY_SIDO/regionLabel retire into catalog lookups; coords-to-region returns adm1 slug (logic unchanged, output renamed).
Work chunks (agent-sized, in order):
- Migration 00456: renames + country_code + composite FKs + adm2_slug backfill + drop UUID cols/triggers/CHECKs, for the 4 club/court tables (+ profiles home region). Regenerate supabase types (prettier!).
- Divisions catalog: generate
config/divisions.kr.tsfrom 00279 seed data; new lookups (divisionLabel, adm2ByAdm1); retire SIDO/SIGUNGU tables + Region enum (REGION_KEYS stays as KR adm1 slug list, re-typed). - SQL readers: sed-level rename fallout in every fn referencing region/district (get_club_discovery_summaries, search-venues fn matchesBases/districtsByReg, RLS, triggers, views) + adjust adm2 matching to slugs.
- Client sweep: entities (club/venue/profile/session/preferences/directory-venue) field renames; wizard step2 (deriveBasesFromPicks, search params), step3 fallback, discovery filters, session surfaces; store persistence keys.
- Seed-verify: scenario chains + search fn probes; gates; OTA.