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

View File

@@ -1,44 +1,83 @@
// Sorgu yolundaki ifade indexi + WAL bakımı. Bir kez çalıştırılır
// (pnpm tsx scripts/db-index.ts); ingest/refresh sonrası da güvenle
// tekrar çalıştırılabilir (IF NOT EXISTS).
// Sorgu yolundaki ifade indexi + WAL bakımı. refresh sonrası (ve istenince tek
// başına) çalıştırılır; tekrar çalıştırmak güvenlidir.
//
// src/lib/db.ts tüm aralık sorgularını COALESCE(sira2025, sira2024)
// üzerinden yapar; SQLite ifade indexleri METİNSEL eşleştiği için buradaki
// ifade oradaki EFEKTIF_SIRA sabitiyle birebir aynı olmalıdır. Bu index
// olmadan her sorgu tur-partisyonunu tarayıp temp B-tree ile sıralıyordu.
// src/lib/db.ts tüm aralık sorgularını EFEKTIF_SIRA_SQL üzerinden yapar;
// SQLite ifade indexleri METİNSEL eşleştiği için indeks ifadesi
// src/lib/veri-yillari.ts'teki sabitle birebir aynı olmalıdır — bu yüzden
// buraya elle yazılmaz, import edilir. Bu index olmadan her sorgu
// tur-partisyonunu tarayıp temp B-tree ile sıralıyordu.
//
// Kullanım: pnpm db:index --db data/yokatlas.db [--vacuum]
// --vacuum: arşivleme gibi büyük silmelerden sonra dosyayı küçültür (site kapalıyken).
import path from "node:path";
import Database from "better-sqlite3";
import { EFEKTIF_SIRA_SQL } from "../src/lib/veri-yillari";
import { bayrakVar, calistir, dbAc, dbArgumani, dur, type Db } from "./yokatlas-goc";
const db = new Database(path.join(process.cwd(), "data", "yokatlas.db"));
export const EFEKTIF_INDEKS = "idx_programs_tur_onlisans_efektif";
db.exec(`
CREATE INDEX IF NOT EXISTS idx_programs_tur_onlisans_efektif
ON programs (tur, onlisans, COALESCE(sira2025, sira2024));
`);
/**
* Efektif sıra indeksini güncel ifadeyle kurar. Eski yılın ifadesiyle kurulmuş
* aynı adlı indeks varsa DROP edip yeniden kurar (yıl değişince ifade değişir).
*/
export function efektifIndeksiKur(db: Db): "kuruldu" | "yenilendi" | "guncel" {
const mevcut = db
.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?")
.get(EFEKTIF_INDEKS) as { sql: string } | undefined;
let sonuc: "kuruldu" | "yenilendi" | "guncel" = "kuruldu";
if (mevcut) {
if (mevcut.sql.includes(EFEKTIF_SIRA_SQL)) return "guncel";
db.exec(`DROP INDEX ${EFEKTIF_INDEKS}`);
sonuc = "yenilendi";
}
db.exec(
`CREATE INDEX ${EFEKTIF_INDEKS} ON programs (tur, onlisans, ${EFEKTIF_SIRA_SQL})`
);
return sonuc;
}
// Programatik SEO sayfaları (src/lib/katalog.ts) bölüm/üniversite detaylarını
// ham ada birebir IN (...) eşleşmesiyle çeker; bu iki index olmadan her
// sayfa tam tablo taraması yapar.
db.exec(`
CREATE INDEX IF NOT EXISTS idx_programs_isim ON programs (isim);
CREATE INDEX IF NOT EXISTS idx_programs_universite ON programs (universite);
`);
/** Sihirbaz/liste aralık sorgusunun planı (src/lib/db.ts dilimAra ile aynı biçim). */
export function efektifSorguPlani(db: Db): string[] {
const plan = db
.prepare(
`EXPLAIN QUERY PLAN
SELECT id FROM programs
WHERE tur = ? AND onlisans = ?
AND ${EFEKTIF_SIRA_SQL} IS NOT NULL
AND ${EFEKTIF_SIRA_SQL} BETWEEN ? AND ?
ORDER BY ${EFEKTIF_SIRA_SQL} ASC LIMIT 10`
)
.all("SAYISAL", 0, 1000, 50000) as { detail: string }[];
return plan.map((p) => p.detail);
}
// Uygulama DB'yi readonly açtığı için checkpoint yapamaz; şişen WAL'i
// (okuma başına ekstra maliyet) burada dosyaya geri yaz ve sıfırla.
db.pragma("wal_checkpoint(TRUNCATE)");
if (process.argv[1]?.endsWith("db-index.ts")) {
calistir(() => {
const db = dbAc(dbArgumani());
console.log(`Efektif indeks (${EFEKTIF_SIRA_SQL}): ${efektifIndeksiKur(db)}`);
const plan = db
.prepare(
`EXPLAIN QUERY PLAN
SELECT id FROM programs
WHERE tur = ? AND onlisans = ?
AND COALESCE(sira2025, sira2024) IS NOT NULL
AND COALESCE(sira2025, sira2024) BETWEEN ? AND ?
ORDER BY COALESCE(sira2025, sira2024) ASC LIMIT 10`,
)
.all("SAYISAL", 0, 1000, 50000);
// Programatik SEO sayfaları (src/lib/katalog.ts) bölüm/üniversite
// detaylarını ham ada birebir IN (...) eşleşmesiyle çeker; bu iki index
// olmadan her sayfa tam tablo taraması yapar.
db.exec(`
CREATE INDEX IF NOT EXISTS idx_programs_isim ON programs (isim);
CREATE INDEX IF NOT EXISTS idx_programs_universite ON programs (universite);
`);
console.log("Sorgu planı:", JSON.stringify(plan, null, 2));
db.close();
if (bayrakVar("vacuum")) {
db.exec("VACUUM");
console.log("VACUUM tamam.");
}
// Uygulama DB'yi readonly açtığı için checkpoint yapamaz; şişen WAL'i
// (okuma başına ekstra maliyet) burada dosyaya geri yaz ve sıfırla.
db.pragma("wal_checkpoint(TRUNCATE)");
const plan = efektifSorguPlani(db);
console.log("Sorgu planı:");
for (const satir of plan) console.log(` ${satir}`);
db.close();
if (!plan.some((s) => s.includes(EFEKTIF_INDEKS))) {
dur(`Sorgu planı ${EFEKTIF_INDEKS} indeksini kullanmıyor.`);
}
});
}