2026-03-02 16:52:24 -05:00
#!/usr/bin/env node
const sqlite3 = require ( 'sqlite3' )
const { Client } = require ( 'pg' )
const SQLITE _PATH = process . env . SQLITE _PATH || '/config/absdatabase.sqlite'
const DATABASE _URL = process . env . DATABASE _URL
const PG _SCHEMA = process . env . PG _SCHEMA || 'public'
const BATCH _SIZE = Number ( process . env . MIGRATION _BATCH _SIZE || 500 )
const DRY _RUN = String ( process . env . DRY _RUN || 'false' ) . toLowerCase ( ) === 'true'
2026-08-02 17:56:08 -04:00
const ALLOW _DESTRUCTIVE _TARGET = String ( process . env . ALLOW _DESTRUCTIVE _TARGET || 'false' ) . toLowerCase ( ) === 'true'
const integerBounds = {
smallint : { min : - 32768 n , max : 32767 n } ,
integer : { min : - 2147483648 n , max : 2147483647 n } ,
bigint : { min : - 9223372036854775808 n , max : 9223372036854775807 n }
}
2026-03-02 16:52:24 -05:00
const preferredOrder = [
'migrationsMeta' ,
'SequelizeMeta' ,
'settings' ,
'users' ,
'apiKeys' ,
'sessions' ,
'libraries' ,
'libraryFolders' ,
'authors' ,
'series' ,
'books' ,
'podcasts' ,
'podcastEpisodes' ,
'libraryItems' ,
'bookAuthors' ,
'bookSeries' ,
'collections' ,
'collectionBooks' ,
'playlists' ,
'playlistMediaItems' ,
'mediaProgresses' ,
'devices' ,
'playbackSessions' ,
'feeds' ,
'feedEpisodes' ,
'mediaItemShares' ,
'customMetadataProviders'
]
function quoteIdent ( identifier ) {
return ` " ${ String ( identifier ) . replace ( /"/g , '""' ) } " `
}
function openSqlite ( filePath ) {
return new Promise ( ( resolve , reject ) => {
const db = new sqlite3 . Database ( filePath , sqlite3 . OPEN _READONLY , ( err ) => {
if ( err ) return reject ( err )
resolve ( db )
} )
} )
}
function sqliteAll ( db , sql , params = [ ] ) {
return new Promise ( ( resolve , reject ) => {
db . all ( sql , params , ( err , rows ) => {
if ( err ) return reject ( err )
resolve ( rows )
} )
} )
}
function sqliteGet ( db , sql , params = [ ] ) {
return new Promise ( ( resolve , reject ) => {
db . get ( sql , params , ( err , row ) => {
if ( err ) return reject ( err )
resolve ( row )
} )
} )
}
2026-03-02 17:45:32 -05:00
async function findOverlongVarcharValues ( sqliteDb , tablesToMigrate , pgColumnsByTable ) {
const issues = [ ]
for ( const { sqliteTable , postgresTable } of tablesToMigrate ) {
const pgColumns = pgColumnsByTable . get ( postgresTable )
if ( ! pgColumns ) continue
for ( const column of pgColumns . values ( ) ) {
if ( column . data _type !== 'character varying' || ! column . character _maximum _length ) continue
const sqliteColumn = column . column _name
const maxLength = Number ( column . character _maximum _length )
const quotedTable = quoteIdent ( sqliteTable )
const quotedColumn = quoteIdent ( sqliteColumn )
try {
const maxLengthRow = await sqliteGet (
sqliteDb ,
` SELECT MAX(LENGTH( ${ quotedColumn } )) AS maxLength FROM ${ quotedTable } WHERE ${ quotedColumn } IS NOT NULL `
)
const actualMaxLength = Number ( maxLengthRow ? . maxLength || 0 )
if ( actualMaxLength <= maxLength ) continue
const overCountRow = await sqliteGet (
sqliteDb ,
` SELECT COUNT(*) AS count FROM ${ quotedTable } WHERE LENGTH( ${ quotedColumn } ) > ? ` ,
[ maxLength ]
)
issues . push ( {
sqliteTable ,
sqliteColumn ,
postgresTable ,
postgresColumn : column . column _name ,
maxLength ,
actualMaxLength ,
overCount : Number ( overCountRow ? . count || 0 )
} )
} catch ( error ) {
// Ignore columns missing in sqlite source table
}
}
}
return issues
}
2026-08-02 17:56:08 -04:00
function parseIntegerValue ( value ) {
2026-03-02 17:45:32 -05:00
if ( typeof value === 'number' ) {
2026-08-02 17:56:08 -04:00
if ( ! Number . isSafeInteger ( value ) ) return null
return BigInt ( value )
2026-03-02 17:45:32 -05:00
}
if ( typeof value === 'string' ) {
const trimmed = value . trim ( )
2026-08-02 17:56:08 -04:00
if ( ! trimmed || ! /^-?\d+$/ . test ( trimmed ) ) return null
try {
return BigInt ( trimmed )
} catch ( error ) {
return null
}
2026-03-02 17:45:32 -05:00
}
2026-08-02 17:56:08 -04:00
return null
}
function isIntegerCompatible ( value , dataType = 'bigint' ) {
if ( value === null || value === undefined ) return true
const parsed = parseIntegerValue ( value )
if ( parsed === null ) return false
const bounds = integerBounds [ dataType ]
if ( ! bounds ) return false
return parsed >= bounds . min && parsed <= bounds . max
2026-03-02 17:45:32 -05:00
}
async function findIntegerTypeIssues ( sqliteDb , tablesToMigrate , pgColumnsByTable ) {
const issues = [ ]
for ( const { sqliteTable , postgresTable } of tablesToMigrate ) {
const pgColumns = pgColumnsByTable . get ( postgresTable )
if ( ! pgColumns ) continue
for ( const column of pgColumns . values ( ) ) {
const dataType = column . data _type
if ( dataType !== 'smallint' && dataType !== 'integer' && dataType !== 'bigint' ) continue
const sqliteColumn = column . column _name
const quotedTable = quoteIdent ( sqliteTable )
const quotedColumn = quoteIdent ( sqliteColumn )
let rows
try {
rows = await sqliteAll ( sqliteDb , ` SELECT ${ quotedColumn } AS value FROM ${ quotedTable } WHERE ${ quotedColumn } IS NOT NULL ` )
} catch ( error ) {
// Ignore columns missing in sqlite source table
continue
}
let badCount = 0
let sampleValue = null
for ( const row of rows ) {
2026-08-02 17:56:08 -04:00
if ( ! isIntegerCompatible ( row . value , dataType ) ) {
2026-03-02 17:45:32 -05:00
badCount += 1
if ( sampleValue === null ) sampleValue = row . value
}
}
if ( badCount > 0 ) {
issues . push ( {
sqliteTable ,
sqliteColumn ,
postgresTable ,
postgresColumn : column . column _name ,
postgresType : dataType ,
badCount ,
sampleValue
} )
}
}
}
return issues
}
2026-03-02 16:52:24 -05:00
function normalizeBoolean ( value ) {
if ( value === null || value === undefined ) return null
if ( typeof value === 'boolean' ) return value
if ( typeof value === 'number' ) return value !== 0
if ( typeof value === 'string' ) {
const v = value . trim ( ) . toLowerCase ( )
return v === 'true' || v === '1' || v === 't'
}
return ! ! value
}
function normalizeJson ( value ) {
if ( value === null || value === undefined || value === '' ) return null
2026-03-02 17:45:32 -05:00
if ( typeof value === 'object' ) return JSON . stringify ( value )
2026-03-02 17:15:45 -05:00
const textValue = String ( value )
2026-03-02 16:52:24 -05:00
try {
2026-03-02 17:15:45 -05:00
const parsed = JSON . parse ( textValue )
if ( typeof parsed === 'string' ) {
try {
2026-03-02 17:45:32 -05:00
return JSON . stringify ( JSON . parse ( parsed ) )
2026-03-02 17:15:45 -05:00
} catch ( error ) {
2026-03-02 17:45:32 -05:00
return JSON . stringify ( parsed )
2026-03-02 17:15:45 -05:00
}
}
2026-03-02 17:45:32 -05:00
return JSON . stringify ( parsed )
2026-03-02 16:52:24 -05:00
} catch ( error ) {
2026-03-02 17:45:32 -05:00
// Keep non-JSON payloads as JSON string values so inserts remain valid JSON.
2026-03-02 17:15:45 -05:00
return JSON . stringify ( textValue )
2026-03-02 16:52:24 -05:00
}
}
function convertValue ( value , pgColumn ) {
if ( ! pgColumn ) return value
const dataType = pgColumn . data _type
const udtName = pgColumn . udt _name
if ( dataType === 'boolean' ) {
return normalizeBoolean ( value )
}
if ( dataType === 'json' || dataType === 'jsonb' || udtName === 'json' || udtName === 'jsonb' ) {
return normalizeJson ( value )
}
2026-03-02 17:45:32 -05:00
if ( ( dataType === 'smallint' || dataType === 'integer' || dataType === 'bigint' ) && value !== null && value !== undefined ) {
2026-08-02 17:56:08 -04:00
if ( ! isIntegerCompatible ( value , dataType ) ) return value
if ( dataType === 'bigint' && typeof value === 'string' ) {
return value . trim ( )
2026-03-02 17:45:32 -05:00
}
2026-08-02 17:56:08 -04:00
if ( typeof value === 'number' ) return value
return Number ( value )
2026-03-02 17:45:32 -05:00
}
2026-03-02 16:52:24 -05:00
return value
}
2026-03-02 17:45:32 -05:00
function getOverlongColumns ( row , insertColumns ) {
const overlong = [ ]
for ( const column of insertColumns ) {
const maxLength = column . metadata . character _maximum _length
if ( ! maxLength ) continue
const value = row [ column . sqliteColumn ]
if ( value === null || value === undefined ) continue
const length = String ( value ) . length
if ( length > maxLength ) {
overlong . push ( {
sqliteColumn : column . sqliteColumn ,
postgresColumn : column . postgresColumn ,
maxLength ,
actualLength : length
} )
}
}
return overlong
}
2026-03-02 16:52:24 -05:00
async function main ( ) {
2026-03-02 17:45:32 -05:00
if ( ! DATABASE _URL ) {
throw new Error ( 'DATABASE_URL is required' )
}
2026-03-02 16:52:24 -05:00
console . log ( ` [migrate] sqlite source: ${ SQLITE _PATH } ` )
console . log ( ` [migrate] postgres target schema: ${ PG _SCHEMA } ` )
console . log ( ` [migrate] dry run: ${ DRY _RUN } ` )
const sqliteDb = await openSqlite ( SQLITE _PATH )
const pg = new Client ( { connectionString : DATABASE _URL } )
await pg . connect ( )
try {
const sqliteTablesRows = await sqliteAll (
sqliteDb ,
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
)
const sqliteTables = sqliteTablesRows . map ( ( row ) => row . name )
const pgTablesRows = await pg . query (
` SELECT table_name FROM information_schema.tables WHERE table_schema = $ 1 AND table_type='BASE TABLE' ORDER BY table_name ` ,
[ PG _SCHEMA ]
)
2026-03-02 17:12:12 -05:00
const pgTablesByLowerName = new Map ( pgTablesRows . rows . map ( ( row ) => [ row . table _name . toLowerCase ( ) , row . table _name ] ) )
const tablesToMigrate = sqliteTables
. map ( ( sqliteTable ) => {
const postgresTable = pgTablesByLowerName . get ( sqliteTable . toLowerCase ( ) )
if ( ! postgresTable ) return null
return {
sqliteTable ,
postgresTable
}
} )
. filter ( Boolean )
2026-03-02 16:52:24 -05:00
tablesToMigrate . sort ( ( a , b ) => {
2026-03-02 17:12:12 -05:00
const ai = preferredOrder . findIndex ( ( tableName ) => tableName . toLowerCase ( ) === a . sqliteTable . toLowerCase ( ) )
const bi = preferredOrder . findIndex ( ( tableName ) => tableName . toLowerCase ( ) === b . sqliteTable . toLowerCase ( ) )
if ( ai === - 1 && bi === - 1 ) return a . sqliteTable . localeCompare ( b . sqliteTable )
2026-03-02 16:52:24 -05:00
if ( ai === - 1 ) return 1
if ( bi === - 1 ) return - 1
return ai - bi
} )
if ( ! tablesToMigrate . length ) {
throw new Error ( 'No overlapping tables found between SQLite and PostgreSQL' )
}
2026-03-02 17:12:12 -05:00
console . log ( ` [migrate] tables to migrate: ${ tablesToMigrate . map ( ( table ) => table . sqliteTable ) . join ( ', ' ) } ` )
2026-03-02 16:52:24 -05:00
const pgColumnsByTable = new Map ( )
2026-03-02 17:12:12 -05:00
for ( const { postgresTable } of tablesToMigrate ) {
2026-03-02 16:52:24 -05:00
const columnsResult = await pg . query (
2026-03-02 17:45:32 -05:00
` SELECT column_name, data_type, udt_name, character_maximum_length FROM information_schema.columns WHERE table_schema = $ 1 AND table_name = $ 2 ORDER BY ordinal_position ` ,
2026-03-02 17:12:12 -05:00
[ PG _SCHEMA , postgresTable ]
2026-03-02 16:52:24 -05:00
)
2026-03-02 17:12:12 -05:00
const columnMap = new Map ( columnsResult . rows . map ( ( column ) => [ column . column _name . toLowerCase ( ) , column ] ) )
pgColumnsByTable . set ( postgresTable , columnMap )
2026-03-02 16:52:24 -05:00
}
2026-03-02 17:45:32 -05:00
const overlongVarcharIssues = await findOverlongVarcharValues ( sqliteDb , tablesToMigrate , pgColumnsByTable )
const integerTypeIssues = await findIntegerTypeIssues ( sqliteDb , tablesToMigrate , pgColumnsByTable )
if ( overlongVarcharIssues . length ) {
console . error ( '[migrate] overlong source values detected for varchar columns:' )
overlongVarcharIssues . forEach ( ( issue ) => {
console . error (
` [migrate] ${ issue . sqliteTable } . ${ issue . sqliteColumn } -> ${ issue . postgresTable } . ${ issue . postgresColumn } ` +
` (max= ${ issue . maxLength } , actualMax= ${ issue . actualMaxLength } , overRows= ${ issue . overCount } ) `
)
} )
throw new Error ( 'Migration aborted to prevent truncation/data loss. Widen target column types first.' )
}
if ( integerTypeIssues . length ) {
console . error ( '[migrate] non-integer source values detected for integer columns:' )
integerTypeIssues . forEach ( ( issue ) => {
console . error (
` [migrate] ${ issue . sqliteTable } . ${ issue . sqliteColumn } -> ${ issue . postgresTable } . ${ issue . postgresColumn } ` +
` (type= ${ issue . postgresType } , badRows= ${ issue . badCount } , sample= ${ JSON . stringify ( issue . sampleValue ) } ) `
)
} )
throw new Error ( 'Migration aborted to prevent numeric precision loss. Widen target numeric column types first.' )
}
2026-03-02 16:52:24 -05:00
if ( ! DRY _RUN ) {
2026-08-02 17:56:08 -04:00
if ( ! ALLOW _DESTRUCTIVE _TARGET ) {
throw new Error ( 'Migration writes are destructive. Set ALLOW_DESTRUCTIVE_TARGET=true after confirming the target database can be truncated.' )
}
2026-03-02 16:52:24 -05:00
await pg . query ( 'BEGIN' )
await pg . query ( 'SET session_replication_role = replica' )
2026-03-02 17:12:12 -05:00
const truncateList = tablesToMigrate . map ( ( { postgresTable } ) => ` ${ quoteIdent ( PG _SCHEMA ) } . ${ quoteIdent ( postgresTable ) } ` ) . join ( ', ' )
2026-03-02 16:52:24 -05:00
await pg . query ( ` TRUNCATE TABLE ${ truncateList } RESTART IDENTITY CASCADE ` )
console . log ( '[migrate] truncated target tables' )
}
2026-03-02 17:12:12 -05:00
for ( const { sqliteTable , postgresTable } of tablesToMigrate ) {
const rows = await sqliteAll ( sqliteDb , ` SELECT * FROM ${ quoteIdent ( sqliteTable ) } ` )
const pgColumns = pgColumnsByTable . get ( postgresTable )
const insertColumns = rows . length
? Object . keys ( rows [ 0 ] )
. map ( ( sqliteColumn ) => {
const pgColumn = pgColumns . get ( sqliteColumn . toLowerCase ( ) )
if ( ! pgColumn ) return null
return {
sqliteColumn ,
postgresColumn : pgColumn . column _name ,
metadata : pgColumn
}
} )
. filter ( Boolean )
: [ ]
2026-03-02 16:52:24 -05:00
if ( ! rows . length || ! insertColumns . length ) {
2026-03-02 17:12:12 -05:00
console . log ( ` [migrate] ${ sqliteTable } : skipped (rows= ${ rows . length } , insertableColumns= ${ insertColumns . length } ) ` )
2026-03-02 16:52:24 -05:00
continue
}
if ( ! DRY _RUN ) {
for ( let offset = 0 ; offset < rows . length ; offset += BATCH _SIZE ) {
const batchRows = rows . slice ( offset , offset + BATCH _SIZE )
const valuesSql = [ ]
const params = [ ]
let paramIndex = 1
for ( const row of batchRows ) {
2026-03-02 17:45:32 -05:00
const placeholders = [ ]
for ( const column of insertColumns ) {
params . push ( convertValue ( row [ column . sqliteColumn ] , column . metadata ) )
placeholders . push ( ` $ ${ paramIndex ++ } ` )
}
2026-03-02 16:52:24 -05:00
valuesSql . push ( ` ( ${ placeholders . join ( ', ' ) } ) ` )
}
2026-03-02 17:12:12 -05:00
const insertSql = ` INSERT INTO ${ quoteIdent ( PG _SCHEMA ) } . ${ quoteIdent ( postgresTable ) } ( ${ insertColumns . map ( ( column ) => quoteIdent ( column . postgresColumn ) ) . join ( ', ' ) } ) VALUES ${ valuesSql . join ( ', ' ) } `
2026-03-02 17:45:32 -05:00
try {
await pg . query ( 'SAVEPOINT migrate_batch' )
await pg . query ( insertSql , params )
await pg . query ( 'RELEASE SAVEPOINT migrate_batch' )
} catch ( error ) {
await pg . query ( 'ROLLBACK TO SAVEPOINT migrate_batch' )
console . error ( ` [migrate] batch insert failed for ${ sqliteTable } (offset= ${ offset } , size= ${ batchRows . length } ): ${ error . message } ` )
for ( let rowIndex = 0 ; rowIndex < batchRows . length ; rowIndex ++ ) {
const row = batchRows [ rowIndex ]
const singleRowParams = insertColumns . map ( ( column ) => convertValue ( row [ column . sqliteColumn ] , column . metadata ) )
const singleRowInsertSql = ` INSERT INTO ${ quoteIdent ( PG _SCHEMA ) } . ${ quoteIdent ( postgresTable ) } ( ${ insertColumns . map ( ( column ) => quoteIdent ( column . postgresColumn ) ) . join ( ', ' ) } ) VALUES ( ${ singleRowParams . map ( ( _ , index ) => ` $ ${ index + 1 } ` ) . join ( ', ' ) } ) `
try {
await pg . query ( 'SAVEPOINT migrate_row' )
await pg . query ( singleRowInsertSql , singleRowParams )
await pg . query ( 'RELEASE SAVEPOINT migrate_row' )
} catch ( rowError ) {
await pg . query ( 'ROLLBACK TO SAVEPOINT migrate_row' )
const overlongColumns = getOverlongColumns ( row , insertColumns )
if ( overlongColumns . length ) {
overlongColumns . forEach ( ( column ) => {
console . error (
` [migrate] overlong value in ${ sqliteTable } . ${ column . sqliteColumn } -> ${ postgresTable } . ${ column . postgresColumn } ` +
` (length= ${ column . actualLength } , max= ${ column . maxLength } ) `
)
} )
}
throw rowError
}
}
}
2026-03-02 16:52:24 -05:00
}
}
2026-03-02 17:12:12 -05:00
console . log ( ` [migrate] ${ sqliteTable } : ${ rows . length } rows ` )
2026-03-02 16:52:24 -05:00
}
if ( ! DRY _RUN ) {
await pg . query ( 'SET session_replication_role = DEFAULT' )
await pg . query ( 'COMMIT' )
console . log ( '[migrate] migration transaction committed' )
}
const parity = [ ]
2026-03-02 17:12:12 -05:00
for ( const { sqliteTable , postgresTable } of tablesToMigrate ) {
const sqliteCountRow = await sqliteGet ( sqliteDb , ` SELECT COUNT(*) AS count FROM ${ quoteIdent ( sqliteTable ) } ` )
const pgCountResult = await pg . query ( ` SELECT COUNT(*)::bigint AS count FROM ${ quoteIdent ( PG _SCHEMA ) } . ${ quoteIdent ( postgresTable ) } ` )
2026-03-02 16:52:24 -05:00
parity . push ( {
2026-03-02 17:12:12 -05:00
table : sqliteTable ,
2026-03-02 16:52:24 -05:00
sqliteCount : Number ( sqliteCountRow . count || 0 ) ,
postgresCount : Number ( pgCountResult . rows [ 0 ] . count || 0 )
} )
}
const mismatches = parity . filter ( ( row ) => row . sqliteCount !== row . postgresCount )
if ( mismatches . length ) {
console . error ( '[migrate] row-count mismatches detected:' )
mismatches . forEach ( ( row ) => {
console . error ( ` [migrate] ${ row . table } : sqlite= ${ row . sqliteCount } postgres= ${ row . postgresCount } ` )
} )
process . exitCode = 2
} else {
console . log ( '[migrate] parity check passed for all migrated tables' )
}
} finally {
sqliteDb . close ( )
await pg . end ( )
}
}
2026-03-02 17:45:32 -05:00
if ( require . main === module ) {
main ( ) . catch ( ( error ) => {
console . error ( '[migrate] failed:' , error )
process . exit ( 1 )
} )
}
module . exports = {
normalizeJson ,
isIntegerCompatible ,
convertValue ,
findOverlongVarcharValues ,
findIntegerTypeIssues ,
quoteIdent
}