import { drizzle as drizzlePg } from "drizzle-orm/postgres-js"; import { migrate as migratePg } from "drizzle-orm/postgres-js/migrator"; import { readdir, readFile } from "node:fs/promises"; import postgres from "postgres"; import * as schema from "./schema/index.js"; const MIGRATIONS_FOLDER = new URL("./migrations", import.meta.url).pathname; const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; const MIGRATIONS_JOURNAL_JSON = new URL("./migrations/meta/_journal.json", import.meta.url).pathname; function isSafeIdentifier(value: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); } function quoteIdentifier(value: string): string { if (!isSafeIdentifier(value)) throw new Error(`Unsafe SQL identifier: ${value}`); return `"${value.replaceAll("\"", "\"\"")}"`; } export type MigrationState = | { status: "upToDate"; tableCount: number; availableMigrations: string[]; appliedMigrations: string[] } | { status: "needsMigrations"; tableCount: number; availableMigrations: string[]; appliedMigrations: string[]; pendingMigrations: string[]; reason: "no-migration-journal-empty-db" | "no-migration-journal-non-empty-db" | "pending-migrations"; }; export function createDb(url: string) { const sql = postgres(url); return drizzlePg(sql, { schema }); } async function listMigrationFiles(): Promise { const entries = await readdir(MIGRATIONS_FOLDER, { withFileTypes: true }); return entries .filter((entry) => entry.isFile() && entry.name.endsWith(".sql")) .map((entry) => entry.name) .sort((a, b) => a.localeCompare(b)); } type MigrationJournalFile = { entries?: Array<{ tag?: string }>; }; async function listJournalMigrationFiles(): Promise { try { const raw = await readFile(MIGRATIONS_JOURNAL_JSON, "utf8"); const parsed = JSON.parse(raw) as MigrationJournalFile; if (!Array.isArray(parsed.entries)) return []; return parsed.entries .map((entry) => (typeof entry?.tag === "string" ? `${entry.tag}.sql` : null)) .filter((name): name is string => typeof name === "string"); } catch { return []; } } async function loadAppliedMigrations( sql: ReturnType, migrationTableSchema: string, availableMigrations: string[], ): Promise { const qualifiedTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`; try { const rows = await sql.unsafe<{ name: string }[]>(`SELECT name FROM ${qualifiedTable} ORDER BY id`); return rows.map((row) => row.name).filter((name): name is string => Boolean(name)); } catch (error) { if (!(error instanceof Error) || !error.message.includes('column "name" does not exist')) { throw error; } } const rows = await sql.unsafe<{ id: number }[]>(`SELECT id FROM ${qualifiedTable} ORDER BY id`); const journalMigrationFiles = await listJournalMigrationFiles(); const appliedFromIds = rows .map((row) => journalMigrationFiles[row.id - 1]) .filter((name): name is string => Boolean(name)); if (appliedFromIds.length > 0) return appliedFromIds; return availableMigrations.slice(0, Math.max(0, rows.length)); } async function discoverMigrationTableSchema(sql: ReturnType): Promise { const rows = await sql<{ schemaName: string }[]>` SELECT n.nspname AS "schemaName" FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relname = ${DRIZZLE_MIGRATIONS_TABLE} AND c.relkind = 'r' `; if (rows.length === 0) return null; const drizzleSchema = rows.find(({ schemaName }) => schemaName === "drizzle"); if (drizzleSchema) return drizzleSchema.schemaName; const publicSchema = rows.find(({ schemaName }) => schemaName === "public"); if (publicSchema) return publicSchema.schemaName; return rows[0]?.schemaName ?? null; } export async function inspectMigrations(url: string): Promise { const sql = postgres(url, { max: 1 }); try { const availableMigrations = await listMigrationFiles(); const tableCountResult = await sql<{ count: number }[]>` select count(*)::int as count from information_schema.tables where table_schema = 'public' and table_type = 'BASE TABLE' `; const tableCount = tableCountResult[0]?.count ?? 0; const migrationTableSchema = await discoverMigrationTableSchema(sql); if (!migrationTableSchema) { if (tableCount > 0) { return { status: "needsMigrations", tableCount, availableMigrations, appliedMigrations: [], pendingMigrations: availableMigrations, reason: "no-migration-journal-non-empty-db", }; } return { status: "needsMigrations", tableCount, availableMigrations, appliedMigrations: [], pendingMigrations: availableMigrations, reason: "no-migration-journal-empty-db", }; } const appliedMigrations = await loadAppliedMigrations(sql, migrationTableSchema, availableMigrations); const pendingMigrations = availableMigrations.filter((name) => !appliedMigrations.includes(name)); if (pendingMigrations.length === 0) { return { status: "upToDate", tableCount, availableMigrations, appliedMigrations, }; } return { status: "needsMigrations", tableCount, availableMigrations, appliedMigrations, pendingMigrations, reason: "pending-migrations", }; } finally { await sql.end(); } } export async function applyPendingMigrations(url: string): Promise { const sql = postgres(url, { max: 1 }); try { const db = drizzlePg(sql); await migratePg(db, { migrationsFolder: MIGRATIONS_FOLDER }); } finally { await sql.end(); } } export type MigrationBootstrapResult = | { migrated: true; reason: "migrated-empty-db"; tableCount: 0 } | { migrated: false; reason: "already-migrated"; tableCount: number } | { migrated: false; reason: "not-empty-no-migration-journal"; tableCount: number }; export async function migratePostgresIfEmpty(url: string): Promise { const sql = postgres(url, { max: 1 }); try { const migrationTableSchema = await discoverMigrationTableSchema(sql); const tableCountResult = await sql<{ count: number }[]>` select count(*)::int as count from information_schema.tables where table_schema = 'public' and table_type = 'BASE TABLE' `; const tableCount = tableCountResult[0]?.count ?? 0; if (migrationTableSchema) { return { migrated: false, reason: "already-migrated", tableCount }; } if (tableCount > 0) { return { migrated: false, reason: "not-empty-no-migration-journal", tableCount }; } const db = drizzlePg(sql); const migrationsFolder = new URL("./migrations", import.meta.url).pathname; await migratePg(db, { migrationsFolder }); return { migrated: true, reason: "migrated-empty-db", tableCount: 0 }; } finally { await sql.end(); } } export async function ensurePostgresDatabase( url: string, databaseName: string, ): Promise<"created" | "exists"> { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(databaseName)) { throw new Error(`Unsafe database name: ${databaseName}`); } const sql = postgres(url, { max: 1 }); try { const existing = await sql<{ one: number }[]>` select 1 as one from pg_database where datname = ${databaseName} limit 1 `; if (existing.length > 0) return "exists"; await sql.unsafe(`create database "${databaseName}"`); return "created"; } finally { await sql.end(); } } export type Db = ReturnType;