import { loadDotenv } from "../../loadDotenv"; loadDotenv(); import { createClient, type Client } from "@libsql/client"; import { createHash } from "crypto"; import { readdirSync, readFileSync } from "fs"; import path from "path"; type MigrationFile = { name: string; sqlPath: string; sql: string; checksum: string; }; const MIGRATION_TABLE = "_turso_schema_migrations"; const MIGRATIONS_DIR = path.resolve(process.cwd(), "configs/db/migrations"); function sha256(input: string): string { return createHash("sha256").update(input).digest("hex"); } function getMigrations(): MigrationFile[] { const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name) .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); return dirs.map((name) => { const sqlPath = path.join(MIGRATIONS_DIR, name, "migration.sql"); const sql = readFileSync(sqlPath, "utf8").trim(); if (!sql) { throw new Error(`Migration is empty: ${sqlPath}`); } return { name, sqlPath, sql, checksum: sha256(sql), }; }); } function resolveTursoUrl(): string { const explicit = process.env.PRISMA_DATABASE_URL?.trim(); if (explicit) return explicit; const turso = process.env.TURSO_DATABASE_URL?.trim(); if (turso) return turso; throw new Error( "Missing DB URL. Set TURSO_DATABASE_URL (recommended) or PRISMA_DATABASE_URL.", ); } function resolveAuthToken(url: string): string | undefined { const token = process.env.TURSO_AUTH_TOKEN?.trim(); if (!url.startsWith("libsql://")) return undefined; if (!token) { throw new Error( "Missing TURSO_AUTH_TOKEN for libsql:// URL. Set TURSO_AUTH_TOKEN.", ); } return token; } async function ensureMigrationTable(client: Client): Promise { await client.execute(` CREATE TABLE IF NOT EXISTS "${MIGRATION_TABLE}" ( "name" TEXT PRIMARY KEY, "checksum" TEXT NOT NULL, "applied_at" TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) ); `); } function splitSqlStatements(sql: string): string[] { const statements: string[] = []; let current = ""; let inSingle = false; let inDouble = false; for (let i = 0; i < sql.length; i++) { const ch = sql[i]!; const next = i + 1 < sql.length ? sql[i + 1]! : ""; if (!inSingle && !inDouble && ch === "-" && next === "-") { while (i < sql.length && sql[i] !== "\n") i++; continue; } if (ch === "'" && !inDouble) { inSingle = !inSingle; current += ch; continue; } if (ch === `"` && !inSingle) { inDouble = !inDouble; current += ch; continue; } if (ch === ";" && !inSingle && !inDouble) { const stmt = current.trim(); if (stmt) statements.push(stmt); current = ""; continue; } current += ch; } const tail = current.trim(); if (tail) statements.push(tail); return statements; } async function loadApplied( client: Client, ): Promise> { const rs = await client.execute( `SELECT name, checksum FROM "${MIGRATION_TABLE}" ORDER BY name ASC`, ); const out = new Map(); for (const r of rs.rows) { const name = String(r.name ?? ""); if (!name) continue; out.set(name, { checksum: String(r.checksum ?? "") }); } return out; } async function markApplied(client: Client, m: MigrationFile): Promise { await client.execute({ sql: `INSERT INTO "${MIGRATION_TABLE}"(name, checksum) VALUES (?, ?)`, args: [m.name, m.checksum], }); } function parseArgs(): { dryRun: boolean; baseline: boolean } { const args = new Set(process.argv.slice(2)); return { dryRun: args.has("--dry-run"), baseline: args.has("--baseline"), }; } async function run(): Promise { const { dryRun, baseline } = parseArgs(); if (dryRun && baseline) { throw new Error("Choose one mode only: --dry-run OR --baseline."); } const url = resolveTursoUrl(); const authToken = resolveAuthToken(url); const client = createClient({ url, authToken, }); await ensureMigrationTable(client); const migrations = getMigrations(); const applied = await loadApplied(client); const pending = migrations.filter((m) => !applied.has(m.name)); for (const m of migrations) { const seen = applied.get(m.name); if (seen && seen.checksum !== m.checksum) { throw new Error( `Checksum mismatch for applied migration "${m.name}". Expected ${seen.checksum}, current ${m.checksum}.`, ); } } console.log( `[migrate:turso] total=${migrations.length}, applied=${applied.size}, pending=${pending.length}`, ); if (pending.length === 0) { console.log("[migrate:turso] up to date."); return; } for (const m of pending) { console.log(`[migrate:turso] pending: ${m.name}`); } if (dryRun) return; if (baseline) { for (const m of pending) { await markApplied(client, m); console.log(`[migrate:turso] baseline marked: ${m.name}`); } return; } for (const m of pending) { const statements = splitSqlStatements(m.sql); console.log( `[migrate:turso] applying: ${m.name} (${statements.length} statements)`, ); for (const stmt of statements) { await client.execute(stmt); } await markApplied(client, m); console.log(`[migrate:turso] applied: ${m.name}`); } } void run().catch((err) => { console.error("[migrate:turso] failed:", err); process.exit(1); });