/** * 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 } from "../db/tables/settings"; import { Account } from "../db/tables/accounts"; import { logger } from "../utils/logger"; import { parseJsonField } 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: Account[]; differences: { contacts: { added: Contact[]; modified: Contact[]; missing: Contact[]; }; settings: { added: Settings[]; modified: Settings[]; missing: Settings[]; }; accounts: { added: Account[]; modified: Account[]; missing: Account[]; }; }; } /** * 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"); if (!result?.values?.length) { return []; } const contacts = result.values.map((row) => { const contact = parseJsonField(row, {}) as Contact; return { did: contact.did || "", name: contact.name || "", contactMethods: parseJsonField( contact.contactMethods, [], ) as ContactMethod[], nextPubKeyHashB64: contact.nextPubKeyHashB64 || "", notes: contact.notes || "", profileImageUrl: contact.profileImageUrl || "", publicKeyBase64: contact.publicKeyBase64 || "", seesMe: contact.seesMe || false, registered: contact.registered || false, } as 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"); if (!result?.values?.length) { return []; } const settings = result.values.map((row) => { const setting = parseJsonField(row, {}) as Settings; return { id: setting.id, accountDid: setting.accountDid || "", activeDid: setting.activeDid || "", apiServer: setting.apiServer || "", filterFeedByNearby: setting.filterFeedByNearby || false, filterFeedByVisible: setting.filterFeedByVisible || false, finishedOnboarding: setting.finishedOnboarding || false, firstName: setting.firstName || "", hideRegisterPromptOnNewContact: setting.hideRegisterPromptOnNewContact || false, isRegistered: setting.isRegistered || false, lastName: setting.lastName || "", lastAckedOfferToUserJwtId: setting.lastAckedOfferToUserJwtId || "", lastAckedOfferToUserProjectsJwtId: setting.lastAckedOfferToUserProjectsJwtId || "", lastNotifiedClaimId: setting.lastNotifiedClaimId || "", lastViewedClaimId: setting.lastViewedClaimId || "", notifyingNewActivityTime: setting.notifyingNewActivityTime || "", notifyingReminderMessage: setting.notifyingReminderMessage || "", notifyingReminderTime: setting.notifyingReminderTime || "", partnerApiServer: setting.partnerApiServer || "", passkeyExpirationMinutes: setting.passkeyExpirationMinutes, profileImageUrl: setting.profileImageUrl || "", searchBoxes: parseJsonField(setting.searchBoxes, []), showContactGivesInline: setting.showContactGivesInline || false, showGeneralAdvanced: setting.showGeneralAdvanced || false, showShortcutBvc: setting.showShortcutBvc || false, vapid: setting.vapid || "", } as Settings; }); 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 * FROM accounts"); if (!result?.values?.length) { return []; } const accounts = result.values.map((row) => { const account = parseJsonField(row, {}) as Account; 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. * * 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 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); } } // 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, 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 missing: Settings[] = []; // Find settings that exist in Dexie but not in SQLite for (const dexieSetting of dexieSettings) { const sqliteSetting = sqliteSettings.find((s) => s.id === dexieSetting.id); if (!sqliteSetting) { added.push(dexieSetting); } else if (!settingsEqual(dexieSetting, sqliteSetting)) { modified.push(dexieSetting); } } // Find settings that exist in SQLite but not in Dexie for (const sqliteSetting of sqliteSettings) { const dexieSetting = dexieSettings.find((s) => s.id === sqliteSetting.id); if (!dexieSetting) { missing.push(sqliteSetting); } } 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 * * 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.did).length, }, differences: { contacts: { added: comparison.differences.contacts.added.length, modified: comparison.differences.contacts.modified.length, missing: comparison.differences.contacts.missing.filter(c => c.did).length, }, settings: { added: comparison.differences.settings.added.length, modified: comparison.differences.settings.modified.length, missing: comparison.differences.settings.missing.filter(s => s.accountDid || s.activeDid).length, }, accounts: { added: comparison.differences.accounts.added.length, modified: comparison.differences.accounts.modified.length, missing: comparison.differences.accounts.missing.filter(a => a.did).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 .filter(a => a.did) .map((a) => ({ id: a.id, did: a.did, dateCreated: a.dateCreated, hasIdentity: !!a.identity, hasMnemonic: !!a.mnemonic, })), }, }, }; 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(); // Group settings by DID to handle duplicates const settingsByDid = new Map(); // Organize settings by DID dexieSettings.forEach(setting => { const isMasterSetting = setting.id === MASTER_SETTINGS_KEY; const did = isMasterSetting ? setting.activeDid : setting.accountDid; if (!did) { result.warnings.push(`Setting ${setting.id} has no DID, skipping`); return; } if (!settingsByDid.has(did)) { settingsByDid.set(did, {}); } const didSettings = settingsByDid.get(did)!; if (isMasterSetting) { didSettings.master = setting; logger.info("[MigrationService] Found master settings", { did, id: setting.id, firstName: setting.firstName, isRegistered: setting.isRegistered, profileImageUrl: setting.profileImageUrl, showShortcutBvc: setting.showShortcutBvc, searchBoxes: setting.searchBoxes }); } else { didSettings.account = setting; logger.info("[MigrationService] Found account settings", { did, id: setting.id, firstName: setting.firstName, isRegistered: setting.isRegistered, profileImageUrl: setting.profileImageUrl, showShortcutBvc: setting.showShortcutBvc, searchBoxes: setting.searchBoxes }); } }); // Process each unique DID's settings for (const [did, didSettings] of settingsByDid.entries()) { try { // Process master settings if (didSettings.master) { const masterData = { id: MASTER_SETTINGS_KEY, activeDid: did, accountDid: "", // Empty for master settings apiServer: didSettings.master.apiServer || "", filterFeedByNearby: didSettings.master.filterFeedByNearby || false, filterFeedByVisible: didSettings.master.filterFeedByVisible || false, finishedOnboarding: didSettings.master.finishedOnboarding || false, firstName: didSettings.master.firstName || "", hideRegisterPromptOnNewContact: didSettings.master.hideRegisterPromptOnNewContact || false, isRegistered: didSettings.master.isRegistered || false, lastName: didSettings.master.lastName || "", profileImageUrl: didSettings.master.profileImageUrl || "", searchBoxes: didSettings.master.searchBoxes || [], showShortcutBvc: didSettings.master.showShortcutBvc || false }; // Check if master setting exists const existingMaster = await platformService.dbQuery( "SELECT id FROM settings WHERE id = ? AND activeDid = ? AND accountDid = ''", [MASTER_SETTINGS_KEY, did] ); if (existingMaster?.values?.length) { logger.info("[MigrationService] Updating master settings", { did, masterData }); await platformService.dbQuery( `UPDATE settings SET activeDid = ?, accountDid = ?, firstName = ?, isRegistered = ?, profileImageUrl = ?, showShortcutBvc = ?, searchBoxes = ? WHERE id = ?`, [ masterData.activeDid, masterData.accountDid, masterData.firstName, masterData.isRegistered, masterData.profileImageUrl, masterData.showShortcutBvc, JSON.stringify(masterData.searchBoxes), MASTER_SETTINGS_KEY ] ); } else { logger.info("[MigrationService] Inserting master settings", { did, masterData }); await platformService.dbQuery( `INSERT INTO settings ( id, activeDid, accountDid, firstName, isRegistered, profileImageUrl, showShortcutBvc, searchBoxes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ MASTER_SETTINGS_KEY, masterData.activeDid, masterData.accountDid, masterData.firstName, masterData.isRegistered, masterData.profileImageUrl, masterData.showShortcutBvc, JSON.stringify(masterData.searchBoxes) ] ); } result.settingsMigrated++; } // Process account settings if (didSettings.account) { const accountData = { id: 2, // Account settings always use id 2 activeDid: "", // Empty for account settings accountDid: did, apiServer: didSettings.account.apiServer || "", filterFeedByNearby: didSettings.account.filterFeedByNearby || false, filterFeedByVisible: didSettings.account.filterFeedByVisible || false, finishedOnboarding: didSettings.account.finishedOnboarding || false, firstName: didSettings.account.firstName || "", hideRegisterPromptOnNewContact: didSettings.account.hideRegisterPromptOnNewContact || false, isRegistered: didSettings.account.isRegistered || false, lastName: didSettings.account.lastName || "", profileImageUrl: didSettings.account.profileImageUrl || "", searchBoxes: didSettings.account.searchBoxes || [], showShortcutBvc: didSettings.account.showShortcutBvc || false }; // Check if account setting exists const existingAccount = await platformService.dbQuery( "SELECT id FROM settings WHERE id = ? AND accountDid = ? AND activeDid = ''", [2, did] ); if (existingAccount?.values?.length) { logger.info("[MigrationService] Updating account settings", { did, accountData }); await platformService.dbQuery( `UPDATE settings SET activeDid = ?, accountDid = ?, firstName = ?, isRegistered = ?, profileImageUrl = ?, showShortcutBvc = ?, searchBoxes = ? WHERE id = ?`, [ accountData.activeDid, accountData.accountDid, accountData.firstName, accountData.isRegistered, accountData.profileImageUrl, accountData.showShortcutBvc, JSON.stringify(accountData.searchBoxes), 2 ] ); } else { logger.info("[MigrationService] Inserting account settings", { did, accountData }); await platformService.dbQuery( `INSERT INTO settings ( id, activeDid, accountDid, firstName, isRegistered, profileImageUrl, showShortcutBvc, searchBoxes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ 2, accountData.activeDid, accountData.accountDid, accountData.firstName, accountData.isRegistered, accountData.profileImageUrl, accountData.showShortcutBvc, JSON.stringify(accountData.searchBoxes) ] ); } result.settingsMigrated++; } logger.info("[MigrationService] Successfully migrated settings for DID", { did, masterMigrated: !!didSettings.master, accountMigrated: !!didSettings.account }); } catch (error) { const errorMessage = `Failed to migrate settings for DID ${did}: ${error}`; result.errors.push(errorMessage); logger.error("[MigrationService] Settings migration failed:", { error, did }); } } if (result.errors.length > 0) { result.success = false; } 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 * @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(); // 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 && !overwriteExisting) { result.warnings.push(`Account with DID ${did} already exists, skipping`); continue; } // Map Dexie fields to SQLite fields const accountData = { did: account.did, dateCreated: account.dateCreated, derivationPath: account.derivationPath || "", identityEncrBase64: account.identity || "", mnemonicEncrBase64: account.mnemonic || "", passkeyCredIdHex: account.passkeyCredIdHex || "", publicKeyHex: account.publicKeyHex || "" }; // Insert or update the account if (existingResult?.values?.length) { await platformService.dbQuery( `UPDATE accounts SET dateCreated = ?, derivationPath = ?, identityEncrBase64 = ?, mnemonicEncrBase64 = ?, passkeyCredIdHex = ?, publicKeyHex = ? WHERE did = ?`, [ accountData.dateCreated, accountData.derivationPath, accountData.identityEncrBase64, accountData.mnemonicEncrBase64, accountData.passkeyCredIdHex, accountData.publicKeyHex, did ] ); } else { await platformService.dbQuery( `INSERT INTO accounts ( did, dateCreated, derivationPath, identityEncrBase64, mnemonicEncrBase64, passkeyCredIdHex, publicKeyHex ) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ did, accountData.dateCreated, accountData.derivationPath, accountData.identityEncrBase64, accountData.mnemonicEncrBase64, accountData.passkeyCredIdHex, accountData.publicKeyHex ] ); } result.accountsMigrated++; 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; } } /** * Generates SQL INSERT statement and parameters from a model object * * This helper function creates a parameterized SQL INSERT statement * from a JavaScript object. It filters out undefined values and * creates the appropriate SQL syntax with placeholders. * * The function is used internally by the migration functions to * safely insert data into the SQLite database. * * @function generateInsertStatement * @param {Record} model - The model object containing fields to insert * @param {string} tableName - The name of the table to insert into * @returns {Object} Object containing the SQL statement and parameters array * @returns {string} returns.sql - The SQL INSERT statement * @returns {unknown[]} returns.params - Array of parameter values * @example * ```typescript * const contact = { did: 'did:example:123', name: 'John Doe' }; * const { sql, params } = generateInsertStatement(contact, 'contacts'); * // sql: "INSERT INTO contacts (did, name) VALUES (?, ?)" * // params: ['did:example:123', 'John Doe'] * ``` */ function generateInsertStatement( model: Record, tableName: string, ): { sql: string; params: unknown[] } { const columns = Object.keys(model).filter((key) => model[key] !== undefined); const values = Object.values(model).filter((value) => value !== undefined); const placeholders = values.map(() => "?").join(", "); const insertSql = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`; return { sql: insertSql, params: values, }; } /** * Generates SQL UPDATE statement and parameters from a model object * * This helper function creates a parameterized SQL UPDATE statement * from a JavaScript object. It filters out undefined values and * creates the appropriate SQL syntax with placeholders. * * The function is used internally by the migration functions to * safely update data in the SQLite database. * * @function generateUpdateStatement * @param {Record} model - The model object containing fields to update * @param {string} tableName - The name of the table to update * @param {string} whereClause - The WHERE clause for the update (e.g. "id = ?") * @param {unknown[]} [whereParams=[]] - Parameters for the WHERE clause * @returns {Object} Object containing the SQL statement and parameters array * @returns {string} returns.sql - The SQL UPDATE statement * @returns {unknown[]} returns.params - Array of parameter values * @example * ```typescript * const contact = { name: 'Jane Doe' }; * const { sql, params } = generateUpdateStatement(contact, 'contacts', 'did = ?', ['did:example:123']); * // sql: "UPDATE contacts SET name = ? WHERE did = ?" * // params: ['Jane Doe', 'did:example:123'] * ``` */ function generateUpdateStatement( model: Record, tableName: string, whereClause: string, whereParams: unknown[] = [], ): { sql: string; params: unknown[] } { const setClauses: string[] = []; const params: unknown[] = []; Object.entries(model).forEach(([key, value]) => { if (value !== undefined) { setClauses.push(`${key} = ?`); params.push(value); } }); if (setClauses.length === 0) { throw new Error("No valid fields to update"); } const sql = `UPDATE ${tableName} SET ${setClauses.join(", ")} WHERE ${whereClause}`; return { sql, params: [...params, ...whereParams], }; } /** * 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(overwriteExisting); 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; } }