2026-02-19 13:02:14 -06:00
import { createHash } from "node:crypto" ;
2026-02-16 19:07:37 -06:00
import { drizzle as drizzlePg } from "drizzle-orm/postgres-js" ;
2026-02-18 11:45:43 -06:00
import { migrate as migratePg } from "drizzle-orm/postgres-js/migrator" ;
2026-02-19 13:02:14 -06:00
import { readFile , readdir } from "node:fs/promises" ;
2026-02-16 13:31:52 -06:00
import postgres from "postgres" ;
import * as schema from "./schema/index.js" ;
2026-02-19 09:09:26 -06:00
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 ( "\"" , "\"\"" ) } " ` ;
}
2026-02-19 13:02:14 -06:00
function quoteLiteral ( value : string ) : string {
return ` ' ${ value . replaceAll ( "'" , "''" ) } ' ` ;
}
function splitMigrationStatements ( content : string ) : string [ ] {
return content
. split ( "--> statement-breakpoint" )
. map ( ( statement ) = > statement . trim ( ) )
. filter ( ( statement ) = > statement . length > 0 ) ;
}
2026-02-19 09:09:26 -06:00
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" ;
} ;
2026-02-16 13:31:52 -06:00
export function createDb ( url : string ) {
const sql = postgres ( url ) ;
2026-02-16 19:07:37 -06:00
return drizzlePg ( sql , { schema } ) ;
}
2026-02-19 09:09:26 -06:00
async function listMigrationFiles ( ) : Promise < string [ ] > {
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 = {
2026-02-19 13:02:14 -06:00
entries? : Array < { tag? : string ; when? : number } > ;
2026-02-19 09:09:26 -06:00
} ;
2026-02-19 13:02:14 -06:00
type JournalMigrationEntry = {
fileName : string ;
folderMillis : number ;
} ;
async function listJournalMigrationEntries ( ) : Promise < JournalMigrationEntry [ ] > {
2026-02-19 09:09:26 -06:00
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
2026-02-19 13:02:14 -06:00
. map ( ( entry ) = > {
if ( typeof entry ? . tag !== "string" ) return null ;
if ( typeof entry ? . when !== "number" || ! Number . isFinite ( entry . when ) ) return null ;
return { fileName : ` ${ entry . tag } .sql ` , folderMillis : entry.when } ;
} )
. filter ( ( entry ) : entry is JournalMigrationEntry = > entry !== null ) ;
2026-02-19 09:09:26 -06:00
} catch {
return [ ] ;
}
}
2026-02-19 13:02:14 -06:00
async function listJournalMigrationFiles ( ) : Promise < string [ ] > {
const entries = await listJournalMigrationEntries ( ) ;
return entries . map ( ( entry ) = > entry . fileName ) ;
}
async function readMigrationFileContent ( migrationFile : string ) : Promise < string > {
return readFile ( new URL ( ` ./migrations/ ${ migrationFile } ` , import . meta . url ) , "utf8" ) ;
}
async function mapHashesToMigrationFiles ( migrationFiles : string [ ] ) : Promise < Map < string , string > > {
const mapped = new Map < string , string > ( ) ;
await Promise . all (
migrationFiles . map ( async ( migrationFile ) = > {
const content = await readMigrationFileContent ( migrationFile ) ;
const hash = createHash ( "sha256" ) . update ( content ) . digest ( "hex" ) ;
mapped . set ( hash , migrationFile ) ;
} ) ,
) ;
return mapped ;
}
async function getMigrationTableColumnNames (
sql : ReturnType < typeof postgres > ,
migrationTableSchema : string ,
) : Promise < Set < string > > {
const columns = await sql . unsafe < { column_name : string } [ ] > (
`
SELECT column_name
FROM information_schema . columns
WHERE table_schema = $ { quoteLiteral ( migrationTableSchema ) }
AND table_name = $ { quoteLiteral ( DRIZZLE_MIGRATIONS_TABLE ) }
` ,
) ;
return new Set ( columns . map ( ( column ) = > column . column_name ) ) ;
}
async function tableExists (
sql : ReturnType < typeof postgres > ,
tableName : string ,
) : Promise < boolean > {
const rows = await sql < { exists : boolean } [ ] > `
SELECT EXISTS (
SELECT 1
FROM information_schema . tables
WHERE table_schema = 'public'
AND table_name = $ { tableName }
) AS exists
` ;
return rows [ 0 ] ? . exists ? ? false ;
}
async function columnExists (
sql : ReturnType < typeof postgres > ,
tableName : string ,
columnName : string ,
) : Promise < boolean > {
const rows = await sql < { exists : boolean } [ ] > `
SELECT EXISTS (
SELECT 1
FROM information_schema . columns
WHERE table_schema = 'public'
AND table_name = $ { tableName }
AND column_name = $ { columnName }
) AS exists
` ;
return rows [ 0 ] ? . exists ? ? false ;
}
async function indexExists (
sql : ReturnType < typeof postgres > ,
indexName : string ,
) : Promise < boolean > {
const rows = await sql < { exists : boolean } [ ] > `
SELECT EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n . oid = c . relnamespace
WHERE n . nspname = 'public'
AND c . relkind = 'i'
AND c . relname = $ { indexName }
) AS exists
` ;
return rows [ 0 ] ? . exists ? ? false ;
}
async function constraintExists (
sql : ReturnType < typeof postgres > ,
constraintName : string ,
) : Promise < boolean > {
const rows = await sql < { exists : boolean } [ ] > `
SELECT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_namespace n ON n . oid = c . connamespace
WHERE n . nspname = 'public'
AND c . conname = $ { constraintName }
) AS exists
` ;
return rows [ 0 ] ? . exists ? ? false ;
}
async function migrationStatementAlreadyApplied (
sql : ReturnType < typeof postgres > ,
statement : string ,
) : Promise < boolean > {
const normalized = statement . replace ( /\s+/g , " " ) . trim ( ) ;
const createTableMatch = normalized . match ( /^CREATE TABLE(?: IF NOT EXISTS)? "([^"]+)"/i ) ;
if ( createTableMatch ) {
return tableExists ( sql , createTableMatch [ 1 ] ) ;
}
const addColumnMatch = normalized . match (
/^ALTER TABLE "([^"]+)" ADD COLUMN(?: IF NOT EXISTS)? "([^"]+)"/i ,
) ;
if ( addColumnMatch ) {
return columnExists ( sql , addColumnMatch [ 1 ] , addColumnMatch [ 2 ] ) ;
}
const createIndexMatch = normalized . match ( /^CREATE (?:UNIQUE )?INDEX(?: IF NOT EXISTS)? "([^"]+)"/i ) ;
if ( createIndexMatch ) {
return indexExists ( sql , createIndexMatch [ 1 ] ) ;
}
const addConstraintMatch = normalized . match ( /^ALTER TABLE "([^"]+)" ADD CONSTRAINT "([^"]+)"/i ) ;
if ( addConstraintMatch ) {
return constraintExists ( sql , addConstraintMatch [ 2 ] ) ;
}
// If we cannot reason about a statement safely, require manual migration.
return false ;
}
async function migrationContentAlreadyApplied (
sql : ReturnType < typeof postgres > ,
migrationContent : string ,
) : Promise < boolean > {
const statements = splitMigrationStatements ( migrationContent ) ;
if ( statements . length === 0 ) return false ;
for ( const statement of statements ) {
const applied = await migrationStatementAlreadyApplied ( sql , statement ) ;
if ( ! applied ) return false ;
}
return true ;
}
2026-02-19 09:09:26 -06:00
async function loadAppliedMigrations (
sql : ReturnType < typeof postgres > ,
migrationTableSchema : string ,
availableMigrations : string [ ] ,
) : Promise < string [ ] > {
2026-02-19 13:02:14 -06:00
const quotedSchema = quoteIdentifier ( migrationTableSchema ) ;
const qualifiedTable = ` ${ quotedSchema } . ${ quoteIdentifier ( DRIZZLE_MIGRATIONS_TABLE ) } ` ;
const columnNames = await getMigrationTableColumnNames ( sql , migrationTableSchema ) ;
if ( columnNames . has ( "name" ) ) {
2026-02-19 09:09:26 -06:00
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 ) ) ;
2026-02-19 13:02:14 -06:00
}
if ( columnNames . has ( "hash" ) && columnNames . has ( "created_at" ) ) {
const journalEntries = await listJournalMigrationEntries ( ) ;
if ( journalEntries . length > 0 ) {
const lastDbRows = await sql . unsafe < { created_at : string | number | null } [ ] > (
` SELECT created_at FROM ${ qualifiedTable } ORDER BY created_at DESC LIMIT 1 ` ,
) ;
const lastCreatedAt = Number ( lastDbRows [ 0 ] ? . created_at ? ? - 1 ) ;
if ( Number . isFinite ( lastCreatedAt ) && lastCreatedAt >= 0 ) {
return journalEntries
. filter ( ( entry ) = > availableMigrations . includes ( entry . fileName ) )
. filter ( ( entry ) = > entry . folderMillis <= lastCreatedAt )
. map ( ( entry ) = > entry . fileName ) ;
}
return [ ] ;
2026-02-19 09:09:26 -06:00
}
}
2026-02-19 13:02:14 -06:00
if ( columnNames . has ( "hash" ) ) {
const rows = await sql . unsafe < { hash : string } [ ] > ( ` SELECT hash FROM ${ qualifiedTable } ORDER BY id ` ) ;
const hashesToMigrationFiles = await mapHashesToMigrationFiles ( availableMigrations ) ;
const appliedFromHashes = rows
. map ( ( row ) = > hashesToMigrationFiles . get ( row . hash ) )
. filter ( ( name ) : name is string = > Boolean ( name ) ) ;
if ( appliedFromHashes . length > 0 ) return appliedFromHashes ;
}
2026-02-19 09:09:26 -06:00
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 ) ) ;
}
2026-02-19 13:02:14 -06:00
export type MigrationHistoryReconcileResult = {
repairedMigrations : string [ ] ;
remainingMigrations : string [ ] ;
} ;
export async function reconcilePendingMigrationHistory (
url : string ,
) : Promise < MigrationHistoryReconcileResult > {
const state = await inspectMigrations ( url ) ;
if ( state . status !== "needsMigrations" || state . reason !== "pending-migrations" ) {
return { repairedMigrations : [ ] , remainingMigrations : [ ] } ;
}
const sql = postgres ( url , { max : 1 } ) ;
const repairedMigrations : string [ ] = [ ] ;
try {
const journalEntries = await listJournalMigrationEntries ( ) ;
const folderMillisByFile = new Map ( journalEntries . map ( ( entry ) = > [ entry . fileName , entry . folderMillis ] ) ) ;
const migrationTableSchema = await discoverMigrationTableSchema ( sql ) ;
if ( ! migrationTableSchema ) {
return { repairedMigrations , remainingMigrations : state.pendingMigrations } ;
}
const columnNames = await getMigrationTableColumnNames ( sql , migrationTableSchema ) ;
const qualifiedTable = ` ${ quoteIdentifier ( migrationTableSchema ) } . ${ quoteIdentifier ( DRIZZLE_MIGRATIONS_TABLE ) } ` ;
for ( const migrationFile of state . pendingMigrations ) {
const migrationContent = await readMigrationFileContent ( migrationFile ) ;
const alreadyApplied = await migrationContentAlreadyApplied ( sql , migrationContent ) ;
if ( ! alreadyApplied ) break ;
const hash = createHash ( "sha256" ) . update ( migrationContent ) . digest ( "hex" ) ;
const folderMillis = folderMillisByFile . get ( migrationFile ) ? ? Date . now ( ) ;
const existingByHash = columnNames . has ( "hash" )
? await sql . unsafe < { created_at : string | number | null } [ ] > (
` SELECT created_at FROM ${ qualifiedTable } WHERE hash = ${ quoteLiteral ( hash ) } ORDER BY created_at DESC LIMIT 1 ` ,
)
: [ ] ;
const existingByName = columnNames . has ( "name" )
? await sql . unsafe < { created_at : string | number | null } [ ] > (
` SELECT created_at FROM ${ qualifiedTable } WHERE name = ${ quoteLiteral ( migrationFile ) } ORDER BY created_at DESC LIMIT 1 ` ,
)
: [ ] ;
if ( existingByHash . length > 0 || existingByName . length > 0 ) {
if ( columnNames . has ( "created_at" ) ) {
const existingHashCreatedAt = Number ( existingByHash [ 0 ] ? . created_at ? ? - 1 ) ;
if ( existingByHash . length > 0 && Number . isFinite ( existingHashCreatedAt ) && existingHashCreatedAt < folderMillis ) {
await sql . unsafe (
` UPDATE ${ qualifiedTable } SET created_at = ${ quoteLiteral ( String ( folderMillis ) ) } WHERE hash = ${ quoteLiteral ( hash ) } AND created_at < ${ quoteLiteral ( String ( folderMillis ) ) } ` ,
) ;
}
const existingNameCreatedAt = Number ( existingByName [ 0 ] ? . created_at ? ? - 1 ) ;
if ( existingByName . length > 0 && Number . isFinite ( existingNameCreatedAt ) && existingNameCreatedAt < folderMillis ) {
await sql . unsafe (
` UPDATE ${ qualifiedTable } SET created_at = ${ quoteLiteral ( String ( folderMillis ) ) } WHERE name = ${ quoteLiteral ( migrationFile ) } AND created_at < ${ quoteLiteral ( String ( folderMillis ) ) } ` ,
) ;
}
}
repairedMigrations . push ( migrationFile ) ;
continue ;
}
const insertColumns : string [ ] = [ ] ;
const insertValues : string [ ] = [ ] ;
if ( columnNames . has ( "hash" ) ) {
insertColumns . push ( quoteIdentifier ( "hash" ) ) ;
insertValues . push ( quoteLiteral ( hash ) ) ;
}
if ( columnNames . has ( "name" ) ) {
insertColumns . push ( quoteIdentifier ( "name" ) ) ;
insertValues . push ( quoteLiteral ( migrationFile ) ) ;
}
if ( columnNames . has ( "created_at" ) ) {
insertColumns . push ( quoteIdentifier ( "created_at" ) ) ;
insertValues . push ( quoteLiteral ( String ( folderMillis ) ) ) ;
}
if ( insertColumns . length === 0 ) break ;
await sql . unsafe (
` INSERT INTO ${ qualifiedTable } ( ${ insertColumns . join ( ", " ) } ) VALUES ( ${ insertValues . join ( ", " ) } ) ` ,
) ;
repairedMigrations . push ( migrationFile ) ;
}
} finally {
await sql . end ( ) ;
}
const refreshed = await inspectMigrations ( url ) ;
return {
repairedMigrations ,
remainingMigrations :
refreshed . status === "needsMigrations" ? refreshed . pendingMigrations : [ ] ,
} ;
}
2026-02-19 09:09:26 -06:00
async function discoverMigrationTableSchema ( sql : ReturnType < typeof postgres > ) : Promise < string | null > {
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 < MigrationState > {
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 < void > {
const sql = postgres ( url , { max : 1 } ) ;
try {
const db = drizzlePg ( sql ) ;
await migratePg ( db , { migrationsFolder : MIGRATIONS_FOLDER } ) ;
} finally {
await sql . end ( ) ;
}
}
2026-02-18 11:45:43 -06:00
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 } ;
2026-02-16 19:07:37 -06:00
2026-02-18 11:45:43 -06:00
export async function migratePostgresIfEmpty ( url : string ) : Promise < MigrationBootstrapResult > {
const sql = postgres ( url , { max : 1 } ) ;
2026-02-16 19:07:37 -06:00
2026-02-18 11:45:43 -06:00
try {
2026-02-19 09:09:26 -06:00
const migrationTableSchema = await discoverMigrationTableSchema ( sql ) ;
2026-02-18 11:45:43 -06:00
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 ;
2026-02-19 09:09:26 -06:00
if ( migrationTableSchema ) {
2026-02-18 11:45:43 -06:00
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 ( ) ;
}
2026-02-16 13:31:52 -06:00
}
export type Db = ReturnType < typeof createDb > ;