feat: prod app.db şemasını açılışta drizzle migration'larıyla eşitle
Some checks failed
Deploy / deploy (push) Failing after 13s

Volume'deki app.db yalnızca ilk kurulumda seed'den kopyalanıyordu; sonraki
deploy'larda şemaya eklenen kolonlar (son örnek: kredi_bitti_at,
kredi_hatirlatma_gonderildi_at) prod'a hiç ulaşmıyor ve 'no such column'
hatalarıyla kırılıyordu.

- drizzle-kit generate ile baseline migration üretildi (drizzle/0000_baslangic)
- scripts/db-goc.mjs: entrypoint'te server'dan önce koşan migration runner;
  drizzle migrator protokolünü birebir taklit eder (__drizzle_migrations,
  sha256, created_at=journal.when) ama yalnızca @libsql/client'a dayanır —
  drizzle-orm standalone imajda bundle'landığından paket olarak çözülemiyor
- Benimseme: migration kaydı olmayan mevcut volume'lerde baseline additive
  uzlaştırılır (IF NOT EXISTS + snapshot'tan eksik kolon ALTER'ları) ve
  damgalanır; sonraki migration'lar normal akışla uygulanır
- sema-guvence.ts kaldırıldı: elle DDL aynalama yerini migration'lara bıraktı
- Yeni akış: şema değişikliğinde pnpm db:generate ile migration commit'lenir

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
bilalgursen
2026-08-07 01:05:42 +03:00
parent 18d5b42136
commit 45775c8c3f
10 changed files with 1214 additions and 51 deletions

157
scripts/db-goc.mjs Normal file
View File

