/** * yokatlas.db şema göçü — idempotent. Yeni yerleştirme yılı için kolonları, * `son_kilavuz_yili` / `eski_kod` kolonlarını, `programs_arsiv` ve `veri_meta` * tablolarını garanti eder. Hiçbir mevcut değeri silmez/ezmez. * * Betiklerin ortak yardımcıları (argüman okuma, DB açma) da buradadır; * refresh / gecmis-doldur / eski-arsivle / detay / veri-kalite buradan alır. * * Kullanım: pnpm yokatlas:goc --yil 2026 --db data/yokatlas.db */ import fs from "node:fs"; import path from "node:path"; import Database from "better-sqlite3"; import { ILK_YIL, SON_YIL } from "../src/lib/veri-yillari"; export type Db = Database.Database; /** Her yıl için tutulan dört ölçü ve SQLite tipi. */ export const YIL_OLCULERI = [ ["sira", "INTEGER"], ["puan", "REAL"], ["kontenjan", "INTEGER"], ["yerlesen", "INTEGER"], ] as const; // ── Komut satırı ───────────────────────────────────────────────────────── /** `--ad deger` ya da `--ad=deger`. Yoksa undefined. */ export function argOku(ad: string): string | undefined { const argv = process.argv.slice(2); for (let i = 0; i < argv.length; i++) { if (argv[i] === `--${ad}`) { const sonraki = argv[i + 1]; return sonraki && !sonraki.startsWith("--") ? sonraki : undefined; } if (argv[i].startsWith(`--${ad}=`)) return argv[i].slice(ad.length + 3); } return undefined; } export function bayrakVar(ad: string): boolean { return process.argv.slice(2).includes(`--${ad}`); } export class DurHatasi extends Error {} /** Güvenlik kilidi: mesajı yazıp betiği durdurmak için fırlatılır. */ export function dur(mesaj: string): never { throw new DurHatasi(mesaj); } /** main() sarmalayıcı: DurHatasi → temiz mesaj + çıkış kodu 1. */ export function calistir(main: () => Promise | void): void { Promise.resolve() .then(main) .catch((err) => { if (err instanceof DurHatasi) console.error(`\nDURDU: ${err.message}`); else console.error(err); process.exit(1); }); } /** * `--yil` ZORUNLU ve `SON_YIL` ile aynı olmalı. Varsayılan yok: betik hangi * yılı yazdığını API'den ya da takvimden TAHMİN ETMEZ. */ export function yilArgumani(): number { const ham = argOku("yil"); if (!ham) { dur( `--yil zorunlu (ör. --yil ${SON_YIL}). Varsayılan yok; yıl API kaydındaki ` + "`yil` alanından da türetilmez." ); } const yil = Number.parseInt(ham, 10); if (!Number.isInteger(yil) || String(yil) !== ham.trim()) { dur(`--yil sayı olmalı: "${ham}"`); } if (yil !== SON_YIL) { dur( `--yil ${yil} ≠ SON_YIL ${SON_YIL} (src/lib/veri-yillari.ts). Yeni yıl ` + "yükleniyorsa önce SON_YIL'i güncelle; betikler ve site aynı sabiti okur." ); } return yil; } /** `--db` ZORUNLU; dosya var olmalı. Yanlışlıkla boş DB yaratılmaz. */ export function dbArgumani(): string { const ham = argOku("db"); if (!ham) { dur( "--db zorunlu (ör. --db data/yokatlas.db). Önce kopya üzerinde dene: " + "cp data/yokatlas.db /tmp/deneme.db" ); } const yol = path.resolve(process.cwd(), ham); if (!fs.existsSync(yol)) dur(`DB bulunamadı: ${yol}`); return yol; } export function dbAc(yol: string, secenek: { saltOkunur?: boolean } = {}): Db { const db = new Database(yol, { readonly: secenek.saltOkunur ?? false, fileMustExist: true, }); if (!secenek.saltOkunur) db.pragma("journal_mode = WAL"); return db; } // ── Şema ───────────────────────────────────────────────────────────────── export function tabloVar(db: Db, tablo: string): boolean { return ( db .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") .get(tablo) !== undefined ); } export function kolonAdlari(db: Db, tablo: string): string[] { return ( db.prepare(`PRAGMA table_info(${tablo})`).all() as { name: string }[] ).map((c) => c.name); } function yilDogrula(yil: number): void { if (!Number.isInteger(yil) || yil < ILK_YIL || yil > 2100) { dur(`Geçersiz yıl: ${yil}`); } } /** * sira/puan/kontenjan/yerlesen kolonlarını (yoksa) ekler. İdempotent. * Kontenjan kırılımı (gk/obk/…) ve kontenjanMeb kolonları BİLEREK eklenmez: * `src/` içinde okuyan kod yok (KARARLAR 2026-09-21). */ export function yilKolonlariniGarantiEt( db: Db, yil: number, tablo = "programs" ): string[] { yilDogrula(yil); const mevcut = new Set(kolonAdlari(db, tablo)); const eklenen: string[] = []; for (const [olcu, tip] of YIL_OLCULERI) { const kolon = `${olcu}${yil}`; if (mevcut.has(kolon)) continue; db.exec(`ALTER TABLE ${tablo} ADD COLUMN ${kolon} ${tip}`); eklenen.push(kolon); } return eklenen; } /** * `son_kilavuz_yili`: programın görüldüğü son tercih kılavuzu yılı. * `eski_kod`: kılavuz kodu değiştiyse bir önceki kod (API `eskiKilavuzKodu` * ya da gecmis-doldur'un benzersiz ad eşleşmesi). * * Geri doldurma TEK SEFERLİK — yalnız kolon ilk kez eklenirken koşar: * kontenjan2025 dolu → 2025 kılavuzunda vardı. Gerisi NULL (bilinmiyor) kalır. */ export function kilavuzKolonlariniGarantiEt(db: Db): string[] { const mevcut = new Set(kolonAdlari(db, "programs")); const eklenen: string[] = []; if (!mevcut.has("son_kilavuz_yili")) { db.exec("ALTER TABLE programs ADD COLUMN son_kilavuz_yili INTEGER"); eklenen.push("son_kilavuz_yili"); if (mevcut.has("kontenjan2025")) { const r = db .prepare( "UPDATE programs SET son_kilavuz_yili = 2025 WHERE kontenjan2025 IS NOT NULL" ) .run(); console.log(` son_kilavuz_yili geri doldurma (2025): ${r.changes} satır`); } } if (!mevcut.has("eski_kod")) { db.exec("ALTER TABLE programs ADD COLUMN eski_kod TEXT"); eklenen.push("eski_kod"); } return eklenen; } /** * `programs_arsiv`: programs ile AYNI kolonlar (ad + tip + sıra). Yoksa kurar; * varsa programs'a sonradan eklenmiş kolonları ona da ekler. Böylece * `INSERT INTO programs_arsiv SELECT * FROM programs` her zaman geçerlidir… * yine de taşıma kolon adlarıyla yapılır (bkz. eski-arsivle.ts). */ export function arsivTablosunuGarantiEt(db: Db): string[] { const kolonlar = db.prepare("PRAGMA table_info(programs)").all() as { name: string; type: string; }[]; if (!tabloVar(db, "programs_arsiv")) { const tanim = kolonlar .map((c) => c.name === "id" ? "id TEXT PRIMARY KEY" : `${c.name} ${c.type || "TEXT"}` ) .join(",\n "); db.exec(`CREATE TABLE programs_arsiv (\n ${tanim}\n)`); return ["programs_arsiv"]; } const arsivde = new Set(kolonAdlari(db, "programs_arsiv")); const eklenen: string[] = []; for (const c of kolonlar) { if (arsivde.has(c.name)) continue; db.exec( `ALTER TABLE programs_arsiv ADD COLUMN ${c.name} ${c.type || "TEXT"}` ); eklenen.push(`programs_arsiv.${c.name}`); } return eklenen; } /** Koşu kayıtları (API toplamı, atlanan, ham dosya…): veri-kalite okur. */ export function metaTablosunuGarantiEt(db: Db): void { db.exec( "CREATE TABLE IF NOT EXISTS veri_meta (anahtar TEXT PRIMARY KEY, deger TEXT NOT NULL)" ); } export function metaYaz(db: Db, anahtar: string, deger: string | number): void { metaTablosunuGarantiEt(db); db.prepare( "INSERT INTO veri_meta (anahtar, deger) VALUES (?, ?) " + "ON CONFLICT(anahtar) DO UPDATE SET deger = excluded.deger" ).run(anahtar, String(deger)); } export function metaOku(db: Db, anahtar: string): string | undefined { if (!tabloVar(db, "veri_meta")) return undefined; const r = db .prepare("SELECT deger FROM veri_meta WHERE anahtar = ?") .get(anahtar) as { deger: string } | undefined; return r?.deger; } /** Tüm göç: yıl kolonları + kılavuz kolonları + arşiv + meta. İdempotent. */ export function yokatlasGoc(db: Db, yil: number): string[] { const eklenen: string[] = []; db.transaction(() => { eklenen.push(...yilKolonlariniGarantiEt(db, yil)); eklenen.push(...kilavuzKolonlariniGarantiEt(db)); eklenen.push(...arsivTablosunuGarantiEt(db)); metaTablosunuGarantiEt(db); })(); return eklenen; } // Doğrudan koşulduğunda if (process.argv[1]?.endsWith("yokatlas-goc.ts")) { calistir(() => { const yil = yilArgumani(); const db = dbAc(dbArgumani()); const eklenen = yokatlasGoc(db, yil); console.log( eklenen.length ? `Göç tamam. Eklenen: ${eklenen.join(", ")}` : "Göç tamam. Şema zaten güncel (değişiklik yok)." ); db.close(); }); }