feat(veri): 2026 yükleme hattı — yıl kilitli refresh, geçmiş doldurma, arşiv, kalite kapısı (P-A)

- yokatlas-goc: idempotent şema göçü (yıl kolonları, son_kilavuz_yili, eski_kod,
  programs_arsiv, veri_meta) + betiklerin ortak yardımcıları
- refresh: --yil/--db zorunlu, YIL KİLİDİ (…1 = DB sira<yil-1> ≥%95 ve eksiz ≠),
  yıl asla r.yil'den türetilmez; ham veri data/ham/*.jsonl.gz, --ham ile API'siz
  tekrar; benzersiz kod = totalElements; tek transaction + önceki yıl sağlaması;
  yeni programda …1/2/3 → üç önceki yıl; efektif indeks EFEKTIF_SIRA_SQL ile
- gecmis-doldur: yalnız NULL alan; API …1/2/3 → eski_kod → kanıtlı 1:1 ad eşleşmesi
- eski-arsivle: güncel kılavuzda olmayanlar → programs_arsiv, netler yeni koda; --kuru
- detay: netler bağımsız + yıl sağlaması; kırılım yalnız --kirilim, alan yoksa durur,
  mevcut değerin üstüne NULL yazılmaz; `yil - 1` varsayımı kalktı
- veri-kalite: salt okunur kalite kapısı, zorunlu kontrol kalırsa çıkış kodu 1
- docs/veri/yillik-guncelleme.md: adım adım runbook + geri alma

DB commit'lenmedi (arşivleme kararı [Bilal]).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-09-21 17:03:01 +03:00
parent 38e02e0ed9
commit a773ac7beb
10 changed files with 2016 additions and 279 deletions

267
scripts/yokatlas-goc.ts Normal file
View File

@@ -0,0 +1,267 @@
/**
* 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): 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<yil> 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();
});
}