@@ -0,0 +1,157 @@
#!/usr/bin/env node
/**
* app.db şemasını drizzle/ altındaki migration'larla eşitler — konteyner
* açılışında (docker-entrypoint.sh) node server.js'den ÖNCE koşar, lokalde
* `pnpm db:migrate` ile çalıştırılır. Idempotenttir.
*
* Neden drizzle-orm'un kendi migrate()'i değil: standalone çıktıda drizzle-orm
* Turbopack'çe bundle'landığı için /app/node_modules'ta paket olarak yok;
* @libsql/client ise serverExternalPackages'ta olduğundan her zaman çözülür.
* Bu script drizzle migrator protokolünü birebir taklit eder (__drizzle_migrations
* tablosu, sha256 hash, created_at = journal.when) — ileride resmi migrate()'e
* geçiş kayıtları bozmaz.
*
* Benimseme (adoption): migration akışından önce doldurulmuş prod volume'lerinde
* __drizzle_migrations tablosu yoktur ama şema (kısmen) vardır. Bu durumda
* baseline (0000) bir kez additive uygulanır — CREATE'ler IF NOT EXISTS'e
* çevrilir, eksik kolonlar snapshot'tan ALTER TABLE ile eklenir — ve baseline
* uygulanmış olarak damgalanır. Sonraki migration'lar (0001+) normal akışla gelir.
*/
import { createClient } from "@libsql/client";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import process from "node:process";
const GOC_KLASORU = process.env.DRIZZLE_DIR ?? join(process.cwd(), "drizzle");
const DB_URL = process.env.APP_DB_URL ?? "file:./data/app.db";
const GOC_TABLOSU_DDL = `CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at numeric
)`;
/** Journal'daki her girdiyi { when, tag, hash, ifadeler[] } olarak yükler. */
function gocleriOku() {
const journal = JSON.parse(
readFileSync(join(GOC_KLASORU, "meta", "_journal.json"), "utf8"),
);
return journal.entries.map((girdi) => {
const sql = readFileSync(join(GOC_KLASORU, `${girdi.tag}.sql`), "utf8");
return {
when: girdi.when,
tag: girdi.tag,
hash: createHash("sha256").update(sql).digest("hex"),
ifadeler: sql
.split("--> statement-breakpoint")
.map((s) => s.trim())
.filter(Boolean),
};
});
}
async function tabloVar(db, ad) {
const r = await db.execute({
sql: "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
args: [ad],
});
return r.rows.length > 0;
}
/** Snapshot'taki default değerini SQL literaline çevirir (string zaten tırnaklı gelir). */
function sqlVarsayilan(deger) {
return typeof deger === "string" ? deger : String(deger);
}
/**
* Benimseme adımı: baseline'ı additive uygular.
* 1) CREATE TABLE/INDEX ifadeleri IF NOT EXISTS ile koşulur (eksik tablolar gelir).
* 2) Mevcut tablolarda eksik kolonlar baseline snapshot'ından ALTER TABLE ile eklenir.
*/
async function baselineIleUzlastir(db, baseline) {
for (const ifade of baseline.ifadeler) {
const korunmus = ifade
.replace(/^CREATE TABLE `/, "CREATE TABLE IF NOT EXISTS `")
.replace(/^CREATE INDEX `/, "CREATE INDEX IF NOT EXISTS `")
.replace(/^CREATE UNIQUE INDEX `/, "CREATE UNIQUE INDEX IF NOT EXISTS `");
await db.execute(korunmus);
}
const snapshot = JSON.parse(
readFileSync(join(GOC_KLASORU, "meta", "0000_snapshot.json"), "utf8"),
);
for (const tablo of Object.values(snapshot.tables)) {
const mevcut = await db.execute(`PRAGMA table_info(\`${tablo.name}\`)`);
const mevcutKolonlar = new Set(mevcut.rows.map((r) => r.name));
for (const kolon of Object.values(tablo.columns)) {
if (mevcutKolonlar.has(kolon.name)) continue;
let ddl = `ALTER TABLE \`${tablo.name}\` ADD COLUMN \`${kolon.name}\` ${kolon.type}`;
if (kolon.default !== undefined) {
ddl += ` DEFAULT ${sqlVarsayilan(kolon.default)}`;
if (kolon.notNull) ddl += " NOT NULL";
} else if (kolon.notNull) {
// Dolu tabloya default'suz NOT NULL kolon eklenemez (SQLite kısıtı);
// nullable ekleyip uyarıyoruz — uygulama zaten değeri hep yazıyor.
console.warn(
`[db-goc] uyarı: ${tablo.name}.${kolon.name} NOT NULL ama default'suz — nullable eklendi`,
);
}
await db.execute(ddl);
console.log(`[db-goc] kolon eklendi: ${tablo.name}.${kolon.name}`);
}
}
}
async function main() {
const gocler = gocleriOku();
const db = createClient({
url: DB_URL,
authToken: process.env.APP_DB_AUTH_TOKEN,
});
try {
// Benimseme: şema var ama migration kaydı yok → baseline'ı uzlaştır + damgala.
const kayitVar = await tabloVar(db, "__drizzle_migrations");
if (!kayitVar && (await tabloVar(db, "user"))) {
console.log("[db-goc] migration kaydı yok, mevcut şema benimseniyor...");
await baselineIleUzlastir(db, gocler[0]);
await db.execute(GOC_TABLOSU_DDL);
await db.execute({
sql: 'INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)',
args: [gocler[0].hash, gocler[0].when],
});
console.log(`[db-goc] baseline damgalandı: ${gocler[0].tag}`);
}
// Normal akış: son uygulanan migration'dan sonrakileri sırayla uygula.
await db.execute(GOC_TABLOSU_DDL);
const son = await db.execute(
'SELECT created_at FROM "__drizzle_migrations" ORDER BY created_at DESC LIMIT 1',
);
const sonZaman = son.rows.length ? Number(son.rows[0].created_at) : 0;
for (const goc of gocler) {
if (goc.when <= sonZaman) continue;
await db.batch(
[
...goc.ifadeler,
{
sql: 'INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES (?, ?)',
args: [goc.hash, goc.when],
},
],
"write",
);
console.log(`[db-goc] uygulandı: ${goc.tag}`);
}
console.log("[db-goc] şema güncel");
} finally {
db.close();
}
}
main().catch((hata) => {
console.error("[db-goc] migration başarısız:", hata);
process.exit(1);
});

View File

@@ -9,9 +9,14 @@ for ek in wal shm; do
fi
done
# app.db kullanıcı verisi: sadece volume'de yoksa (ilk kurulum) şema tohumu kopyalanır
# app.db kullanıcı verisi: sadece volume'de yoksa (ilk kurulum) tohum kopyalanır
# (tohum tadimlik_havuzu içeriği de taşır, salt şema değil)
if [ ! -s /app/data/app.db ] && [ -f /app/seed/app.db ]; then
cp /app/seed/app.db /app/data/
fi
# Volume'deki app.db şeması güncel şemayla eşitlenir: drizzle/ altındaki
# migration'lar uygulanır (idempotent; ilk koşuda mevcut şema benimsenir).
node /app/scripts/db-goc.mjs
exec node server.js