2026-04-06 08:35:59 -05:00
import { createReadStream , createWriteStream , existsSync , mkdirSync , readdirSync , statSync , unlinkSync } from "node:fs" ;
2026-03-10 07:41:01 -05:00
import { basename , resolve } from "node:path" ;
2026-04-06 08:35:59 -05:00
import { createInterface } from "node:readline" ;
2026-04-07 09:41:13 +02:00
import { pipeline } from "node:stream/promises" ;
import { createGunzip , createGzip } from "node:zlib" ;
2026-03-04 18:03:23 -06:00
import postgres from "postgres" ;
2026-04-07 09:54:39 +02:00
export type BackupRetentionPolicy = {
dailyDays : number ;
weeklyWeeks : number ;
monthlyMonths : number ;
} ;
2026-03-04 18:03:23 -06:00
export type RunDatabaseBackupOptions = {
connectionString : string ;
backupDir : string ;
2026-04-07 09:54:39 +02:00
retention : BackupRetentionPolicy ;
2026-03-04 18:03:23 -06:00
filenamePrefix? : string ;
connectTimeoutSeconds? : number ;
2026-03-10 10:08:13 -05:00
includeMigrationJournal? : boolean ;
2026-03-10 07:41:01 -05:00
excludeTables? : string [ ] ;
nullifyColumns? : Record < string , string [ ] > ;
2026-03-04 18:03:23 -06:00
} ;
export type RunDatabaseBackupResult = {
backupFile : string ;
sizeBytes : number ;
prunedCount : number ;
} ;
2026-03-10 10:08:13 -05:00
export type RunDatabaseRestoreOptions = {
connectionString : string ;
backupFile : string ;
connectTimeoutSeconds? : number ;
} ;
2026-03-10 07:41:01 -05:00
type SequenceDefinition = {
2026-03-10 10:08:58 -05:00
sequence_schema : string ;
2026-03-10 07:41:01 -05:00
sequence_name : string ;
data_type : string ;
start_value : string ;
minimum_value : string ;
maximum_value : string ;
increment : string ;
cycle_option : "YES" | "NO" ;
2026-03-10 10:08:58 -05:00
owner_schema : string | null ;
2026-03-10 07:41:01 -05:00
owner_table : string | null ;
owner_column : string | null ;
} ;
2026-03-10 10:08:58 -05:00
type TableDefinition = {
schema_name : string ;
tablename : string ;
} ;
2026-04-06 20:30:50 -05:00
type ExtensionDefinition = {
extension_name : string ;
schema_name : string ;
} ;
2026-03-10 10:08:58 -05:00
const DRIZZLE_SCHEMA = "drizzle" ;
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations" ;
2026-04-02 11:51:40 -05:00
const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024 ;
2026-03-10 10:08:58 -05:00
2026-03-10 07:41:01 -05:00
const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900" ;
function sanitizeRestoreErrorMessage ( error : unknown ) : string {
if ( error && typeof error === "object" ) {
const record = error as Record < string , unknown > ;
const firstLine = typeof record . message === "string"
? record . message . split ( /\r?\n/ , 1 ) [ 0 ] ? . trim ( )
: "" ;
const detail = typeof record . detail === "string" ? record . detail . trim ( ) : "" ;
const severity = typeof record . severity === "string" ? record . severity . trim ( ) : "" ;
const message = firstLine || detail || ( error instanceof Error ? error.message : String ( error ) ) ;
return severity ? ` ${ severity } : ${ message } ` : message ;
}
return error instanceof Error ? error.message : String ( error ) ;
}
2026-03-04 18:03:23 -06:00
function timestamp ( date : Date = new Date ( ) ) : string {
const pad = ( n : number ) = > String ( n ) . padStart ( 2 , "0" ) ;
return ` ${ date . getFullYear ( ) } ${ pad ( date . getMonth ( ) + 1 ) } ${ pad ( date . getDate ( ) ) } - ${ pad ( date . getHours ( ) ) } ${ pad ( date . getMinutes ( ) ) } ${ pad ( date . getSeconds ( ) ) } ` ;
}
2026-04-07 09:54:39 +02:00
/ * *
* ISO week key for grouping backups by calendar week ( ISO 8601 ) .
* /
function isoWeekKey ( date : Date ) : string {
const d = new Date ( Date . UTC ( date . getFullYear ( ) , date . getMonth ( ) , date . getDate ( ) ) ) ;
d . setUTCDate ( d . getUTCDate ( ) + 4 - ( d . getUTCDay ( ) || 7 ) ) ;
const yearStart = new Date ( Date . UTC ( d . getUTCFullYear ( ) , 0 , 1 ) ) ;
const weekNo = Math . ceil ( ( ( d . getTime ( ) - yearStart . getTime ( ) ) / 86400000 + 1 ) / 7 ) ;
return ` ${ d . getUTCFullYear ( ) } -W ${ String ( weekNo ) . padStart ( 2 , "0" ) } ` ;
}
function monthKey ( date : Date ) : string {
return ` ${ date . getFullYear ( ) } - ${ String ( date . getMonth ( ) + 1 ) . padStart ( 2 , "0" ) } ` ;
}
/ * *
* Tiered backup pruning :
* - Daily tier : keep ALL backups from the last ` dailyDays ` days
* - Weekly tier : keep the NEWEST backup per calendar week for ` weeklyWeeks ` weeks
* - Monthly tier : keep the NEWEST backup per calendar month for ` monthlyMonths ` months
* - Everything else is deleted
* /
function pruneOldBackups ( backupDir : string , retention : BackupRetentionPolicy , filenamePrefix : string ) : number {
2026-03-04 18:03:23 -06:00
if ( ! existsSync ( backupDir ) ) return 0 ;
2026-04-07 09:54:39 +02:00
const now = Date . now ( ) ;
const dailyCutoff = now - Math . max ( 1 , retention . dailyDays ) * 24 * 60 * 60 * 1000 ;
const weeklyCutoff = now - Math . max ( 1 , retention . weeklyWeeks ) * 7 * 24 * 60 * 60 * 1000 ;
const monthlyCutoff = now - Math . max ( 1 , retention . monthlyMonths ) * 30 * 24 * 60 * 60 * 1000 ;
type BackupEntry = { name : string ; fullPath : string ; mtimeMs : number } ;
const entries : BackupEntry [ ] = [ ] ;
2026-03-04 18:03:23 -06:00
for ( const name of readdirSync ( backupDir ) ) {
2026-04-07 09:41:13 +02:00
if ( ! name . startsWith ( ` ${ filenamePrefix } - ` ) ) continue ;
if ( ! name . endsWith ( ".sql" ) && ! name . endsWith ( ".sql.gz" ) ) continue ;
2026-03-04 18:03:23 -06:00
const fullPath = resolve ( backupDir , name ) ;
const stat = statSync ( fullPath ) ;
2026-04-07 09:54:39 +02:00
entries . push ( { name , fullPath , mtimeMs : stat.mtimeMs } ) ;
}
// Sort newest first so the first entry per week/month bucket is the one we keep
entries . sort ( ( a , b ) = > b . mtimeMs - a . mtimeMs ) ;
const keepWeekBuckets = new Set < string > ( ) ;
const keepMonthBuckets = new Set < string > ( ) ;
const toDelete : string [ ] = [ ] ;
for ( const entry of entries ) {
// Daily tier — keep everything within dailyDays
if ( entry . mtimeMs >= dailyCutoff ) continue ;
const date = new Date ( entry . mtimeMs ) ;
const week = isoWeekKey ( date ) ;
const month = monthKey ( date ) ;
// Weekly tier — keep newest per calendar week
if ( entry . mtimeMs >= weeklyCutoff ) {
if ( keepWeekBuckets . has ( week ) ) {
toDelete . push ( entry . fullPath ) ;
} else {
keepWeekBuckets . add ( week ) ;
}
continue ;
}
// Monthly tier — keep newest per calendar month
if ( entry . mtimeMs >= monthlyCutoff ) {
if ( keepMonthBuckets . has ( month ) ) {
toDelete . push ( entry . fullPath ) ;
} else {
keepMonthBuckets . add ( month ) ;
}
continue ;
2026-03-04 18:03:23 -06:00
}
2026-04-07 09:54:39 +02:00
// Beyond all retention tiers — delete
toDelete . push ( entry . fullPath ) ;
}
for ( const filePath of toDelete ) {
unlinkSync ( filePath ) ;
2026-03-04 18:03:23 -06:00
}
2026-04-07 09:54:39 +02:00
return toDelete . length ;
2026-03-04 18:03:23 -06:00
}
function formatBackupSize ( sizeBytes : number ) : string {
if ( sizeBytes < 1024 ) return ` ${ sizeBytes } B ` ;
if ( sizeBytes < 1024 * 1024 ) return ` ${ ( sizeBytes / 1024 ) . toFixed ( 1 ) } K ` ;
return ` ${ ( sizeBytes / ( 1024 * 1024 ) ) . toFixed ( 1 ) } M ` ;
}
2026-03-10 07:41:01 -05:00
function formatSqlLiteral ( value : string ) : string {
const sanitized = value . replace ( /\u0000/g , "" ) ;
let tag = "$paperclip$" ;
while ( sanitized . includes ( tag ) ) {
tag = ` $ paperclip_ ${ Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } $ ` ;
}
return ` ${ tag } ${ sanitized } ${ tag } ` ;
}
function normalizeTableNameSet ( values : string [ ] | undefined ) : Set < string > {
return new Set (
( values ? ? [ ] )
. map ( ( value ) = > value . trim ( ) )
. filter ( ( value ) = > value . length > 0 ) ,
) ;
}
function normalizeNullifyColumnMap ( values : Record < string , string [ ] > | undefined ) : Map < string , Set < string > > {
const out = new Map < string , Set < string > > ( ) ;
if ( ! values ) return out ;
for ( const [ tableName , columns ] of Object . entries ( values ) ) {
const normalizedTable = tableName . trim ( ) ;
if ( normalizedTable . length === 0 ) continue ;
const normalizedColumns = new Set (
columns
. map ( ( column ) = > column . trim ( ) )
. filter ( ( column ) = > column . length > 0 ) ,
) ;
if ( normalizedColumns . size > 0 ) {
out . set ( normalizedTable , normalizedColumns ) ;
}
}
return out ;
}
2026-03-10 10:08:58 -05:00
function quoteIdentifier ( value : string ) : string {
return ` " ${ value . replaceAll ( "\"" , "\"\"" ) } " ` ;
}
function quoteQualifiedName ( schemaName : string , objectName : string ) : string {
return ` ${ quoteIdentifier ( schemaName ) } . ${ quoteIdentifier ( objectName ) } ` ;
}
function tableKey ( schemaName : string , tableName : string ) : string {
return ` ${ schemaName } . ${ tableName } ` ;
}
2026-04-06 08:35:59 -05:00
async function * readRestoreStatements ( backupFile : string ) : AsyncGenerator < string > {
2026-04-07 09:41:13 +02:00
const raw = createReadStream ( backupFile ) ;
const stream = backupFile . endsWith ( ".gz" ) ? raw . pipe ( createGunzip ( ) ) : raw ;
stream . setEncoding ( "utf8" ) ;
2026-04-06 08:35:59 -05:00
const reader = createInterface ( {
input : stream ,
crlfDelay : Infinity ,
} ) ;
let statementLines : string [ ] = [ ] ;
const flushStatement = ( ) = > {
const statement = statementLines . join ( "\n" ) . trim ( ) ;
statementLines = [ ] ;
return statement ;
} ;
try {
for await ( const line of reader ) {
if ( line === STATEMENT_BREAKPOINT ) {
const statement = flushStatement ( ) ;
if ( statement . length > 0 ) {
yield statement ;
}
continue ;
}
statementLines . push ( line ) ;
}
const trailingStatement = flushStatement ( ) ;
if ( trailingStatement . length > 0 ) {
yield trailingStatement ;
}
} finally {
reader . close ( ) ;
stream . destroy ( ) ;
2026-04-07 09:41:13 +02:00
raw . destroy ( ) ;
2026-04-06 08:35:59 -05:00
}
}
2026-04-02 11:51:40 -05:00
export function createBufferedTextFileWriter ( filePath : string , maxBufferedBytes = DEFAULT_BACKUP_WRITE_BUFFER_BYTES ) {
const stream = createWriteStream ( filePath , { encoding : "utf8" } ) ;
const flushThreshold = Math . max ( 1 , Math . trunc ( maxBufferedBytes ) ) ;
let bufferedLines : string [ ] = [ ] ;
let bufferedBytes = 0 ;
let firstChunk = true ;
let closed = false ;
let streamError : Error | null = null ;
let pendingWrite = Promise . resolve ( ) ;
stream . on ( "error" , ( error ) = > {
streamError = error ;
} ) ;
const writeChunk = async ( chunk : string ) : Promise < void > = > {
if ( streamError ) throw streamError ;
const canContinue = stream . write ( chunk ) ;
if ( ! canContinue ) {
await new Promise < void > ( ( resolve , reject ) = > {
const handleDrain = ( ) = > {
cleanup ( ) ;
resolve ( ) ;
} ;
const handleError = ( error : Error ) = > {
cleanup ( ) ;
reject ( error ) ;
} ;
const cleanup = ( ) = > {
stream . off ( "drain" , handleDrain ) ;
stream . off ( "error" , handleError ) ;
} ;
stream . once ( "drain" , handleDrain ) ;
stream . once ( "error" , handleError ) ;
} ) ;
}
if ( streamError ) throw streamError ;
} ;
const flushBufferedLines = ( ) = > {
if ( bufferedLines . length === 0 ) return ;
const linesToWrite = bufferedLines ;
bufferedLines = [ ] ;
bufferedBytes = 0 ;
const chunkBody = linesToWrite . join ( "\n" ) ;
const chunk = firstChunk ? chunkBody : ` \ n ${ chunkBody } ` ;
firstChunk = false ;
pendingWrite = pendingWrite . then ( ( ) = > writeChunk ( chunk ) ) ;
} ;
return {
emit ( line : string ) {
if ( closed ) {
throw new Error ( ` Cannot write to closed backup file: ${ filePath } ` ) ;
}
if ( streamError ) throw streamError ;
bufferedLines . push ( line ) ;
bufferedBytes += Buffer . byteLength ( line , "utf8" ) + 1 ;
if ( bufferedBytes >= flushThreshold ) {
flushBufferedLines ( ) ;
}
} ,
async close() {
if ( closed ) return ;
closed = true ;
flushBufferedLines ( ) ;
await pendingWrite ;
await new Promise < void > ( ( resolve , reject ) = > {
if ( streamError ) {
reject ( streamError ) ;
return ;
}
stream . end ( ( error? : Error | null ) = > {
if ( error ) reject ( error ) ;
else resolve ( ) ;
} ) ;
} ) ;
if ( streamError ) throw streamError ;
} ,
async abort() {
if ( closed ) return ;
closed = true ;
bufferedLines = [ ] ;
bufferedBytes = 0 ;
stream . destroy ( ) ;
await pendingWrite . catch ( ( ) = > { } ) ;
if ( existsSync ( filePath ) ) {
2026-04-02 12:21:35 -05:00
try {
unlinkSync ( filePath ) ;
} catch {
// Preserve the original backup failure if temporary file cleanup also fails.
}
2026-04-02 11:51:40 -05:00
}
} ,
} ;
}
2026-03-04 18:03:23 -06:00
export async function runDatabaseBackup ( opts : RunDatabaseBackupOptions ) : Promise < RunDatabaseBackupResult > {
const filenamePrefix = opts . filenamePrefix ? ? "paperclip" ;
2026-04-07 09:54:39 +02:00
const retention = opts . retention ;
2026-03-04 18:03:23 -06:00
const connectTimeout = Math . max ( 1 , Math . trunc ( opts . connectTimeoutSeconds ? ? 5 ) ) ;
2026-03-10 10:08:13 -05:00
const includeMigrationJournal = opts . includeMigrationJournal === true ;
2026-03-10 07:41:01 -05:00
const excludedTableNames = normalizeTableNameSet ( opts . excludeTables ) ;
const nullifiedColumnsByTable = normalizeNullifyColumnMap ( opts . nullifyColumns ) ;
2026-03-04 18:03:23 -06:00
const sql = postgres ( opts . connectionString , { max : 1 , connect_timeout : connectTimeout } ) ;
2026-04-02 11:51:40 -05:00
mkdirSync ( opts . backupDir , { recursive : true } ) ;
2026-04-07 09:41:13 +02:00
const sqlFile = resolve ( opts . backupDir , ` ${ filenamePrefix } - ${ timestamp ( ) } .sql ` ) ;
const backupFile = ` ${ sqlFile } .gz ` ;
const writer = createBufferedTextFileWriter ( sqlFile ) ;
2026-03-04 18:03:23 -06:00
try {
await sql ` SELECT 1 ` ;
2026-04-02 11:51:40 -05:00
const emit = ( line : string ) = > writer . emit ( line ) ;
2026-03-10 07:41:01 -05:00
const emitStatement = ( statement : string ) = > {
emit ( statement ) ;
emit ( STATEMENT_BREAKPOINT ) ;
} ;
const emitStatementBoundary = ( ) = > {
emit ( STATEMENT_BREAKPOINT ) ;
} ;
2026-03-04 18:03:23 -06:00
emit ( "-- Paperclip database backup" ) ;
emit ( ` -- Created: ${ new Date ( ) . toISOString ( ) } ` ) ;
emit ( "" ) ;
2026-03-10 07:41:01 -05:00
emitStatement ( "BEGIN;" ) ;
emitStatement ( "SET LOCAL session_replication_role = replica;" ) ;
emitStatement ( "SET LOCAL client_min_messages = warning;" ) ;
2026-03-04 18:03:23 -06:00
emit ( "" ) ;
2026-03-10 10:08:58 -05:00
const allTables = await sql < TableDefinition [ ] > `
SELECT table_schema AS schema_name , table_name AS tablename
FROM information_schema . tables
WHERE table_type = 'BASE TABLE'
AND (
table_schema = 'public'
OR ( $ { includeMigrationJournal } : : boolean AND table_schema = $ { DRIZZLE_SCHEMA } AND table_name = $ { DRIZZLE_MIGRATIONS_TABLE } )
)
ORDER BY table_schema , table_name
2026-03-10 07:41:01 -05:00
` ;
2026-03-10 10:08:58 -05:00
const tables = allTables ;
const includedTableNames = new Set ( tables . map ( ( { schema_name , tablename } ) = > tableKey ( schema_name , tablename ) ) ) ;
2026-03-10 07:41:01 -05:00
2026-03-04 18:03:23 -06:00
// Get all enums
const enums = await sql < { typname : string ; labels : string [ ] } [ ] > `
SELECT t . typname , array_agg ( e . enumlabel ORDER BY e . enumsortorder ) AS labels
FROM pg_type t
JOIN pg_enum e ON t . oid = e . enumtypid
JOIN pg_namespace n ON t . typnamespace = n . oid
WHERE n . nspname = 'public'
GROUP BY t . typname
ORDER BY t . typname
` ;
for ( const e of enums ) {
const labels = e . labels . map ( ( l ) = > ` ' ${ l . replace ( /'/g , "''" ) } ' ` ) . join ( ", " ) ;
2026-03-10 07:41:01 -05:00
emitStatement ( ` CREATE TYPE "public"." ${ e . typname } " AS ENUM ( ${ labels } ); ` ) ;
2026-03-04 18:03:23 -06:00
}
if ( enums . length > 0 ) emit ( "" ) ;
2026-03-10 07:41:01 -05:00
const allSequences = await sql < SequenceDefinition [ ] > `
SELECT
2026-03-10 10:08:58 -05:00
s . sequence_schema ,
2026-03-10 07:41:01 -05:00
s . sequence_name ,
s . data_type ,
s . start_value ,
s . minimum_value ,
s . maximum_value ,
s . increment ,
s . cycle_option ,
2026-03-10 10:08:58 -05:00
tblns . nspname AS owner_schema ,
2026-03-10 07:41:01 -05:00
tbl . relname AS owner_table ,
attr . attname AS owner_column
FROM information_schema . sequences s
JOIN pg_class seq ON seq . relname = s . sequence_name
JOIN pg_namespace n ON n . oid = seq . relnamespace AND n . nspname = s . sequence_schema
LEFT JOIN pg_depend dep ON dep . objid = seq . oid AND dep . deptype = 'a'
LEFT JOIN pg_class tbl ON tbl . oid = dep . refobjid
2026-03-10 10:08:58 -05:00
LEFT JOIN pg_namespace tblns ON tblns . oid = tbl . relnamespace
2026-03-10 07:41:01 -05:00
LEFT JOIN pg_attribute attr ON attr . attrelid = tbl . oid AND attr . attnum = dep . refobjsubid
WHERE s . sequence_schema = 'public'
2026-03-10 10:08:58 -05:00
OR ( $ { includeMigrationJournal } : : boolean AND s . sequence_schema = $ { DRIZZLE_SCHEMA } )
ORDER BY s . sequence_schema , s . sequence_name
2026-03-04 18:03:23 -06:00
` ;
2026-03-10 10:08:58 -05:00
const sequences = allSequences . filter (
( seq ) = > ! seq . owner_table || includedTableNames . has ( tableKey ( seq . owner_schema ? ? "public" , seq . owner_table ) ) ,
) ;
const schemas = new Set < string > ( ) ;
for ( const table of tables ) schemas . add ( table . schema_name ) ;
for ( const seq of sequences ) schemas . add ( seq . sequence_schema ) ;
const extraSchemas = [ . . . schemas ] . filter ( ( schemaName ) = > schemaName !== "public" ) ;
if ( extraSchemas . length > 0 ) {
emit ( "-- Schemas" ) ;
for ( const schemaName of extraSchemas ) {
emitStatement ( ` CREATE SCHEMA IF NOT EXISTS ${ quoteIdentifier ( schemaName ) } ; ` ) ;
}
emit ( "" ) ;
}
2026-03-10 07:41:01 -05:00
2026-04-06 20:30:50 -05:00
const extensions = await sql < ExtensionDefinition [ ] > `
SELECT
e . extname AS extension_name ,
n . nspname AS schema_name
FROM pg_extension e
JOIN pg_namespace n ON n . oid = e . extnamespace
WHERE e . extname < > 'plpgsql'
ORDER BY e . extname
` ;
if ( extensions . length > 0 ) {
emit ( "-- Extensions" ) ;
for ( const extension of extensions ) {
emitStatement (
` CREATE EXTENSION IF NOT EXISTS ${ quoteIdentifier ( extension . extension_name ) } WITH SCHEMA ${ quoteIdentifier ( extension . schema_name ) } ; ` ,
) ;
}
emit ( "" ) ;
}
2026-03-10 07:41:01 -05:00
if ( sequences . length > 0 ) {
emit ( "-- Sequences" ) ;
for ( const seq of sequences ) {
2026-03-10 10:08:58 -05:00
const qualifiedSequenceName = quoteQualifiedName ( seq . sequence_schema , seq . sequence_name ) ;
emitStatement ( ` DROP SEQUENCE IF EXISTS ${ qualifiedSequenceName } CASCADE; ` ) ;
2026-03-10 07:41:01 -05:00
emitStatement (
2026-03-10 10:08:58 -05:00
` CREATE SEQUENCE ${ qualifiedSequenceName } AS ${ seq . data_type } INCREMENT BY ${ seq . increment } MINVALUE ${ seq . minimum_value } MAXVALUE ${ seq . maximum_value } START WITH ${ seq . start_value } ${ seq . cycle_option === "YES" ? " CYCLE" : " NO CYCLE" } ; ` ,
2026-03-10 07:41:01 -05:00
) ;
}
emit ( "" ) ;
}
2026-03-04 18:03:23 -06:00
// Get full CREATE TABLE DDL via column info
2026-03-10 10:08:58 -05:00
for ( const { schema_name , tablename } of tables ) {
const qualifiedTableName = quoteQualifiedName ( schema_name , tablename ) ;
2026-03-04 18:03:23 -06:00
const columns = await sql < {
column_name : string ;
data_type : string ;
udt_name : string ;
is_nullable : string ;
column_default : string | null ;
character_maximum_length : number | null ;
numeric_precision : number | null ;
numeric_scale : number | null ;
} [ ] > `
SELECT column_name , data_type , udt_name , is_nullable , column_default ,
character_maximum_length , numeric_precision , numeric_scale
FROM information_schema . columns
2026-03-10 10:08:58 -05:00
WHERE table_schema = $ { schema_name } AND table_name = $ { tablename }
2026-03-04 18:03:23 -06:00
ORDER BY ordinal_position
` ;
2026-03-10 10:08:58 -05:00
emit ( ` -- Table: ${ schema_name } . ${ tablename } ` ) ;
emitStatement ( ` DROP TABLE IF EXISTS ${ qualifiedTableName } CASCADE; ` ) ;
2026-03-04 18:03:23 -06:00
const colDefs : string [ ] = [ ] ;
for ( const col of columns ) {
let typeStr : string ;
if ( col . data_type === "USER-DEFINED" ) {
typeStr = ` " ${ col . udt_name } " ` ;
} else if ( col . data_type === "ARRAY" ) {
typeStr = ` ${ col . udt_name . replace ( /^_/ , "" ) } [] ` ;
} else if ( col . data_type === "character varying" ) {
typeStr = col . character_maximum_length
? ` varchar( ${ col . character_maximum_length } ) `
: "varchar" ;
} else if ( col . data_type === "numeric" && col . numeric_precision != null ) {
typeStr =
col . numeric_scale != null
? ` numeric( ${ col . numeric_precision } , ${ col . numeric_scale } ) `
: ` numeric( ${ col . numeric_precision } ) ` ;
} else {
typeStr = col . data_type ;
}
let def = ` " ${ col . column_name } " ${ typeStr } ` ;
if ( col . column_default != null ) def += ` DEFAULT ${ col . column_default } ` ;
if ( col . is_nullable === "NO" ) def += " NOT NULL" ;
colDefs . push ( def ) ;
}
// Primary key
const pk = await sql < { constraint_name : string ; column_names : string [ ] } [ ] > `
SELECT c . conname AS constraint_name ,
array_agg ( a . attname ORDER BY array_position ( c . conkey , a . attnum ) ) AS column_names
FROM pg_constraint c
JOIN pg_class t ON t . oid = c . conrelid
JOIN pg_namespace n ON n . oid = t . relnamespace
JOIN pg_attribute a ON a . attrelid = t . oid AND a . attnum = ANY ( c . conkey )
2026-03-10 10:08:58 -05:00
WHERE n . nspname = $ { schema_name } AND t . relname = $ { tablename } AND c . contype = 'p'
2026-03-04 18:03:23 -06:00
GROUP BY c . conname
` ;
for ( const p of pk ) {
const cols = p . column_names . map ( ( c ) = > ` " ${ c } " ` ) . join ( ", " ) ;
colDefs . push ( ` CONSTRAINT " ${ p . constraint_name } " PRIMARY KEY ( ${ cols } ) ` ) ;
}
2026-03-10 10:08:58 -05:00
emit ( ` CREATE TABLE ${ qualifiedTableName } ( ` ) ;
2026-03-04 18:03:23 -06:00
emit ( colDefs . join ( ",\n" ) ) ;
emit ( ");" ) ;
2026-03-10 07:41:01 -05:00
emitStatementBoundary ( ) ;
emit ( "" ) ;
}
const ownedSequences = sequences . filter ( ( seq ) = > seq . owner_table && seq . owner_column ) ;
if ( ownedSequences . length > 0 ) {
emit ( "-- Sequence ownership" ) ;
for ( const seq of ownedSequences ) {
emitStatement (
2026-03-10 10:08:58 -05:00
` ALTER SEQUENCE ${ quoteQualifiedName ( seq . sequence_schema , seq . sequence_name ) } OWNED BY ${ quoteQualifiedName ( seq . owner_schema ? ? "public" , seq . owner_table ! ) } . ${ quoteIdentifier ( seq . owner_column ! ) } ; ` ,
2026-03-10 07:41:01 -05:00
) ;
}
2026-03-04 18:03:23 -06:00
emit ( "" ) ;
}
// Foreign keys (after all tables created)
2026-03-10 07:41:01 -05:00
const allForeignKeys = await sql < {
2026-03-04 18:03:23 -06:00
constraint_name : string ;
2026-03-10 10:08:58 -05:00
source_schema : string ;
2026-03-04 18:03:23 -06:00
source_table : string ;
source_columns : string [ ] ;
2026-03-10 10:08:58 -05:00
target_schema : string ;
2026-03-04 18:03:23 -06:00
target_table : string ;
target_columns : string [ ] ;
update_rule : string ;
delete_rule : string ;
} [ ] > `
SELECT
c . conname AS constraint_name ,
2026-03-10 10:08:58 -05:00
srcn . nspname AS source_schema ,
2026-03-04 18:03:23 -06:00
src . relname AS source_table ,
array_agg ( sa . attname ORDER BY array_position ( c . conkey , sa . attnum ) ) AS source_columns ,
2026-03-10 10:08:58 -05:00
tgtn . nspname AS target_schema ,
2026-03-04 18:03:23 -06:00
tgt . relname AS target_table ,
array_agg ( ta . attname ORDER BY array_position ( c . confkey , ta . attnum ) ) AS target_columns ,
CASE c . confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule ,
CASE c . confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
FROM pg_constraint c
JOIN pg_class src ON src . oid = c . conrelid
2026-03-10 10:08:58 -05:00
JOIN pg_namespace srcn ON srcn . oid = src . relnamespace
2026-03-04 18:03:23 -06:00
JOIN pg_class tgt ON tgt . oid = c . confrelid
2026-03-10 10:08:58 -05:00
JOIN pg_namespace tgtn ON tgtn . oid = tgt . relnamespace
2026-03-04 18:03:23 -06:00
JOIN pg_attribute sa ON sa . attrelid = src . oid AND sa . attnum = ANY ( c . conkey )
JOIN pg_attribute ta ON ta . attrelid = tgt . oid AND ta . attnum = ANY ( c . confkey )
2026-03-10 10:08:58 -05:00
WHERE c . contype = 'f' AND (
srcn . nspname = 'public'
OR ( $ { includeMigrationJournal } : : boolean AND srcn . nspname = $ { DRIZZLE_SCHEMA } )
)
GROUP BY c . conname , srcn . nspname , src . relname , tgtn . nspname , tgt . relname , c . confupdtype , c . confdeltype
ORDER BY srcn . nspname , src . relname , c . conname
2026-03-04 18:03:23 -06:00
` ;
2026-03-10 07:41:01 -05:00
const fks = allForeignKeys . filter (
2026-03-10 10:08:58 -05:00
( fk ) = > includedTableNames . has ( tableKey ( fk . source_schema , fk . source_table ) )
&& includedTableNames . has ( tableKey ( fk . target_schema , fk . target_table ) ) ,
2026-03-10 07:41:01 -05:00
) ;
2026-03-04 18:03:23 -06:00
if ( fks . length > 0 ) {
emit ( "-- Foreign keys" ) ;
for ( const fk of fks ) {
const srcCols = fk . source_columns . map ( ( c ) = > ` " ${ c } " ` ) . join ( ", " ) ;
const tgtCols = fk . target_columns . map ( ( c ) = > ` " ${ c } " ` ) . join ( ", " ) ;
2026-03-10 07:41:01 -05:00
emitStatement (
2026-03-10 10:08:58 -05:00
` ALTER TABLE ${ quoteQualifiedName ( fk . source_schema , fk . source_table ) } ADD CONSTRAINT " ${ fk . constraint_name } " FOREIGN KEY ( ${ srcCols } ) REFERENCES ${ quoteQualifiedName ( fk . target_schema , fk . target_table ) } ( ${ tgtCols } ) ON UPDATE ${ fk . update_rule } ON DELETE ${ fk . delete_rule } ; ` ,
2026-03-04 18:03:23 -06:00
) ;
}
emit ( "" ) ;
}
// Unique constraints
2026-03-10 07:41:01 -05:00
const allUniqueConstraints = await sql < {
2026-03-04 18:03:23 -06:00
constraint_name : string ;
2026-03-10 10:08:58 -05:00
schema_name : string ;
2026-03-04 18:03:23 -06:00
tablename : string ;
column_names : string [ ] ;
} [ ] > `
SELECT c . conname AS constraint_name ,
2026-03-10 10:08:58 -05:00
n . nspname AS schema_name ,
2026-03-04 18:03:23 -06:00
t . relname AS tablename ,
array_agg ( a . attname ORDER BY array_position ( c . conkey , a . attnum ) ) AS column_names
FROM pg_constraint c
JOIN pg_class t ON t . oid = c . conrelid
JOIN pg_namespace n ON n . oid = t . relnamespace
JOIN pg_attribute a ON a . attrelid = t . oid AND a . attnum = ANY ( c . conkey )
2026-03-10 10:08:58 -05:00
WHERE c . contype = 'u' AND (
n . nspname = 'public'
OR ( $ { includeMigrationJournal } : : boolean AND n . nspname = $ { DRIZZLE_SCHEMA } )
)
GROUP BY c . conname , n . nspname , t . relname
ORDER BY n . nspname , t . relname , c . conname
2026-03-04 18:03:23 -06:00
` ;
2026-03-10 10:08:58 -05:00
const uniques = allUniqueConstraints . filter ( ( entry ) = > includedTableNames . has ( tableKey ( entry . schema_name , entry . tablename ) ) ) ;
2026-03-04 18:03:23 -06:00
if ( uniques . length > 0 ) {
emit ( "-- Unique constraints" ) ;
for ( const u of uniques ) {
const cols = u . column_names . map ( ( c ) = > ` " ${ c } " ` ) . join ( ", " ) ;
2026-03-10 10:08:58 -05:00
emitStatement ( ` ALTER TABLE ${ quoteQualifiedName ( u . schema_name , u . tablename ) } ADD CONSTRAINT " ${ u . constraint_name } " UNIQUE ( ${ cols } ); ` ) ;
2026-03-04 18:03:23 -06:00
}
emit ( "" ) ;
}
// Indexes (non-primary, non-unique-constraint)
2026-03-10 10:08:58 -05:00
const allIndexes = await sql < { schema_name : string ; tablename : string ; indexdef : string } [ ] > `
SELECT schemaname AS schema_name , tablename , indexdef
2026-03-04 18:03:23 -06:00
FROM pg_indexes
2026-03-10 10:08:58 -05:00
WHERE (
schemaname = 'public'
OR ( $ { includeMigrationJournal } : : boolean AND schemaname = $ { DRIZZLE_SCHEMA } )
)
2026-03-04 18:03:23 -06:00
AND indexname NOT IN (
2026-03-10 10:08:58 -05:00
SELECT conname FROM pg_constraint c
JOIN pg_namespace n ON n . oid = c . connamespace
WHERE n . nspname = pg_indexes . schemaname
2026-03-04 18:03:23 -06:00
)
2026-03-10 10:08:58 -05:00
ORDER BY schemaname , tablename , indexname
2026-03-04 18:03:23 -06:00
` ;
2026-03-10 10:08:58 -05:00
const indexes = allIndexes . filter ( ( entry ) = > includedTableNames . has ( tableKey ( entry . schema_name , entry . tablename ) ) ) ;
2026-03-04 18:03:23 -06:00
if ( indexes . length > 0 ) {
emit ( "-- Indexes" ) ;
for ( const idx of indexes ) {
2026-03-10 07:41:01 -05:00
emitStatement ( ` ${ idx . indexdef } ; ` ) ;
2026-03-04 18:03:23 -06:00
}
emit ( "" ) ;
}
// Dump data for each table
2026-03-10 10:08:58 -05:00
for ( const { schema_name , tablename } of tables ) {
const qualifiedTableName = quoteQualifiedName ( schema_name , tablename ) ;
const count = await sql . unsafe < { n : number } [ ] > ( ` SELECT count(*)::int AS n FROM ${ qualifiedTableName } ` ) ;
if ( excludedTableNames . has ( tablename ) || ( count [ 0 ] ? . n ? ? 0 ) === 0 ) continue ;
2026-03-04 18:03:23 -06:00
// Get column info for this table
const cols = await sql < { column_name : string ; data_type : string } [ ] > `
SELECT column_name , data_type
FROM information_schema . columns
2026-03-10 10:08:58 -05:00
WHERE table_schema = $ { schema_name } AND table_name = $ { tablename }
2026-03-04 18:03:23 -06:00
ORDER BY ordinal_position
` ;
const colNames = cols . map ( ( c ) = > ` " ${ c . column_name } " ` ) . join ( ", " ) ;
2026-03-10 10:08:58 -05:00
emit ( ` -- Data for: ${ schema_name } . ${ tablename } ( ${ count [ 0 ] ! . n } rows) ` ) ;
2026-03-04 18:03:23 -06:00
2026-03-10 10:08:58 -05:00
const rows = await sql . unsafe ( ` SELECT * FROM ${ qualifiedTableName } ` ) . values ( ) ;
2026-03-10 07:41:01 -05:00
const nullifiedColumns = nullifiedColumnsByTable . get ( tablename ) ? ? new Set < string > ( ) ;
2026-03-04 18:03:23 -06:00
for ( const row of rows ) {
2026-03-10 07:41:01 -05:00
const values = row . map ( ( rawValue : unknown , index ) = > {
const columnName = cols [ index ] ? . column_name ;
const val = columnName && nullifiedColumns . has ( columnName ) ? null : rawValue ;
2026-03-04 18:03:23 -06:00
if ( val === null || val === undefined ) return "NULL" ;
if ( typeof val === "boolean" ) return val ? "true" : "false" ;
if ( typeof val === "number" ) return String ( val ) ;
2026-03-10 07:41:01 -05:00
if ( val instanceof Date ) return formatSqlLiteral ( val . toISOString ( ) ) ;
if ( typeof val === "object" ) return formatSqlLiteral ( JSON . stringify ( val ) ) ;
return formatSqlLiteral ( String ( val ) ) ;
2026-03-04 18:03:23 -06:00
} ) ;
2026-03-10 10:08:58 -05:00
emitStatement ( ` INSERT INTO ${ qualifiedTableName } ( ${ colNames } ) VALUES ( ${ values . join ( ", " ) } ); ` ) ;
2026-03-04 18:03:23 -06:00
}
emit ( "" ) ;
}
// Sequence values
if ( sequences . length > 0 ) {
emit ( "-- Sequence values" ) ;
for ( const seq of sequences ) {
2026-03-10 10:08:58 -05:00
const qualifiedSequenceName = quoteQualifiedName ( seq . sequence_schema , seq . sequence_name ) ;
const val = await sql . unsafe < { last_value : string ; is_called : boolean } [ ] > (
` SELECT last_value::text, is_called FROM ${ qualifiedSequenceName } ` ,
) ;
const skipSequenceValue =
seq . owner_table !== null
&& excludedTableNames . has ( seq . owner_table ) ;
if ( val [ 0 ] && ! skipSequenceValue ) {
emitStatement ( ` SELECT setval(' ${ qualifiedSequenceName . replaceAll ( "'" , "''" ) } ', ${ val [ 0 ] . last_value } , ${ val [ 0 ] . is_called ? "true" : "false" } ); ` ) ;
2026-03-04 18:03:23 -06:00
}
}
emit ( "" ) ;
}
2026-03-10 07:41:01 -05:00
emitStatement ( "COMMIT;" ) ;
2026-03-04 18:03:23 -06:00
emit ( "" ) ;
2026-04-02 11:51:40 -05:00
await writer . close ( ) ;
2026-03-04 18:03:23 -06:00
2026-04-07 09:41:13 +02:00
// Compress the SQL file with gzip
const sqlReadStream = createReadStream ( sqlFile ) ;
const gzWriteStream = createWriteStream ( backupFile ) ;
await pipeline ( sqlReadStream , createGzip ( ) , gzWriteStream ) ;
unlinkSync ( sqlFile ) ;
2026-03-04 18:03:23 -06:00
const sizeBytes = statSync ( backupFile ) . size ;
2026-04-07 09:54:39 +02:00
const prunedCount = pruneOldBackups ( opts . backupDir , retention , filenamePrefix ) ;
2026-03-04 18:03:23 -06:00
return {
backupFile ,
sizeBytes ,
prunedCount ,
} ;
2026-04-02 11:51:40 -05:00
} catch ( error ) {
await writer . abort ( ) ;
2026-04-07 09:41:13 +02:00
if ( existsSync ( backupFile ) ) {
try { unlinkSync ( backupFile ) ; } catch { /* ignore */ }
}
2026-04-07 10:55:32 +02:00
if ( existsSync ( sqlFile ) ) {
try { unlinkSync ( sqlFile ) ; } catch { /* ignore */ }
}
2026-04-02 11:51:40 -05:00
throw error ;
2026-03-04 18:03:23 -06:00
} finally {
await sql . end ( ) ;
}
}
2026-03-10 10:08:13 -05:00
export async function runDatabaseRestore ( opts : RunDatabaseRestoreOptions ) : Promise < void > {
const connectTimeout = Math . max ( 1 , Math . trunc ( opts . connectTimeoutSeconds ? ? 5 ) ) ;
const sql = postgres ( opts . connectionString , { max : 1 , connect_timeout : connectTimeout } ) ;
try {
await sql ` SELECT 1 ` ;
2026-04-06 08:35:59 -05:00
for await ( const statement of readRestoreStatements ( opts . backupFile ) ) {
2026-03-10 07:41:01 -05:00
await sql . unsafe ( statement ) . execute ( ) ;
}
} catch ( error ) {
const statementPreview = typeof error === "object" && error !== null && typeof ( error as Record < string , unknown > ) . query === "string"
? String ( ( error as Record < string , unknown > ) . query )
. split ( /\r?\n/ )
. map ( ( line ) = > line . trim ( ) )
. find ( ( line ) = > line . length > 0 && ! line . startsWith ( "--" ) )
: null ;
throw new Error (
` Failed to restore ${ basename ( opts . backupFile ) } : ${ sanitizeRestoreErrorMessage ( error ) } ${ statementPreview ? ` [statement: ${ statementPreview . slice ( 0 , 120 ) } ] ` : "" } ` ,
) ;
2026-03-10 10:08:13 -05:00
} finally {
await sql . end ( ) ;
}
}
2026-03-04 18:03:23 -06:00
export function formatDatabaseBackupResult ( result : RunDatabaseBackupResult ) : string {
const size = formatBackupSize ( result . sizeBytes ) ;
const pruned = result . prunedCount > 0 ? ` ; pruned ${ result . prunedCount } old backup(s) ` : "" ;
return ` ${ result . backupFile } ( ${ size } ${ pruned } ) ` ;
}