/** * SQLite Migration Utilities * * This module handles the migration of data from Dexie to SQLite, * including data transformation, validation, and rollback capabilities. */ import { Database } from '@wa-sqlite/sql.js'; import { initDatabase, createSchema, createBackup } from './init'; import { MigrationData, MigrationResult, SQLiteAccount, SQLiteContact, SQLiteContactMethod, SQLiteSettings, SQLiteLog, SQLiteSecret, isSQLiteAccount, isSQLiteContact, isSQLiteSettings } from './types'; import { logger } from '../../utils/logger'; // ============================================================================ // Migration Types // ============================================================================ interface MigrationContext { db: Database; startTime: number; stats: MigrationResult['stats']; errors: Error[]; } // ============================================================================ // Migration Functions // ============================================================================ /** * Migrate data from Dexie to SQLite */ export async function migrateFromDexie(data: MigrationData): Promise { const startTime = Date.now(); const context: MigrationContext = { db: (await initDatabase()).db, startTime, stats: { accounts: 0, contacts: 0, contactMethods: 0, settings: 0, logs: 0, secrets: 0 }, errors: [] }; try { // Create backup before migration const backup = await createBackup(); // Create schema if needed await createSchema(); // Perform migration in a transaction await context.db.transaction(async () => { // Migrate in order of dependencies await migrateAccounts(context, data.accounts); await migrateContacts(context, data.contacts); await migrateContactMethods(context, data.contactMethods); await migrateSettings(context, data.settings); await migrateLogs(context, data.logs); await migrateSecrets(context, data.secrets); }); // Verify migration const verificationResult = await verifyMigration(context, data); if (!verificationResult.success) { throw new Error(`Migration verification failed: ${verificationResult.error}`); } return { success: true, stats: context.stats, duration: Date.now() - startTime }; } catch (error) { logger.error('[SQLite] Migration failed:', error); // Attempt rollback try { await rollbackMigration(backup); } catch (rollbackError) { logger.error('[SQLite] Rollback failed:', rollbackError); context.errors.push(new Error('Migration and rollback failed')); } return { success: false, error: error instanceof Error ? error : new Error('Unknown migration error'), stats: context.stats, duration: Date.now() - startTime }; } } // ============================================================================ // Migration Helpers // ============================================================================ /** * Migrate accounts */ async function migrateAccounts(context: MigrationContext, accounts: SQLiteAccount[]): Promise { for (const account of accounts) { try { if (!isSQLiteAccount(account)) { throw new Error(`Invalid account data: ${JSON.stringify(account)}`); } await context.db.exec(` INSERT INTO accounts ( did, public_key_hex, created_at, updated_at, identity_json, mnemonic_encrypted, passkey_cred_id_hex, derivation_path ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, [ account.did, account.public_key_hex, account.created_at, account.updated_at, account.identity_json || null, account.mnemonic_encrypted || null, account.passkey_cred_id_hex || null, account.derivation_path || null ]); context.stats.accounts++; } catch (error) { context.errors.push(new Error(`Failed to migrate account ${account.did}: ${error}`)); throw error; // Re-throw to trigger transaction rollback } } } /** * Migrate contacts */ async function migrateContacts(context: MigrationContext, contacts: SQLiteContact[]): Promise { for (const contact of contacts) { try { if (!isSQLiteContact(contact)) { throw new Error(`Invalid contact data: ${JSON.stringify(contact)}`); } await context.db.exec(` INSERT INTO contacts ( id, did, name, notes, profile_image_url, public_key_base64, next_pub_key_hash_b64, sees_me, registered, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ contact.id, contact.did, contact.name || null, contact.notes || null, contact.profile_image_url || null, contact.public_key_base64 || null, contact.next_pub_key_hash_b64 || null, contact.sees_me ? 1 : 0, contact.registered ? 1 : 0, contact.created_at, contact.updated_at ]); context.stats.contacts++; } catch (error) { context.errors.push(new Error(`Failed to migrate contact ${contact.id}: ${error}`)); throw error; } } } /** * Migrate contact methods */ async function migrateContactMethods( context: MigrationContext, methods: SQLiteContactMethod[] ): Promise { for (const method of methods) { try { await context.db.exec(` INSERT INTO contact_methods ( id, contact_id, label, type, value, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?) `, [ method.id, method.contact_id, method.label, method.type, method.value, method.created_at, method.updated_at ]); context.stats.contactMethods++; } catch (error) { context.errors.push(new Error(`Failed to migrate contact method ${method.id}: ${error}`)); throw error; } } } /** * Migrate settings */ async function migrateSettings(context: MigrationContext, settings: SQLiteSettings[]): Promise { for (const setting of settings) { try { if (!isSQLiteSettings(setting)) { throw new Error(`Invalid settings data: ${JSON.stringify(setting)}`); } await context.db.exec(` INSERT INTO settings ( key, account_did, value_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?) `, [ setting.key, setting.account_did || null, setting.value_json, setting.created_at, setting.updated_at ]); context.stats.settings++; } catch (error) { context.errors.push(new Error(`Failed to migrate setting ${setting.key}: ${error}`)); throw error; } } } /** * Migrate logs */ async function migrateLogs(context: MigrationContext, logs: SQLiteLog[]): Promise { for (const log of logs) { try { await context.db.exec(` INSERT INTO logs ( id, level, message, metadata_json, created_at ) VALUES (?, ?, ?, ?, ?) `, [ log.id, log.level, log.message, log.metadata_json || null, log.created_at ]); context.stats.logs++; } catch (error) { context.errors.push(new Error(`Failed to migrate log ${log.id}: ${error}`)); throw error; } } } /** * Migrate secrets */ async function migrateSecrets(context: MigrationContext, secrets: SQLiteSecret[]): Promise { for (const secret of secrets) { try { await context.db.exec(` INSERT INTO secrets ( key, value_encrypted, created_at, updated_at ) VALUES (?, ?, ?, ?) `, [ secret.key, secret.value_encrypted, secret.created_at, secret.updated_at ]); context.stats.secrets++; } catch (error) { context.errors.push(new Error(`Failed to migrate secret ${secret.key}: ${error}`)); throw error; } } } // ============================================================================ // Verification and Rollback // ============================================================================ /** * Verify migration success */ async function verifyMigration( context: MigrationContext, data: MigrationData ): Promise<{ success: boolean; error?: string }> { try { // Verify counts const counts = await context.db.selectAll<{ table: string; count: number }>(` SELECT 'accounts' as table, COUNT(*) as count FROM accounts UNION ALL SELECT 'contacts', COUNT(*) FROM contacts UNION ALL SELECT 'contact_methods', COUNT(*) FROM contact_methods UNION ALL SELECT 'settings', COUNT(*) FROM settings UNION ALL SELECT 'logs', COUNT(*) FROM logs UNION ALL SELECT 'secrets', COUNT(*) FROM secrets `); const countMap = new Map(counts.map(c => [c.table, c.count])); if (countMap.get('accounts') !== data.accounts.length) { return { success: false, error: 'Account count mismatch' }; } if (countMap.get('contacts') !== data.contacts.length) { return { success: false, error: 'Contact count mismatch' }; } if (countMap.get('contact_methods') !== data.contactMethods.length) { return { success: false, error: 'Contact method count mismatch' }; } if (countMap.get('settings') !== data.settings.length) { return { success: false, error: 'Settings count mismatch' }; } if (countMap.get('logs') !== data.logs.length) { return { success: false, error: 'Log count mismatch' }; } if (countMap.get('secrets') !== data.secrets.length) { return { success: false, error: 'Secret count mismatch' }; } return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown verification error' }; } } /** * Rollback migration */ async function rollbackMigration(backup: Uint8Array): Promise { const { db } = await initDatabase(); try { // Close current connection await db.close(); // Restore from backup const sqlite3 = await import('@wa-sqlite/sql.js'); await sqlite3.open(backup); logger.info('[SQLite] Migration rollback successful'); } catch (error) { logger.error('[SQLite] Migration rollback failed:', error); throw new Error('Failed to rollback migration'); } }