@ -1,63 +1,174 @@
/ * *
/ * *
* Database Migration Service for TimeSafari
* Database Migration Service for TimeSafari
*
*
* Manages database migrations as people upgrade their app over time .
* This module provides a comprehensive database migration system that manages
* Provides safe , tracked migrations with rollback capabilities and
* schema changes as users upgrade their TimeSafari application over time .
* detailed logging for debugging .
* The system ensures that database changes are applied safely , tracked properly ,
* and can handle edge cases gracefully .
*
* # # Architecture Overview
*
* The migration system follows these key principles :
*
* 1 . * * Single Application * * : Each migration runs exactly once per database
* 2 . * * Tracked Execution * * : All applied migrations are recorded in a migrations table
* 3 . * * Schema Validation * * : Actual database schema is validated before and after migrations
* 4 . * * Graceful Recovery * * : Handles cases where schema exists but tracking is missing
* 5 . * * Comprehensive Logging * * : Detailed logging for debugging and monitoring
*
* # # Migration Flow
*
* ` ` `
* 1 . Create migrations table ( if needed )
* 2 . Query existing applied migrations
* 3 . For each registered migration :
* a . Check if recorded as applied
* b . Check if schema already exists
* c . Skip if already applied
* d . Apply migration SQL
* e . Validate schema was created
* f . Record migration as applied
* 4 . Final validation of all migrations
* ` ` `
*
* # # Usage Example
*
* ` ` ` typescript
* // Register migrations (typically in migration.ts)
* registerMigration ( {
* name : "001_initial" ,
* sql : "CREATE TABLE accounts (id INTEGER PRIMARY KEY, ...)"
* } ) ;
*
* // Run migrations (typically in platform service)
* await runMigrations ( sqlExec , sqlQuery , extractMigrationNames ) ;
* ` ` `
*
* # # Error Handling
*
* The system handles several error scenarios :
* - Duplicate table / column errors ( schema already exists )
* - Migration tracking inconsistencies
* - Database connection issues
* - Schema validation failures
*
*
* @author Matthew Raymer
* @author Matthew Raymer
* @version 1.0 . 0
* @since 2025 - 06 - 30
* /
* /
import { logger } from "../utils/logger" ;
import { logger } from "../utils/logger" ;
/ * *
/ * *
* Migration interface for database schema migrations
* Migration interface for database schema migrations
*
* Represents a single database migration that can be applied to upgrade
* the database schema . Each migration should be idempotent and focused
* on a single schema change .
*
* @interface Migration
* /
* /
interface Migration {
interface Migration {
/** Unique identifier for the migration (e.g., "001_initial", "002_add_column") */
name : string ;
name : string ;
/** SQL statement(s) to execute for this migration */
sql : string ;
sql : string ;
}
}
/ * *
/ * *
* Migration validation result
* Migration validation result
*
* Contains the results of validating that a migration was successfully
* applied by checking the actual database schema .
*
* @interface MigrationValidation
* /
* /
interface MigrationValidation {
interface MigrationValidation {
/** Whether the migration validation passed overall */
isValid : boolean ;
isValid : boolean ;
/** Whether expected tables exist */
tableExists : boolean ;
tableExists : boolean ;
/** Whether expected columns exist */
hasExpectedColumns : boolean ;
hasExpectedColumns : boolean ;
/** List of validation errors encountered */
errors : string [ ] ;
errors : string [ ] ;
}
}
/ * *
/ * *
* Migration registry to store and manage database migrations
* Migration registry to store and manage database migrations
*
* This class maintains a registry of all migrations that need to be applied
* to the database . It uses the singleton pattern to ensure migrations are
* registered once and can be accessed globally .
*
* @class MigrationRegistry
* /
* /
class MigrationRegistry {
class MigrationRegistry {
/** Array of registered migrations */
private migrations : Migration [ ] = [ ] ;
private migrations : Migration [ ] = [ ] ;
/ * *
/ * *
* Register a migration with the registry
* Register a migration with the registry
*
*
* Adds a migration to the list of migrations that will be applied when
* runMigrations ( ) is called . Migrations should be registered in order
* of their intended execution .
*
* @param migration - The migration to register
* @param migration - The migration to register
* @throws { Error } If migration name is empty or already exists
*
* @example
* ` ` ` typescript
* registry . registerMigration ( {
* name : "001_create_users_table" ,
* sql : "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"
* } ) ;
* ` ` `
* /
* /
registerMigration ( migration : Migration ) : void {
registerMigration ( migration : Migration ) : void {
if ( ! migration . name || migration . name . trim ( ) === '' ) {
throw new Error ( 'Migration name cannot be empty' ) ;
}
if ( this . migrations . some ( m = > m . name === migration . name ) ) {
throw new Error ( ` Migration with name ' ${ migration . name } ' already exists ` ) ;
}
this . migrations . push ( migration ) ;
this . migrations . push ( migration ) ;
}
}
/ * *
/ * *
* Get all registered migrations
* Get all registered migrations
*
*
* @returns Array of registered migrations
* Returns a copy of all migrations that have been registered with this
* registry . The migrations are returned in the order they were registered .
*
* @returns Array of registered migrations ( defensive copy )
* /
* /
getMigrations ( ) : Migration [ ] {
getMigrations ( ) : Migration [ ] {
return this . migrations ;
return [ . . . this . migrations ] ;
}
}
/ * *
/ * *
* Clear all registered migrations
* Clear all registered migrations
*
* Removes all migrations from the registry . This is primarily used for
* testing purposes to ensure a clean state between test runs .
*
* @internal Used primarily for testing
* /
* /
clearMigrations ( ) : void {
clearMigrations ( ) : void {
this . migrations = [ ] ;
this . migrations = [ ] ;
}
}
/ * *
* Get the count of registered migrations
*
* @returns Number of migrations currently registered
* /
getCount ( ) : number {
return this . migrations . length ;
}
}
}
// Create a singleton instance of the migration registry
// Create a singleton instance of the migration registry
@ -65,11 +176,30 @@ const migrationRegistry = new MigrationRegistry();
/ * *
/ * *
* Register a migration with the migration service
* Register a migration with the migration service
*
*
* This function is used by the migration system to register database
* This is the primary public API for registering database migrations .
* schema migrations that need to be applied to the database .
* Each migration should represent a single , focused schema change that
*
* can be applied atomically .
*
* @param migration - The migration to register
* @param migration - The migration to register
* @throws { Error } If migration is invalid
*
* @example
* ` ` ` typescript
* registerMigration ( {
* name : "001_initial_schema" ,
* sql : `
* CREATE TABLE accounts (
* id INTEGER PRIMARY KEY ,
* did TEXT UNIQUE NOT NULL ,
* privateKeyHex TEXT NOT NULL ,
* publicKeyHex TEXT NOT NULL ,
* derivationPath TEXT ,
* mnemonic TEXT
* ) ;
* `
* } ) ;
* ` ` `
* /
* /
export function registerMigration ( migration : Migration ) : void {
export function registerMigration ( migration : Migration ) : void {
migrationRegistry . registerMigration ( migration ) ;
migrationRegistry . registerMigration ( migration ) ;
@ -77,6 +207,23 @@ export function registerMigration(migration: Migration): void {
/ * *
/ * *
* Validate that a migration was successfully applied by checking schema
* Validate that a migration was successfully applied by checking schema
*
* This function performs post - migration validation to ensure that the
* expected database schema changes were actually applied . It checks for
* the existence of tables , columns , and other schema elements that should
* have been created by the migration .
*
* @param migration - The migration to validate
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to validation results
*
* @example
* ` ` ` typescript
* const validation = await validateMigrationApplication ( migration , sqlQuery ) ;
* if ( ! validation . isValid ) {
* console . error ( 'Migration validation failed:' , validation . errors ) ;
* }
* ` ` `
* /
* /
async function validateMigrationApplication < T > (
async function validateMigrationApplication < T > (
migration : Migration ,
migration : Migration ,
@ -91,7 +238,7 @@ async function validateMigrationApplication<T>(
try {
try {
if ( migration . name === "001_initial" ) {
if ( migration . name === "001_initial" ) {
// Validate core tables exist
// Validate core tables exist for initial migration
const tables = [ 'accounts' , 'secret' , 'settings' , 'contacts' , 'logs' , 'temp' ] ;
const tables = [ 'accounts' , 'secret' , 'settings' , 'contacts' , 'logs' , 'temp' ] ;
for ( const tableName of tables ) {
for ( const tableName of tables ) {
@ -104,8 +251,10 @@ async function validateMigrationApplication<T>(
console . error ( ` ❌ [Migration-Validation] Table ${ tableName } missing: ` , error ) ;
console . error ( ` ❌ [Migration-Validation] Table ${ tableName } missing: ` , error ) ;
}
}
}
}
validation . tableExists = validation . errors . length === 0 ;
} else if ( migration . name === "002_add_iViewContent_to_contacts" ) {
} else if ( migration . name === "002_add_iViewContent_to_contacts" ) {
// Validate iViewContent column exists
// Validate iViewContent column exists in contacts table
try {
try {
await sqlQuery ( ` SELECT iViewContent FROM contacts LIMIT 1 ` ) ;
await sqlQuery ( ` SELECT iViewContent FROM contacts LIMIT 1 ` ) ;
validation . hasExpectedColumns = true ;
validation . hasExpectedColumns = true ;
@ -116,6 +265,12 @@ async function validateMigrationApplication<T>(
console . error ( ` ❌ [Migration-Validation] Column iViewContent missing: ` , error ) ;
console . error ( ` ❌ [Migration-Validation] Column iViewContent missing: ` , error ) ;
}
}
}
}
// Add validation for future migrations here
// } else if (migration.name === "003_future_migration") {
// // Validate future migration schema changes
// }
} catch ( error ) {
} catch ( error ) {
validation . isValid = false ;
validation . isValid = false ;
validation . errors . push ( ` Validation error: ${ error } ` ) ;
validation . errors . push ( ` Validation error: ${ error } ` ) ;
@ -127,6 +282,23 @@ async function validateMigrationApplication<T>(
/ * *
/ * *
* Check if migration is already applied by examining actual schema
* Check if migration is already applied by examining actual schema
*
* This function performs schema introspection to determine if a migration
* has already been applied , even if it ' s not recorded in the migrations
* table . This is useful for handling cases where the database schema exists
* but the migration tracking got out of sync .
*
* @param migration - The migration to check
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to true if schema already exists
*
* @example
* ` ` ` typescript
* const schemaExists = await isSchemaAlreadyPresent ( migration , sqlQuery ) ;
* if ( schemaExists ) {
* console . log ( 'Schema already exists, skipping migration' ) ;
* }
* ` ` `
* /
* /
async function isSchemaAlreadyPresent < T > (
async function isSchemaAlreadyPresent < T > (
migration : Migration ,
migration : Migration ,
@ -134,13 +306,14 @@ async function isSchemaAlreadyPresent<T>(
) : Promise < boolean > {
) : Promise < boolean > {
try {
try {
if ( migration . name === "001_initial" ) {
if ( migration . name === "001_initial" ) {
// Check if accounts table exists (primary indicator)
// Check if accounts table exists (primary indicator of initial migration )
const result = await sqlQuery ( ` SELECT name FROM sqlite_master WHERE type='table' AND name='accounts' ` ) as any ;
const result = await sqlQuery ( ` SELECT name FROM sqlite_master WHERE type='table' AND name='accounts' ` ) as any ;
const hasTable = result ? . values ? . length > 0 || ( Array . isArray ( result ) && result . length > 0 ) ;
const hasTable = result ? . values ? . length > 0 || ( Array . isArray ( result ) && result . length > 0 ) ;
console . log ( ` 🔍 [Migration-Schema] Initial migration schema check - accounts table exists: ${ hasTable } ` ) ;
console . log ( ` 🔍 [Migration-Schema] Initial migration schema check - accounts table exists: ${ hasTable } ` ) ;
return hasTable ;
return hasTable ;
} else if ( migration . name === "002_add_iViewContent_to_contacts" ) {
} else if ( migration . name === "002_add_iViewContent_to_contacts" ) {
// Check if iViewContent column exists
// Check if iViewContent column exists in contacts table
try {
try {
await sqlQuery ( ` SELECT iViewContent FROM contacts LIMIT 1 ` ) ;
await sqlQuery ( ` SELECT iViewContent FROM contacts LIMIT 1 ` ) ;
console . log ( ` 🔍 [Migration-Schema] iViewContent column already exists ` ) ;
console . log ( ` 🔍 [Migration-Schema] iViewContent column already exists ` ) ;
@ -150,6 +323,12 @@ async function isSchemaAlreadyPresent<T>(
return false ;
return false ;
}
}
}
}
// Add schema checks for future migrations here
// } else if (migration.name === "003_future_migration") {
// // Check if future migration schema already exists
// }
} catch ( error ) {
} catch ( error ) {
console . log ( ` 🔍 [Migration-Schema] Schema check failed for ${ migration . name } , assuming not present: ` , error ) ;
console . log ( ` 🔍 [Migration-Schema] Schema check failed for ${ migration . name } , assuming not present: ` , error ) ;
return false ;
return false ;
@ -160,16 +339,42 @@ async function isSchemaAlreadyPresent<T>(
/ * *
/ * *
* Run all registered migrations against the database
* Run all registered migrations against the database
*
*
* This function executes all registered migrations in order , checking
* This is the main function that executes the migration process . It :
* which ones have already been applied to avoid duplicate execution .
* 1 . Creates the migrations tracking table if needed
* It creates a migrations table if it doesn ' t exist to track applied
* 2 . Determines which migrations have already been applied
* migrations .
* 3 . Applies any pending migrations in order
*
* 4 . Validates that migrations were applied correctly
* @param sqlExec - Function to execute SQL statements
* 5 . Records successful migrations in the tracking table
* @param sqlQuery - Function to query SQL data
* 6 . Performs final validation of the migration state
*
* The function is designed to be idempotent - it can be run multiple times
* safely without re - applying migrations that have already been completed .
*
* @template T - The type returned by SQL query operations
* @param sqlExec - Function to execute SQL statements ( INSERT , UPDATE , CREATE , etc . )
* @param sqlQuery - Function to execute SQL queries ( SELECT )
* @param extractMigrationNames - Function to extract migration names from query results
* @param extractMigrationNames - Function to extract migration names from query results
* @returns Promise that resolves when all migrations are complete
* @returns Promise that resolves when all migrations are complete
* @throws { Error } If any migration fails to apply
*
* @example
* ` ` ` typescript
* // Platform-specific implementation
* const sqlExec = async ( sql : string , params? : unknown [ ] ) = > {
* return await db . run ( sql , params ) ;
* } ;
*
* const sqlQuery = async ( sql : string , params? : unknown [ ] ) = > {
* return await db . query ( sql , params ) ;
* } ;
*
* const extractNames = ( result : DBResult ) = > {
* return new Set ( result . values . map ( row = > row [ 0 ] ) ) ;
* } ;
*
* await runMigrations ( sqlExec , sqlQuery , extractNames ) ;
* ` ` `
* /
* /
export async function runMigrations < T > (
export async function runMigrations < T > (
sqlExec : ( sql : string , params? : unknown [ ] ) = > Promise < unknown > ,
sqlExec : ( sql : string , params? : unknown [ ] ) = > Promise < unknown > ,
@ -177,9 +382,10 @@ export async function runMigrations<T>(
extractMigrationNames : ( result : T ) = > Set < string > ,
extractMigrationNames : ( result : T ) = > Set < string > ,
) : Promise < void > {
) : Promise < void > {
try {
try {
console . log ( "📋 [Migration] Checking migration statu s..." ) ;
console . log ( "📋 [Migration] Starting migration proces s..." ) ;
// Create migrations table if it doesn't exist
// Step 1: Create migrations table if it doesn't exist
// Note: We use IF NOT EXISTS here because this is infrastructure, not a business migration
console . log ( "🔧 [Migration] Creating migrations table if it doesn't exist..." ) ;
console . log ( "🔧 [Migration] Creating migrations table if it doesn't exist..." ) ;
await sqlExec ( `
await sqlExec ( `
CREATE TABLE IF NOT EXISTS migrations (
CREATE TABLE IF NOT EXISTS migrations (
@ -189,7 +395,7 @@ export async function runMigrations<T>(
` );
` );
console . log ( "✅ [Migration] Migrations table ready" ) ;
console . log ( "✅ [Migration] Migrations table ready" ) ;
// Get list of already applied migrations
// Step 2: Get list of already applied migrations
console . log ( "🔍 [Migration] Querying existing migrations..." ) ;
console . log ( "🔍 [Migration] Querying existing migrations..." ) ;
const appliedMigrationsResult = await sqlQuery (
const appliedMigrationsResult = await sqlQuery (
"SELECT name FROM migrations" ,
"SELECT name FROM migrations" ,
@ -199,7 +405,7 @@ export async function runMigrations<T>(
const appliedMigrations = extractMigrationNames ( appliedMigrationsResult ) ;
const appliedMigrations = extractMigrationNames ( appliedMigrationsResult ) ;
console . log ( "📋 [Migration] Extracted applied migrations:" , Array . from ( appliedMigrations ) ) ;
console . log ( "📋 [Migration] Extracted applied migrations:" , Array . from ( appliedMigrations ) ) ;
// Get all registered migrations
// Step 3: Get all registered migrations
const migrations = migrationRegistry . getMigrations ( ) ;
const migrations = migrationRegistry . getMigrations ( ) ;
if ( migrations . length === 0 ) {
if ( migrations . length === 0 ) {
@ -214,22 +420,26 @@ export async function runMigrations<T>(
let appliedCount = 0 ;
let appliedCount = 0 ;
let skippedCount = 0 ;
let skippedCount = 0 ;
// Run each migration that hasn't been applied yet
// Step 4: Process each migration
for ( const migration of migrations ) {
for ( const migration of migrations ) {
// First check: Is it recorded as applied in migrations table?
console . log ( ` \ n🔍 [Migration] Processing migration: ${ migration . name } ` ) ;
// Check 1: Is it recorded as applied in migrations table?
const isRecordedAsApplied = appliedMigrations . has ( migration . name ) ;
const isRecordedAsApplied = appliedMigrations . has ( migration . name ) ;
// Second check: Does the schema already exist ?
// Check 2: Does the schema already exist in the database ?
const isSchemaPresent = await isSchemaAlreadyPresent ( migration , sqlQuery ) ;
const isSchemaPresent = await isSchemaAlreadyPresent ( migration , sqlQuery ) ;
console . log ( ` 🔍 [Migration] ${ migration . name } - Recorded: ${ isRecordedAsApplied } , Schema: ${ isSchemaPresent } ` ) ;
console . log ( ` 🔍 [Migration] ${ migration . name } - Recorded: ${ isRecordedAsApplied } , Schema: ${ isSchemaPresent } ` ) ;
// Skip if already recorded as applied
if ( isRecordedAsApplied ) {
if ( isRecordedAsApplied ) {
console . log ( ` ⏭️ [Migration] Skipping already applied: ${ migration . name } ` ) ;
console . log ( ` ⏭️ [Migration] Skipping already applied: ${ migration . name } ` ) ;
skippedCount ++ ;
skippedCount ++ ;
continue ;
continue ;
}
}
// Handle case where schema exists but isn't recorded
if ( isSchemaPresent ) {
if ( isSchemaPresent ) {
console . log ( ` 🔄 [Migration] Schema exists but not recorded. Marking ${ migration . name } as applied... ` ) ;
console . log ( ` 🔄 [Migration] Schema exists but not recorded. Marking ${ migration . name } as applied... ` ) ;
try {
try {
@ -242,10 +452,11 @@ export async function runMigrations<T>(
continue ;
continue ;
} catch ( insertError ) {
} catch ( insertError ) {
console . warn ( ` ⚠️ [Migration] Could not record existing schema ${ migration . name } : ` , insertError ) ;
console . warn ( ` ⚠️ [Migration] Could not record existing schema ${ migration . name } : ` , insertError ) ;
// Continue with normal migration process
// Continue with normal migration process as fallback
}
}
}
}
// Apply the migration
console . log ( ` 🔄 [Migration] Applying migration: ${ migration . name } ` ) ;
console . log ( ` 🔄 [Migration] Applying migration: ${ migration . name } ` ) ;
try {
try {
@ -258,6 +469,8 @@ export async function runMigrations<T>(
const validation = await validateMigrationApplication ( migration , sqlQuery ) ;
const validation = await validateMigrationApplication ( migration , sqlQuery ) ;
if ( ! validation . isValid ) {
if ( ! validation . isValid ) {
console . warn ( ` ⚠️ [Migration] Validation failed for ${ migration . name } : ` , validation . errors ) ;
console . warn ( ` ⚠️ [Migration] Validation failed for ${ migration . name } : ` , validation . errors ) ;
} else {
console . log ( ` ✅ [Migration] Schema validation passed for ${ migration . name } ` ) ;
}
}
// Record that the migration was applied
// Record that the migration was applied
@ -267,11 +480,12 @@ export async function runMigrations<T>(
] ) ;
] ) ;
console . log ( ` ✅ [Migration] Migration record inserted: ` , insertResult ) ;
console . log ( ` ✅ [Migration] Migration record inserted: ` , insertResult ) ;
console . log ( ` ✅ [Migration] Successfully applied: ${ migration . name } ` ) ;
console . log ( ` 🎉 [Migration] Successfully applied: ${ migration . name } ` ) ;
logger . info (
logger . info (
` [MigrationService] Successfully applied migration: ${ migration . name } ` ,
` [MigrationService] Successfully applied migration: ${ migration . name } ` ,
) ;
) ;
appliedCount ++ ;
appliedCount ++ ;
} catch ( error ) {
} catch ( error ) {
console . error ( ` ❌ [Migration] Error applying ${ migration . name } : ` , error ) ;
console . error ( ` ❌ [Migration] Error applying ${ migration . name } : ` , error ) ;
@ -327,15 +541,27 @@ export async function runMigrations<T>(
}
}
}
}
// Final validation: V erify all migrations are properly recorded
// Step 5: Final validation - v erify all migrations are properly recorded
console . log ( "🔍 [Migration] Final validation - checking migrations table..." ) ;
console . log ( "\n 🔍 [Migration] Final validation - checking migrations table..." ) ;
const finalMigrationsResult = await sqlQuery ( "SELECT name FROM migrations" ) ;
const finalMigrationsResult = await sqlQuery ( "SELECT name FROM migrations" ) ;
const finalAppliedMigrations = extractMigrationNames ( finalMigrationsResult ) ;
const finalAppliedMigrations = extractMigrationNames ( finalMigrationsResult ) ;
console . log ( "📋 [Migration] Final applied migrations:" , Array . from ( finalAppliedMigrations ) ) ;
console . log ( "📋 [Migration] Final applied migrations:" , Array . from ( finalAppliedMigrations ) ) ;
console . log ( ` 🎉 [Migration] Migration process complete! Applied: ${ appliedCount } , Skipped: ${ skippedCount } ` ) ;
// Check that all expected migrations are recorded
const expectedMigrations = new Set ( migrations . map ( m = > m . name ) ) ;
const missingMigrations = [ . . . expectedMigrations ] . filter ( name = > ! finalAppliedMigrations . has ( name ) ) ;
if ( missingMigrations . length > 0 ) {
console . warn ( ` ⚠️ [Migration] Missing migration records: ${ missingMigrations . join ( ', ' ) } ` ) ;
logger . warn ( ` [MigrationService] Missing migration records: ${ missingMigrations . join ( ', ' ) } ` ) ;
}
console . log ( ` \ n🎉 [Migration] Migration process complete! ` ) ;
console . log ( ` 📊 [Migration] Summary: Applied: ${ appliedCount } , Skipped: ${ skippedCount } , Total: ${ migrations . length } ` ) ;
logger . info ( ` [MigrationService] Migration process complete. Applied: ${ appliedCount } , Skipped: ${ skippedCount } ` ) ;
} catch ( error ) {
} catch ( error ) {
console . error ( "💥 [Migration] Migration process failed:" , error ) ;
console . error ( "\n 💥 [Migration] Migration process failed:" , error ) ;
logger . error ( "[MigrationService] Migration process failed:" , error ) ;
logger . error ( "[MigrationService] Migration process failed:" , error ) ;
throw error ;
throw error ;
}
}