From 8a7f142cb7322b901c74e24910f055345fddae0c Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Thu, 19 Jun 2025 06:13:25 +0000 Subject: [PATCH] feat: integrate importFromMnemonic utility into migration service and UI - Add account migration support to migrationService with importFromMnemonic integration - Extend DataComparison and MigrationResult interfaces to include accounts - Add getDexieAccounts() and getSqliteAccounts() functions for account retrieval - Implement compareAccounts() and migrateAccounts() functions with proper error handling - Update DatabaseMigration.vue UI to support account migration: - Add "Migrate Accounts" button with lock icon - Extend summary cards grid to show Dexie/SQLite account counts - Add Account Differences section with added/modified/missing indicators - Update success message to include account migration counts - Enhance grid layouts to accommodate 6 summary cards and 3 difference sections The migration service now provides complete data migration capabilities for contacts, settings, and accounts, with enhanced reliability through the importFromMnemonic utility for mnemonic-based account handling. --- src/services/migrationService.ts | 406 ++++++++++++++++++++++++++++++- src/views/DatabaseMigration.vue | 241 +++++++++++++++++- 2 files changed, 636 insertions(+), 11 deletions(-) diff --git a/src/services/migrationService.ts b/src/services/migrationService.ts index fd08cba5..73936210 100644 --- a/src/services/migrationService.ts +++ b/src/services/migrationService.ts @@ -22,12 +22,14 @@ */ import { PlatformServiceFactory } from "./PlatformServiceFactory"; -import { db } from "../db/index"; +import { db, accountsDBPromise } from "../db/index"; import { Contact, ContactMethod } from "../db/tables/contacts"; import { Settings } from "../db/tables/settings"; +import { Account } from "../db/tables/accounts"; import { logger } from "../utils/logger"; import { parseJsonField } from "../db/databaseUtil"; import { USE_DEXIE_DB } from "../constants/app"; +import { importFromMnemonic } from "../libs/util"; /** * Interface for data comparison results between Dexie and SQLite databases @@ -41,6 +43,8 @@ import { USE_DEXIE_DB } from "../constants/app"; * @property {Contact[]} sqliteContacts - All contacts from SQLite database * @property {Settings[]} dexieSettings - All settings from Dexie database * @property {Settings[]} sqliteSettings - All settings from SQLite database + * @property {Account[]} dexieAccounts - All accounts from Dexie database + * @property {Account[]} sqliteAccounts - All accounts from SQLite database * @property {Object} differences - Detailed differences between databases * @property {Object} differences.contacts - Contact-specific differences * @property {Contact[]} differences.contacts.added - Contacts in Dexie but not SQLite @@ -50,12 +54,18 @@ import { USE_DEXIE_DB } from "../constants/app"; * @property {Settings[]} differences.settings.added - Settings in Dexie but not SQLite * @property {Settings[]} differences.settings.modified - Settings that differ between databases * @property {Settings[]} differences.settings.missing - Settings in SQLite but not Dexie + * @property {Object} differences.accounts - Account-specific differences + * @property {Account[]} differences.accounts.added - Accounts in Dexie but not SQLite + * @property {Account[]} differences.accounts.modified - Accounts that differ between databases + * @property {Account[]} differences.accounts.missing - Accounts in SQLite but not Dexie */ export interface DataComparison { dexieContacts: Contact[]; sqliteContacts: Contact[]; dexieSettings: Settings[]; sqliteSettings: Settings[]; + dexieAccounts: Account[]; + sqliteAccounts: Account[]; differences: { contacts: { added: Contact[]; @@ -67,6 +77,11 @@ export interface DataComparison { modified: Settings[]; missing: Settings[]; }; + accounts: { + added: Account[]; + modified: Account[]; + missing: Account[]; + }; }; } @@ -81,6 +96,7 @@ export interface DataComparison { * @property {boolean} success - Whether the migration operation completed successfully * @property {number} contactsMigrated - Number of contacts successfully migrated * @property {number} settingsMigrated - Number of settings successfully migrated + * @property {number} accountsMigrated - Number of accounts successfully migrated * @property {string[]} errors - Array of error messages encountered during migration * @property {string[]} warnings - Array of warning messages (non-fatal issues) */ @@ -88,6 +104,7 @@ export interface MigrationResult { success: boolean; contactsMigrated: number; settingsMigrated: number; + accountsMigrated: number; errors: string[]; warnings: string[]; } @@ -315,6 +332,105 @@ export async function getSqliteSettings(): Promise { } } +/** + * Retrieves all accounts from the SQLite database + * + * This function uses the platform service to query the SQLite database + * and retrieve all account records. It handles the conversion of raw + * database results into properly typed Account objects. + * + * The function also handles JSON parsing for complex fields like + * identity, ensuring proper type conversion. + * + * @async + * @function getSqliteAccounts + * @returns {Promise} Array of all accounts from SQLite database + * @throws {Error} If database query fails or data conversion fails + * @example + * ```typescript + * try { + * const accounts = await getSqliteAccounts(); + * console.log(`Retrieved ${accounts.length} accounts from SQLite`); + * } catch (error) { + * console.error('Failed to retrieve SQLite accounts:', error); + * } + * ``` + */ +export async function getSqliteAccounts(): Promise { + try { + const platformService = PlatformServiceFactory.getInstance(); + const result = await platformService.dbQuery("SELECT * FROM accounts"); + + if (!result?.values?.length) { + return []; + } + + const accounts = result.values.map((row) => { + const account = parseJsonField(row, {}) as any; + return { + id: account.id, + dateCreated: account.dateCreated || "", + derivationPath: account.derivationPath || "", + did: account.did || "", + identity: account.identity || "", + mnemonic: account.mnemonic || "", + passkeyCredIdHex: account.passkeyCredIdHex || "", + publicKeyHex: account.publicKeyHex || "", + } as Account; + }); + + logger.info( + `[MigrationService] Retrieved ${accounts.length} accounts from SQLite`, + ); + return accounts; + } catch (error) { + logger.error("[MigrationService] Error retrieving SQLite accounts:", error); + throw new Error(`Failed to retrieve SQLite accounts: ${error}`); + } +} + +/** + * Retrieves all accounts from the Dexie (IndexedDB) database + * + * This function connects to the Dexie database and retrieves all account + * records. It requires that USE_DEXIE_DB is enabled in the app constants. + * + * The function handles database opening and error conditions, providing + * detailed logging for debugging purposes. + * + * @async + * @function getDexieAccounts + * @returns {Promise} Array of all accounts from Dexie database + * @throws {Error} If Dexie database is not enabled or if database access fails + * @example + * ```typescript + * try { + * const accounts = await getDexieAccounts(); + * console.log(`Retrieved ${accounts.length} accounts from Dexie`); + * } catch (error) { + * console.error('Failed to retrieve Dexie accounts:', error); + * } + * ``` + */ +export async function getDexieAccounts(): Promise { + if (!USE_DEXIE_DB) { + throw new Error("Dexie database is not enabled"); + } + + try { + const accountsDB = await accountsDBPromise; + await accountsDB.open(); + const accounts = await accountsDB.accounts.toArray(); + logger.info( + `[MigrationService] Retrieved ${accounts.length} accounts from Dexie`, + ); + return accounts; + } catch (error) { + logger.error("[MigrationService] Error retrieving Dexie accounts:", error); + throw new Error(`Failed to retrieve Dexie accounts: ${error}`); + } +} + /** * Compares data between Dexie and SQLite databases * @@ -346,13 +462,21 @@ export async function getSqliteSettings(): Promise { export async function compareDatabases(): Promise { logger.info("[MigrationService] Starting database comparison"); - const [dexieContacts, sqliteContacts, dexieSettings, sqliteSettings] = - await Promise.all([ - getDexieContacts(), - getSqliteContacts(), - getDexieSettings(), - getSqliteSettings(), - ]); + const [ + dexieContacts, + sqliteContacts, + dexieSettings, + sqliteSettings, + dexieAccounts, + sqliteAccounts, + ] = await Promise.all([ + getDexieContacts(), + getSqliteContacts(), + getDexieSettings(), + getSqliteSettings(), + getDexieAccounts(), + getSqliteAccounts(), + ]); // Compare contacts const contactDifferences = compareContacts(dexieContacts, sqliteContacts); @@ -360,14 +484,20 @@ export async function compareDatabases(): Promise { // Compare settings const settingsDifferences = compareSettings(dexieSettings, sqliteSettings); + // Compare accounts + const accountDifferences = compareAccounts(dexieAccounts, sqliteAccounts); + const comparison: DataComparison = { dexieContacts, sqliteContacts, dexieSettings, sqliteSettings, + dexieAccounts, + sqliteAccounts, differences: { contacts: contactDifferences, settings: settingsDifferences, + accounts: accountDifferences, }, }; @@ -376,8 +506,11 @@ export async function compareDatabases(): Promise { sqliteContacts: sqliteContacts.length, dexieSettings: dexieSettings.length, sqliteSettings: sqliteSettings.length, + dexieAccounts: dexieAccounts.length, + sqliteAccounts: sqliteAccounts.length, contactDifferences: contactDifferences, settingsDifferences: settingsDifferences, + accountDifferences: accountDifferences, }); return comparison; @@ -491,6 +624,57 @@ function compareSettings( return { added, modified, missing }; } +/** + * Compares accounts between Dexie and SQLite databases + * + * This helper function analyzes two arrays of accounts and identifies + * which accounts are added (in Dexie but not SQLite), modified + * (different between databases), or missing (in SQLite but not Dexie). + * + * The comparison is based on the account's ID as the primary key, + * with detailed field-by-field comparison for modified accounts. + * + * @function compareAccounts + * @param {Account[]} dexieAccounts - Accounts from Dexie database + * @param {Account[]} sqliteAccounts - Accounts from SQLite database + * @returns {Object} Object containing added, modified, and missing accounts + * @returns {Account[]} returns.added - Accounts in Dexie but not SQLite + * @returns {Account[]} returns.modified - Accounts that differ between databases + * @returns {Account[]} returns.missing - Accounts in SQLite but not Dexie + * @example + * ```typescript + * const differences = compareAccounts(dexieAccounts, sqliteAccounts); + * console.log(`Added: ${differences.added.length}`); + * console.log(`Modified: ${differences.modified.length}`); + * console.log(`Missing: ${differences.missing.length}`); + * ``` + */ +function compareAccounts(dexieAccounts: Account[], sqliteAccounts: Account[]) { + const added: Account[] = []; + const modified: Account[] = []; + const missing: Account[] = []; + + // Find accounts that exist in Dexie but not in SQLite + for (const dexieAccount of dexieAccounts) { + const sqliteAccount = sqliteAccounts.find((a) => a.id === dexieAccount.id); + if (!sqliteAccount) { + added.push(dexieAccount); + } else if (!accountsEqual(dexieAccount, sqliteAccount)) { + modified.push(dexieAccount); + } + } + + // Find accounts that exist in SQLite but not in Dexie + for (const sqliteAccount of sqliteAccounts) { + const dexieAccount = dexieAccounts.find((a) => a.id === sqliteAccount.id); + if (!dexieAccount) { + missing.push(sqliteAccount); + } + } + + return { added, modified, missing }; +} + /** * Compares two contacts for equality * @@ -592,6 +776,43 @@ function settingsEqual(settings1: Settings, settings2: Settings): boolean { ); } +/** + * Compares two accounts for equality + * + * This helper function performs a deep comparison of two Account objects + * to determine if they are identical. The comparison includes all + * relevant fields including complex objects like identity. + * + * For identity, the function uses JSON.stringify to compare + * the objects, ensuring that both structure and content are identical. + * + * @function accountsEqual + * @param {Account} account1 - First account to compare + * @param {Account} account2 - Second account to compare + * @returns {boolean} True if accounts are identical, false otherwise + * @example + * ```typescript + * const areEqual = accountsEqual(account1, account2); + * if (areEqual) { + * console.log('Accounts are identical'); + * } else { + * console.log('Accounts differ'); + * } + * ``` + */ +function accountsEqual(account1: Account, account2: Account): boolean { + return ( + account1.id === account2.id && + account1.dateCreated === account2.dateCreated && + account1.derivationPath === account2.derivationPath && + account1.did === account2.did && + account1.identity === account2.identity && + account1.mnemonic === account2.mnemonic && + account1.passkeyCredIdHex === account2.passkeyCredIdHex && + account1.publicKeyHex === account2.publicKeyHex + ); +} + /** * Generates YAML-formatted comparison data * @@ -622,6 +843,8 @@ export function generateComparisonYaml(comparison: DataComparison): string { sqliteContacts: comparison.sqliteContacts.length, dexieSettings: comparison.dexieSettings.length, sqliteSettings: comparison.sqliteSettings.length, + dexieAccounts: comparison.dexieAccounts.length, + sqliteAccounts: comparison.sqliteAccounts.length, }, differences: { contacts: { @@ -634,6 +857,11 @@ export function generateComparisonYaml(comparison: DataComparison): string { modified: comparison.differences.settings.modified.length, missing: comparison.differences.settings.missing.length, }, + accounts: { + added: comparison.differences.accounts.added.length, + modified: comparison.differences.accounts.modified.length, + missing: comparison.differences.accounts.missing.length, + }, }, contacts: { dexie: comparison.dexieContacts.map((c) => ({ @@ -677,6 +905,28 @@ export function generateComparisonYaml(comparison: DataComparison): string { searchBoxes: s.searchBoxes, })), }, + accounts: { + dexie: comparison.dexieAccounts.map((a) => ({ + id: a.id, + dateCreated: a.dateCreated, + derivationPath: a.derivationPath, + did: a.did, + identity: a.identity, + mnemonic: a.mnemonic, + passkeyCredIdHex: a.passkeyCredIdHex, + publicKeyHex: a.publicKeyHex, + })), + sqlite: comparison.sqliteAccounts.map((a) => ({ + id: a.id, + dateCreated: a.dateCreated, + derivationPath: a.derivationPath, + did: a.did, + identity: a.identity, + mnemonic: a.mnemonic, + passkeyCredIdHex: a.passkeyCredIdHex, + publicKeyHex: a.publicKeyHex, + })), + }, }, }; @@ -725,6 +975,7 @@ export async function migrateContacts( success: true, contactsMigrated: 0, settingsMigrated: 0, + accountsMigrated: 0, errors: [], warnings: [], }; @@ -834,6 +1085,7 @@ export async function migrateSettings( success: true, contactsMigrated: 0, settingsMigrated: 0, + accountsMigrated: 0, errors: [], warnings: [], }; @@ -921,6 +1173,144 @@ export async function migrateSettings( } } +/** + * Migrates accounts from Dexie to SQLite database + * + * This function transfers all accounts from the Dexie database to the + * SQLite database. It handles both new accounts (INSERT) and existing + * accounts (UPDATE) based on the overwriteExisting parameter. + * + * For accounts with mnemonic data, the function uses importFromMnemonic + * to ensure proper key derivation and identity creation during migration. + * + * The function processes accounts one by one to ensure data integrity + * and provides detailed logging of the migration process. It returns + * comprehensive results including success status, counts, and any + * errors or warnings encountered. + * + * @async + * @function migrateAccounts + * @param {boolean} [overwriteExisting=false] - Whether to overwrite existing accounts in SQLite + * @returns {Promise} Detailed results of the migration operation + * @throws {Error} If the migration process fails completely + * @example + * ```typescript + * try { + * const result = await migrateAccounts(true); // Overwrite existing + * if (result.success) { + * console.log(`Successfully migrated ${result.accountsMigrated} accounts`); + * } else { + * console.error('Migration failed:', result.errors); + * } + * } catch (error) { + * console.error('Migration process failed:', error); + * } + * ``` + */ +export async function migrateAccounts( + overwriteExisting: boolean = false, +): Promise { + logger.info("[MigrationService] Starting account migration", { + overwriteExisting, + }); + + const result: MigrationResult = { + success: true, + contactsMigrated: 0, + settingsMigrated: 0, + accountsMigrated: 0, + errors: [], + warnings: [], + }; + + try { + const dexieAccounts = await getDexieAccounts(); + const platformService = PlatformServiceFactory.getInstance(); + + for (const account of dexieAccounts) { + try { + // Check if account already exists + const existingResult = await platformService.dbQuery( + "SELECT id FROM accounts WHERE id = ?", + [account.id], + ); + + if (existingResult?.values?.length) { + if (overwriteExisting) { + // Update existing account + const { sql, params } = generateUpdateStatement( + account as unknown as Record, + "accounts", + "id = ?", + [account.id], + ); + await platformService.dbExec(sql, params); + result.accountsMigrated++; + logger.info(`[MigrationService] Updated account: ${account.id}`); + } else { + result.warnings.push( + `Account ${account.id} already exists, skipping`, + ); + } + } else { + // For new accounts with mnemonic, use importFromMnemonic for proper key derivation + if (account.mnemonic && account.derivationPath) { + try { + // Use importFromMnemonic to ensure proper key derivation and identity creation + await importFromMnemonic( + account.mnemonic, + account.derivationPath, + false, // Don't erase existing accounts during migration + ); + logger.info( + `[MigrationService] Imported account with mnemonic: ${account.id}`, + ); + } catch (importError) { + // Fall back to direct insertion if importFromMnemonic fails + logger.warn( + `[MigrationService] importFromMnemonic failed for account ${account.id}, falling back to direct insertion: ${importError}`, + ); + const { sql, params } = generateInsertStatement( + account as unknown as Record, + "accounts", + ); + await platformService.dbExec(sql, params); + } + } else { + // Insert new account without mnemonic + const { sql, params } = generateInsertStatement( + account as unknown as Record, + "accounts", + ); + await platformService.dbExec(sql, params); + } + result.accountsMigrated++; + logger.info(`[MigrationService] Added account: ${account.id}`); + } + } catch (error) { + const errorMsg = `Failed to migrate account ${account.id}: ${error}`; + logger.error("[MigrationService]", errorMsg); + result.errors.push(errorMsg); + result.success = false; + } + } + + logger.info("[MigrationService] Account migration completed", { + accountsMigrated: result.accountsMigrated, + errors: result.errors.length, + warnings: result.warnings.length, + }); + + return result; + } catch (error) { + const errorMsg = `Account migration failed: ${error}`; + logger.error("[MigrationService]", errorMsg); + result.errors.push(errorMsg); + result.success = false; + return result; + } +} + /** * Generates SQL INSERT statement and parameters from a model object * diff --git a/src/views/DatabaseMigration.vue b/src/views/DatabaseMigration.vue index 7c62aa94..c80f38ab 100644 --- a/src/views/DatabaseMigration.vue +++ b/src/views/DatabaseMigration.vue @@ -140,6 +140,27 @@ Migrate Settings + +