/** * Migration Service for transferring data from Dexie (IndexedDB) to SQLite * * This service provides functions to: * 1. Compare data between Dexie and SQLite databases * 2. Transfer contacts and settings from Dexie to SQLite * 3. Generate YAML-formatted data comparisons * * The service is designed to work with the TimeSafari app's dual database architecture, * where data can exist in both Dexie (IndexedDB) and SQLite databases. This allows * for safe migration of data between the two storage systems. * * Usage: * 1. Enable Dexie temporarily by setting USE_DEXIE_DB = true in constants/app.ts * 2. Use compareDatabases() to see differences between databases * 3. Use migrateContacts() and/or migrateSettings() to transfer data * 4. Disable Dexie again after migration is complete * * @author Matthew Raymer * @version 1.0.0 * @since 2024 */ import "dexie-export-import"; import { PlatformServiceFactory } from "./PlatformServiceFactory"; import { db, accountsDBPromise } from "../db/index"; import { Contact, ContactMethod } from "../db/tables/contacts"; import { Settings, MASTER_SETTINGS_KEY, BoundingBox } from "../db/tables/settings"; import { Account } from "../db/tables/accounts"; import { logger } from "../utils/logger"; import { mapColumnsToValues, parseJsonField, generateUpdateStatement, generateInsertStatement } from "../db/databaseUtil"; import { importFromMnemonic } from "../libs/util"; /** * Interface for data comparison results between Dexie and SQLite databases * * This interface provides a comprehensive view of the differences between * the two database systems, including counts and detailed lists of * added, modified, and missing records. * * @interface DataComparison * @property {Contact[]} dexieContacts - All contacts from Dexie database * @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 * @property {Contact[]} differences.contacts.modified - Contacts that differ between databases * @property {Contact[]} differences.contacts.missing - Contacts in SQLite but not Dexie * @property {Object} differences.settings - Settings-specific differences * @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: string[]; differences: { contacts: { added: Contact[]; modified: Contact[]; unmodified: Contact[]; missing: Contact[]; }; settings: { added: Settings[]; modified: Settings[]; unmodified: Settings[]; missing: Settings[]; }; accounts: { added: Account[]; unmodified: Account[]; missing: string[]; }; }; } /** * Interface for migration operation results * * Provides detailed feedback about the success or failure of migration * operations, including counts of migrated records and any errors or * warnings that occurred during the process. * * @interface MigrationResult * @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) */ export interface MigrationResult { success: boolean; contactsMigrated: number; settingsMigrated: number; accountsMigrated: number; errors: string[]; warnings: string[]; } export async function getDexieExportBlob(): Promise { await db.open(); const blob = db.export({ prettyJson: true }); return blob; } /** * Retrieves all contacts from the Dexie (IndexedDB) database * * This function connects to the Dexie database and retrieves all contact * 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 getDexieContacts * @returns {Promise} Array of all contacts from Dexie database * @throws {Error} If Dexie database is not enabled or if database access fails * @example * ```typescript * try { * const contacts = await getDexieContacts(); * console.log(`Retrieved ${contacts.length} contacts from Dexie`); * } catch (error) { * console.error('Failed to retrieve Dexie contacts:', error); * } * ``` */ export async function getDexieContacts(): Promise { try { await db.open(); const contacts = await db.contacts.toArray(); logger.info( `[MigrationService] Retrieved ${contacts.length} contacts from Dexie`, ); return contacts; } catch (error) { logger.error("[MigrationService] Error retrieving Dexie contacts:", error); throw new Error(`Failed to retrieve Dexie contacts: ${error}`); } } /** * Retrieves all contacts from the SQLite database * * This function uses the platform service to query the SQLite database * and retrieve all contact records. It handles the conversion of raw * database results into properly typed Contact objects. * * The function also handles JSON parsing for complex fields like * contactMethods, ensuring proper type conversion. * * @async * @function getSqliteContacts * @returns {Promise} Array of all contacts from SQLite database * @throws {Error} If database query fails or data conversion fails * @example * ```typescript * try { * const contacts = await getSqliteContacts(); * console.log(`Retrieved ${contacts.length} contacts from SQLite`); * } catch (error) { * console.error('Failed to retrieve SQLite contacts:', error); * } * ``` */ export async function getSqliteContacts(): Promise { try { const platformService = PlatformServiceFactory.getInstance(); const result = await platformService.dbQuery("SELECT * FROM contacts"); let contacts: Contact[] = []; if (result?.values?.length) { const preContacts = mapColumnsToValues(result.columns, result.values) as unknown as Contact[]; // This is redundant since absurd-sql auto-parses JSON strings to objects. // But we started it, and it should be known everywhere, so we're keeping it. contacts = preContacts.map((contact) => { if (contact.contactMethods) { contact.contactMethods = parseJsonField(contact.contactMethods, []) as ContactMethod[]; } return contact; }); } logger.info( `[MigrationService] Retrieved ${contacts.length} contacts from SQLite`, ); return contacts; } catch (error) { logger.error("[MigrationService] Error retrieving SQLite contacts:", error); throw new Error(`Failed to retrieve SQLite contacts: ${error}`); } } /** * Retrieves all settings from the Dexie (IndexedDB) database * * This function connects to the Dexie database and retrieves all settings * records. * * Settings include both master settings (id=1) and account-specific settings * that override the master settings for particular user accounts. * * @async * @function getDexieSettings * @returns {Promise} Array of all settings from Dexie database * @throws {Error} If Dexie database is not enabled or if database access fails * @example * ```typescript * try { * const settings = await getDexieSettings(); * console.log(`Retrieved ${settings.length} settings from Dexie`); * } catch (error) { * console.error('Failed to retrieve Dexie settings:', error); * } * ``` */ export async function getDexieSettings(): Promise { try { await db.open(); const settings = await db.settings.toArray(); logger.info( `[MigrationService] Retrieved ${settings.length} settings from Dexie`, ); return settings; } catch (error) { logger.error("[MigrationService] Error retrieving Dexie settings:", error); throw new Error(`Failed to retrieve Dexie settings: ${error}`); } } /** * Retrieves all settings from the SQLite database * * This function uses the platform service to query the SQLite database * and retrieve all settings records. It handles the conversion of raw * database results into properly typed Settings objects. * * The function also handles JSON parsing for complex fields like * searchBoxes, ensuring proper type conversion. * * @async * @function getSqliteSettings * @returns {Promise} Array of all settings from SQLite database * @throws {Error} If database query fails or data conversion fails * @example * ```typescript * try { * const settings = await getSqliteSettings(); * console.log(`Retrieved ${settings.length} settings from SQLite`); * } catch (error) { * console.error('Failed to retrieve SQLite settings:', error); * } * ``` */ export async function getSqliteSettings(): Promise { try { const platformService = PlatformServiceFactory.getInstance(); const result = await platformService.dbQuery("SELECT * FROM settings"); let settings: Settings[] = []; if (result?.values?.length) { const presettings = mapColumnsToValues(result.columns, result.values) as Settings[]; // This is redundant since absurd-sql auto-parses JSON strings to objects. // But we started it, and it should be known everywhere, so we're keeping it. settings = presettings.map((setting) => { if (setting.searchBoxes) { setting.searchBoxes = parseJsonField(setting.searchBoxes, []) as Array<{ name: string, bbox: BoundingBox }>; } return setting; }) } logger.info( `[MigrationService] Retrieved ${settings.length} settings from SQLite`, ); return settings; } catch (error) { logger.error("[MigrationService] Error retrieving SQLite settings:", error); throw new Error(`Failed to retrieve SQLite settings: ${error}`); } } /** * 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 did FROM accounts"); let dids: string[] = []; if (result?.values?.length) { dids = result.values.map((row) => row[0] as string); } logger.info( `[MigrationService] Retrieved ${dids.length} accounts from SQLite`, ); return dids; } 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. * * 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 { 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 * * This is the main comparison function that retrieves data from both * databases and identifies differences. It provides a comprehensive * view of what data exists in each database and what needs to be * migrated. * * The function performs parallel data retrieval for efficiency and * then compares the results to identify added, modified, and missing * records in each table. * * @async * @function compareDatabases * @returns {Promise} Comprehensive comparison results * @throws {Error} If any database access fails * @example * ```typescript * try { * const comparison = await compareDatabases(); * console.log(`Dexie contacts: ${comparison.dexieContacts.length}`); * console.log(`SQLite contacts: ${comparison.sqliteContacts.length}`); * console.log(`Added contacts: ${comparison.differences.contacts.added.length}`); * } catch (error) { * console.error('Database comparison failed:', error); * } * ``` */ export async function compareDatabases(): Promise { logger.info("[MigrationService] Starting database comparison"); const [ dexieContacts, sqliteContacts, dexieSettings, sqliteSettings, dexieAccounts, sqliteAccounts, ] = await Promise.all([ getDexieContacts(), getSqliteContacts(), getDexieSettings(), getSqliteSettings(), getDexieAccounts(), getSqliteAccounts(), ]); // Compare contacts const contactDifferences = compareContacts(dexieContacts, sqliteContacts); // 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, }, }; logger.info("[MigrationService] Database comparison completed", { dexieContacts: dexieContacts.length, sqliteContacts: sqliteContacts.length, dexieSettings: dexieSettings.length, sqliteSettings: sqliteSettings.length, dexieAccounts: dexieAccounts.length, sqliteAccounts: sqliteAccounts.length, contactDifferences: contactDifferences, settingsDifferences: settingsDifferences, accountDifferences: accountDifferences, }); return comparison; } /** * Compares contacts between Dexie and SQLite databases * * This helper function analyzes two arrays of contacts and identifies * which contacts are added (in Dexie but not SQLite), modified * (different between databases), or missing (in SQLite but not Dexie). * * The comparison is based on the contact's DID (Decentralized Identifier) * as the primary key, with detailed field-by-field comparison for * modified contacts. * * @function compareContacts * @param {Contact[]} dexieContacts - Contacts from Dexie database * @param {Contact[]} sqliteContacts - Contacts from SQLite database * @returns {Object} Object containing added, modified, and missing contacts * @returns {Contact[]} returns.added - Contacts in Dexie but not SQLite * @returns {Contact[]} returns.modified - Contacts that differ between databases * @returns {Contact[]} returns.missing - Contacts in SQLite but not Dexie * @example * ```typescript * const differences = compareContacts(dexieContacts, sqliteContacts); * console.log(`Added: ${differences.added.length}`); * console.log(`Modified: ${differences.modified.length}`); * console.log(`Missing: ${differences.missing.length}`); * ``` */ function compareContacts(dexieContacts: Contact[], sqliteContacts: Contact[]) { const added: Contact[] = []; const modified: Contact[] = []; const unmodified: Contact[] = []; const missing: Contact[] = []; // Find contacts that exist in Dexie but not in SQLite for (const dexieContact of dexieContacts) { const sqliteContact = sqliteContacts.find( (c) => c.did === dexieContact.did, ); if (!sqliteContact) { added.push(dexieContact); } else if (!contactsEqual(dexieContact, sqliteContact)) { modified.push(dexieContact); } else { unmodified.push(dexieContact); } } // Find contacts that exist in SQLite but not in Dexie for (const sqliteContact of sqliteContacts) { const dexieContact = dexieContacts.find((c) => c.did === sqliteContact.did); if (!dexieContact) { missing.push(sqliteContact); } } return { added, modified, unmodified, missing }; } /** * Compares settings between Dexie and SQLite databases * * This helper function analyzes two arrays of settings and identifies * which settings are added (in Dexie but not SQLite), modified * (different between databases), or missing (in SQLite but not Dexie). * * The comparison is based on the setting's ID as the primary key, * with detailed field-by-field comparison for modified settings. * * @function compareSettings * @param {Settings[]} dexieSettings - Settings from Dexie database * @param {Settings[]} sqliteSettings - Settings from SQLite database * @returns {Object} Object containing added, modified, and missing settings * @returns {Settings[]} returns.added - Settings in Dexie but not SQLite * @returns {Settings[]} returns.modified - Settings that differ between databases * @returns {Settings[]} returns.missing - Settings in SQLite but not Dexie * @example * ```typescript * const differences = compareSettings(dexieSettings, sqliteSettings); * console.log(`Added: ${differences.added.length}`); * console.log(`Modified: ${differences.modified.length}`); * console.log(`Missing: ${differences.missing.length}`); * ``` */ function compareSettings( dexieSettings: Settings[], sqliteSettings: Settings[], ) { const added: Settings[] = []; const modified: Settings[] = []; const unmodified: Settings[] = []; const missing: Settings[] = []; // Find settings that exist in Dexie but not in SQLite for (const dexieSetting of dexieSettings) { const sqliteSetting = sqliteSettings.find((s) => s.accountDid === dexieSetting.accountDid); if (!sqliteSetting) { added.push(dexieSetting); } else if (!settingsEqual(dexieSetting, sqliteSetting)) { modified.push(dexieSetting); } else { unmodified.push(dexieSetting); } } // Find settings that exist in SQLite but not in Dexie for (const sqliteSetting of sqliteSettings) { const dexieSetting = dexieSettings.find((s) => s.accountDid === sqliteSetting.accountDid); if (!dexieSetting) { missing.push(sqliteSetting); } } return { added, modified, unmodified, 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[]} sqliteDids - 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 - always 0 because we don't check * @returns {string[]} 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[], sqliteDids: string[]) { const added: Account[] = []; const unmodified: Account[] = []; const missing: string[] = []; // Find accounts that exist in Dexie but not in SQLite for (const dexieAccount of dexieAccounts) { const sqliteDid = sqliteDids.find((a) => a === dexieAccount.did); if (!sqliteDid) { added.push(dexieAccount); } else { unmodified.push(dexieAccount); } } // Find accounts that exist in SQLite but not in Dexie for (const sqliteDid of sqliteDids) { const dexieAccount = dexieAccounts.find((a) => a.did === sqliteDid); if (!dexieAccount) { missing.push(sqliteDid); } } return { added, unmodified, missing }; } /** * Compares two contacts for equality * * This helper function performs a deep comparison of two Contact objects * to determine if they are identical. The comparison includes all * relevant fields including complex objects like contactMethods. * * For contactMethods, the function uses JSON.stringify to compare * the arrays, ensuring that both structure and content are identical. * * @function contactsEqual * @param {Contact} contact1 - First contact to compare * @param {Contact} contact2 - Second contact to compare * @returns {boolean} True if contacts are identical, false otherwise * @example * ```typescript * const areEqual = contactsEqual(contact1, contact2); * if (areEqual) { * console.log('Contacts are identical'); * } else { * console.log('Contacts differ'); * } * ``` */ function contactsEqual(contact1: Contact, contact2: Contact): boolean { return ( contact1.did == contact2.did && contact1.name == contact2.name && contact1.notes == contact2.notes && contact1.profileImageUrl == contact2.profileImageUrl && contact1.publicKeyBase64 == contact2.publicKeyBase64 && contact1.nextPubKeyHashB64 == contact2.nextPubKeyHashB64 && contact1.seesMe == contact2.seesMe && contact1.registered == contact2.registered && JSON.stringify(contact1.contactMethods) == JSON.stringify(contact2.contactMethods) ); } /** * Compares two settings for equality * * This helper function performs a deep comparison of two Settings objects * to determine if they are identical. The comparison includes all * relevant fields including complex objects like searchBoxes. * * For searchBoxes, the function uses JSON.stringify to compare * the arrays, ensuring that both structure and content are identical. * * @function settingsEqual * @param {Settings} settings1 - First settings to compare * @param {Settings} settings2 - Second settings to compare * @returns {boolean} True if settings are identical, false otherwise * @example * ```typescript * const areEqual = settingsEqual(settings1, settings2); * if (areEqual) { * console.log('Settings are identical'); * } else { * console.log('Settings differ'); * } * ``` */ function settingsEqual(settings1: Settings, settings2: Settings): boolean { return ( settings1.id == settings2.id && settings1.accountDid == settings2.accountDid && settings1.activeDid == settings2.activeDid && settings1.apiServer == settings2.apiServer && settings1.filterFeedByNearby == settings2.filterFeedByNearby && settings1.filterFeedByVisible == settings2.filterFeedByVisible && settings1.finishedOnboarding == settings2.finishedOnboarding && settings1.firstName == settings2.firstName && settings1.hideRegisterPromptOnNewContact == settings2.hideRegisterPromptOnNewContact && settings1.isRegistered == settings2.isRegistered && settings1.lastName == settings2.lastName && settings1.lastAckedOfferToUserJwtId == settings2.lastAckedOfferToUserJwtId && settings1.lastAckedOfferToUserProjectsJwtId == settings2.lastAckedOfferToUserProjectsJwtId && settings1.lastNotifiedClaimId == settings2.lastNotifiedClaimId && settings1.lastViewedClaimId == settings2.lastViewedClaimId && settings1.notifyingNewActivityTime == settings2.notifyingNewActivityTime && settings1.notifyingReminderMessage == settings2.notifyingReminderMessage && settings1.notifyingReminderTime == settings2.notifyingReminderTime && settings1.partnerApiServer == settings2.partnerApiServer && settings1.passkeyExpirationMinutes == settings2.passkeyExpirationMinutes && settings1.profileImageUrl == settings2.profileImageUrl && settings1.showContactGivesInline == settings2.showContactGivesInline && settings1.showGeneralAdvanced == settings2.showGeneralAdvanced && settings1.showShortcutBvc == settings2.showShortcutBvc && settings1.vapid == settings2.vapid && settings1.warnIfProdServer == settings2.warnIfProdServer && settings1.warnIfTestServer == settings2.warnIfTestServer && settings1.webPushServer == settings2.webPushServer && JSON.stringify(settings1.searchBoxes) == JSON.stringify(settings2.searchBoxes) ); } /** * 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 * * This function converts the database comparison results into a * structured format that can be exported and analyzed. The output * is actually JSON but formatted in a YAML-like structure for * better readability. * * The generated data includes summary statistics, detailed differences, * and the actual data from both databases for inspection purposes. * * @function generateComparisonYaml * @param {DataComparison} comparison - The comparison results to format * @returns {string} JSON string formatted for readability * @example * ```typescript * const comparison = await compareDatabases(); * const yaml = generateComparisonYaml(comparison); * console.log(yaml); * // Save to file or display in UI * ``` */ export function generateComparisonYaml(comparison: DataComparison): string { const yaml = { summary: { dexieContacts: comparison.dexieContacts.length, sqliteContacts: comparison.sqliteContacts.filter(c => c.did).length, dexieSettings: comparison.dexieSettings.length, sqliteSettings: comparison.sqliteSettings.filter(s => s.accountDid || s.activeDid).length, dexieAccounts: comparison.dexieAccounts.length, sqliteAccounts: comparison.sqliteAccounts.filter(a => a).length, }, differences: { contacts: { added: comparison.differences.contacts.added.length, modified: comparison.differences.contacts.modified.length, unmodified: comparison.differences.contacts.unmodified.length, missing: comparison.differences.contacts.missing.filter(c => c.did).length, }, settings: { added: comparison.differences.settings.added.length, modified: comparison.differences.settings.modified.length, unmodified: comparison.differences.settings.unmodified.length, missing: comparison.differences.settings.missing.filter(s => s.accountDid || s.activeDid).length, }, accounts: { added: comparison.differences.accounts.added.length, unmodified: comparison.differences.accounts.unmodified.length, missing: comparison.differences.accounts.missing.filter(a => a).length, }, }, details: { contacts: { dexie: comparison.dexieContacts.map((c) => ({ did: c.did, name: c.name || '', contactMethods: (c.contactMethods || []).length, })), sqlite: comparison.sqliteContacts .filter(c => c.did) .map((c) => ({ did: c.did, name: c.name || '', contactMethods: (c.contactMethods || []).length, })), }, settings: { dexie: comparison.dexieSettings.map((s) => ({ id: s.id, type: s.id === MASTER_SETTINGS_KEY ? 'master' : 'account', did: s.activeDid || s.accountDid, isRegistered: s.isRegistered || false, })), sqlite: comparison.sqliteSettings .filter(s => s.accountDid || s.activeDid) .map((s) => ({ id: s.id, type: s.id === MASTER_SETTINGS_KEY ? 'master' : 'account', did: s.activeDid || s.accountDid, isRegistered: s.isRegistered || false, })), }, accounts: { dexie: comparison.dexieAccounts.map((a) => ({ id: a.id, did: a.did, dateCreated: a.dateCreated, hasIdentity: !!a.identity, hasMnemonic: !!a.mnemonic, })), sqlite: comparison.sqliteAccounts.map((a) => ({ did: a, })), }, }, }; return JSON.stringify(yaml, null, 2); } /** * Migrates contacts from Dexie to SQLite database * * This function transfers all contacts from the Dexie database to the * SQLite database. It handles both new contacts (INSERT) and existing * contacts (UPDATE) based on the overwriteExisting parameter. * * The function processes contacts 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 migrateContacts * @param {boolean} [overwriteExisting=false] - Whether to overwrite existing contacts in SQLite * @returns {Promise} Detailed results of the migration operation * @throws {Error} If the migration process fails completely * @example * ```typescript * try { * const result = await migrateContacts(true); // Overwrite existing * if (result.success) { * console.log(`Successfully migrated ${result.contactsMigrated} contacts`); * } else { * console.error('Migration failed:', result.errors); * } * } catch (error) { * console.error('Migration process failed:', error); * } * ``` */ export async function migrateContacts( overwriteExisting: boolean = false, ): Promise { logger.info("[MigrationService] Starting contact migration", { overwriteExisting, }); const result: MigrationResult = { success: true, contactsMigrated: 0, settingsMigrated: 0, accountsMigrated: 0, errors: [], warnings: [], }; try { const dexieContacts = await getDexieContacts(); const platformService = PlatformServiceFactory.getInstance(); for (const contact of dexieContacts) { try { // Check if contact already exists const existingResult = await platformService.dbQuery( "SELECT did FROM contacts WHERE did = ?", [contact.did], ); if (existingResult?.values?.length) { if (overwriteExisting) { // Update existing contact const { sql, params } = generateUpdateStatement( contact as unknown as Record, "contacts", "did = ?", [contact.did], ); await platformService.dbExec(sql, params); result.contactsMigrated++; logger.info(`[MigrationService] Updated contact: ${contact.did}`); } else { result.warnings.push( `Contact ${contact.did} already exists, skipping`, ); } } else { // Insert new contact const { sql, params } = generateInsertStatement( contact as unknown as Record, "contacts", ); await platformService.dbExec(sql, params); result.contactsMigrated++; logger.info(`[MigrationService] Added contact: ${contact.did}`); } } catch (error) { const errorMsg = `Failed to migrate contact ${contact.did}: ${error}`; logger.error("[MigrationService]", errorMsg); result.errors.push(errorMsg); result.success = false; } } logger.info("[MigrationService] Contact migration completed", { contactsMigrated: result.contactsMigrated, errors: result.errors.length, warnings: result.warnings.length, }); return result; } catch (error) { const errorMsg = `Contact migration failed: ${error}`; logger.error("[MigrationService]", errorMsg); result.errors.push(errorMsg); result.success = false; return result; } } /** * Migrates specific settings fields from Dexie to SQLite database * * This function transfers specific settings fields from the Dexie database * to the SQLite database. It focuses on the most important user-facing * settings: firstName, isRegistered, profileImageUrl, showShortcutBvc, * and searchBoxes. * * The function handles duplicate settings by merging master settings (id=1) * with account-specific settings (id=2) for the same DID, preferring * the most recent values for the specified fields. * * @async * @function migrateSettings * @param {boolean} [overwriteExisting=false] - Whether to overwrite existing settings in SQLite * @returns {Promise} Detailed results of the migration operation * @throws {Error} If the migration process fails completely * @example * ```typescript * try { * const result = await migrateSettings(true); // Overwrite existing * if (result.success) { * console.log(`Successfully migrated ${result.settingsMigrated} settings`); * } else { * console.error('Migration failed:', result.errors); * } * } catch (error) { * console.error('Migration process failed:', error); * } * ``` */ export async function migrateSettings( overwriteExisting: boolean = false, ): Promise { logger.info("[MigrationService] Starting settings migration", { overwriteExisting, }); const result: MigrationResult = { success: true, contactsMigrated: 0, settingsMigrated: 0, accountsMigrated: 0, errors: [], warnings: [], }; try { const dexieSettings = await getDexieSettings(); const platformService = PlatformServiceFactory.getInstance(); // loop through dexieSettings, // load the one with the matching accountDid from sqlite, // and if one doesn't exist then insert it, // otherwise, update the fields dexieSettings.forEach(async (setting) => { const sqliteSettingRaw = await platformService.dbQuery( "SELECT * FROM settings WHERE accountDid = ?", [setting.accountDid] ); if (sqliteSettingRaw?.values?.length) { // should cover the master settings, were accountDid is null const sqliteSetting = mapColumnsToValues(sqliteSettingRaw.columns, sqliteSettingRaw.values) as unknown as Settings; let conditional: string; let preparams: unknown[]; if (!setting.accountDid) { conditional = "accountDid is null"; preparams = []; } else { conditional = "accountDid = ?"; preparams = [setting.accountDid]; } const { sql, params } = generateUpdateStatement( sqliteSetting as unknown as Record, "settings", conditional, preparams ); await platformService.dbExec(sql, params); result.settingsMigrated++; } else { // insert new setting delete setting.activeDid; // ensure we don't set the activeDid (since master settings are an update and don't hit this case) const { sql, params } = generateInsertStatement( setting as unknown as Record, "settings" ); await platformService.dbExec(sql, params); result.settingsMigrated++; } }); return result; } catch (error) { const errorMessage = `Settings migration failed: ${error}`; result.errors.push(errorMessage); result.success = false; logger.error("[MigrationService] Complete settings migration failed:", error); return result; } } /** * 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 * @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(): Promise { logger.info("[MigrationService] Starting account migration"); const result: MigrationResult = { success: true, contactsMigrated: 0, settingsMigrated: 0, accountsMigrated: 0, errors: [], warnings: [], }; try { const dexieAccounts = await getDexieAccounts(); const platformService = PlatformServiceFactory.getInstance(); // Group accounts by DID and keep only the most recent one const accountsByDid = new Map(); dexieAccounts.forEach(account => { const existingAccount = accountsByDid.get(account.did); if (!existingAccount || new Date(account.dateCreated) > new Date(existingAccount.dateCreated)) { accountsByDid.set(account.did, account); if (existingAccount) { result.warnings.push(`Found duplicate account for DID ${account.did}, keeping most recent`); } } }); // Process each unique account for (const [did, account] of accountsByDid.entries()) { try { // Check if account already exists const existingResult = await platformService.dbQuery( "SELECT did FROM accounts WHERE did = ?", [did] ); if (existingResult?.values?.length) { result.warnings.push(`Account with DID ${did} already exists, skipping`); continue; } if (account.mnemonic) { await importFromMnemonic(account.mnemonic, account.derivationPath); result.accountsMigrated++; } else { result.errors.push(`Account with DID ${did} has no mnemonic, skipping`); } logger.info("[MigrationService] Successfully migrated account", { did, dateCreated: account.dateCreated }); } catch (error) { const errorMessage = `Failed to migrate account ${did}: ${error}`; result.errors.push(errorMessage); logger.error("[MigrationService] Account migration failed:", { error, did }); } } if (result.errors.length > 0) { result.success = false; } return result; } catch (error) { const errorMessage = `Account migration failed: ${error}`; result.errors.push(errorMessage); result.success = false; logger.error("[MigrationService] Complete account migration failed:", error); return result; } } /** * Migrates all data from Dexie to SQLite in the proper order * * This function performs a complete migration of all data from Dexie to SQLite * in the correct order to avoid foreign key constraint issues: * 1. Accounts (foundational - contains DIDs) * 2. Settings (references accountDid, activeDid) * 3. Contacts (independent, but migrated after accounts for consistency) * * The migration runs within a transaction to ensure atomicity. If any step fails, * the entire migration is rolled back. * * @param overwriteExisting - Whether to overwrite existing records in SQLite * @returns Promise - Detailed result of the migration operation */ export async function migrateAll( overwriteExisting: boolean = false, ): Promise { const result: MigrationResult = { success: false, contactsMigrated: 0, settingsMigrated: 0, accountsMigrated: 0, errors: [], warnings: [], }; try { logger.info( "[MigrationService] Starting complete migration from Dexie to SQLite", ); // Step 1: Migrate Accounts (foundational) logger.info("[MigrationService] Step 1: Migrating accounts..."); const accountsResult = await migrateAccounts(); if (!accountsResult.success) { result.errors.push( `Account migration failed: ${accountsResult.errors.join(", ")}`, ); return result; } result.accountsMigrated = accountsResult.accountsMigrated; result.warnings.push(...accountsResult.warnings); // Step 2: Migrate Settings (depends on accounts) logger.info("[MigrationService] Step 2: Migrating settings..."); const settingsResult = await migrateSettings(overwriteExisting); if (!settingsResult.success) { result.errors.push( `Settings migration failed: ${settingsResult.errors.join(", ")}`, ); return result; } result.settingsMigrated = settingsResult.settingsMigrated; result.warnings.push(...settingsResult.warnings); // Step 3: Migrate Contacts (independent, but after accounts for consistency) logger.info("[MigrationService] Step 3: Migrating contacts..."); const contactsResult = await migrateContacts(overwriteExisting); if (!contactsResult.success) { result.errors.push( `Contact migration failed: ${contactsResult.errors.join(", ")}`, ); return result; } result.contactsMigrated = contactsResult.contactsMigrated; result.warnings.push(...contactsResult.warnings); // All migrations successful result.success = true; const totalMigrated = result.accountsMigrated + result.settingsMigrated + result.contactsMigrated; logger.info( `[MigrationService] Complete migration successful: ${totalMigrated} total records migrated`, { accounts: result.accountsMigrated, settings: result.settingsMigrated, contacts: result.contactsMigrated, warnings: result.warnings.length, }, ); return result; } catch (error) { const errorMessage = `Complete migration failed: ${error}`; result.errors.push(errorMessage); logger.error("[MigrationService] Complete migration failed:", error); return result; } }