/** * 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 { PlatformServiceFactory } from "./PlatformServiceFactory"; import { db, accountsDBPromise } from "../db/index"; import { Contact, ContactMethod } from "../db/tables/contacts"; import { Settings } from "../db/tables/settings"; import { Account } from "../db/tables/accounts"; import { logger } from "../utils/logger"; import { parseJsonField } from "../db/databaseUtil"; import { USE_DEXIE_DB } from "../constants/app"; import { importFromMnemonic } from "../libs/util"; /** * Interface for data comparison results between Dexie and SQLite databases * * 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[]; } /** * 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 { if (!USE_DEXIE_DB) { throw new Error("Dexie database is not enabled"); } 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. It requires that USE_DEXIE_DB is enabled in the app constants. * * 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 { if (!USE_DEXIE_DB) { throw new Error("Dexie database is not enabled"); } 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 || "", warnIfProdServer: setting.warnIfProdServer || false, warnIfTestServer: setting.warnIfTestServer || false, webPushServer: setting.webPushServer || "", } 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. It requires that USE_DEXIE_DB is enabled in the app constants. * * The function handles database opening and error conditions, providing * detailed logging for debugging purposes. * * @async * @function getDexieAccounts * @returns {Promise} Array of all accounts from Dexie database * @throws {Error} If Dexie database is not enabled or if database access fails * @example * ```typescript * try { * const accounts = await getDexieAccounts(); * console.log(`Retrieved ${accounts.length} accounts from Dexie`); * } catch (error) { * console.error('Failed to retrieve Dexie accounts:', error); * } * ``` */ export async function getDexieAccounts(): Promise { if (!USE_DEXIE_DB) { throw new Error("Dexie database is not enabled"); } try { const accountsDB = await accountsDBPromise; await accountsDB.open(); const accounts = await accountsDB.accounts.toArray(); logger.info( `[MigrationService] Retrieved ${accounts.length} accounts from Dexie`, ); return accounts; } catch (error) { logger.error("[MigrationService] Error retrieving Dexie accounts:", error); throw new Error(`Failed to retrieve Dexie accounts: ${error}`); } } /** * Compares data between Dexie and SQLite databases * * 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 = { comparison: { summary: { dexieContacts: comparison.dexieContacts.length, sqliteContacts: comparison.sqliteContacts.length, dexieSettings: comparison.dexieSettings.length, sqliteSettings: comparison.sqliteSettings.length, dexieAccounts: comparison.dexieAccounts.length, sqliteAccounts: comparison.sqliteAccounts.length, }, differences: { contacts: { added: comparison.differences.contacts.added.length, modified: comparison.differences.contacts.modified.length, missing: comparison.differences.contacts.missing.length, }, settings: { added: comparison.differences.settings.added.length, modified: comparison.differences.settings.modified.length, missing: comparison.differences.settings.missing.length, }, accounts: { added: comparison.differences.accounts.added.length, modified: comparison.differences.accounts.modified.length, missing: comparison.differences.accounts.missing.length, }, }, contacts: { dexie: comparison.dexieContacts.map((c) => ({ did: c.did, name: c.name, notes: c.notes, profileImageUrl: c.profileImageUrl, seesMe: c.seesMe, registered: c.registered, contactMethods: c.contactMethods, })), sqlite: comparison.sqliteContacts.map((c) => ({ did: c.did, name: c.name, notes: c.notes, profileImageUrl: c.profileImageUrl, seesMe: c.seesMe, registered: c.registered, contactMethods: c.contactMethods, })), }, settings: { dexie: comparison.dexieSettings.map((s) => ({ id: s.id, accountDid: s.accountDid, activeDid: s.activeDid, firstName: s.firstName, isRegistered: s.isRegistered, profileImageUrl: s.profileImageUrl, showShortcutBvc: s.showShortcutBvc, searchBoxes: s.searchBoxes, })), sqlite: comparison.sqliteSettings.map((s) => ({ id: s.id, accountDid: s.accountDid, activeDid: s.activeDid, firstName: s.firstName, isRegistered: s.isRegistered, profileImageUrl: s.profileImageUrl, showShortcutBvc: s.showShortcutBvc, searchBoxes: s.searchBoxes, })), }, accounts: { dexie: comparison.dexieAccounts.map((a) => ({ id: a.id, dateCreated: a.dateCreated, derivationPath: a.derivationPath, did: a.did, identity: a.identity, mnemonic: a.mnemonic, passkeyCredIdHex: a.passkeyCredIdHex, publicKeyHex: a.publicKeyHex, })), sqlite: comparison.sqliteAccounts.map((a) => ({ id: a.id, dateCreated: a.dateCreated, derivationPath: a.derivationPath, did: a.did, identity: a.identity, mnemonic: a.mnemonic, passkeyCredIdHex: a.passkeyCredIdHex, publicKeyHex: a.publicKeyHex, })), }, }, }; 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 both new settings (INSERT) and existing settings * (UPDATE) based on the overwriteExisting parameter. For updates, it * only modifies the specified fields, preserving other settings. * * @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(); // Fields to migrate - these are the most important user-facing settings const fieldsToMigrate = [ "firstName", "isRegistered", "profileImageUrl", "showShortcutBvc", "searchBoxes", ]; for (const dexieSetting of dexieSettings) { try { // Check if setting already exists const existingResult = await platformService.dbQuery( "SELECT id FROM settings WHERE id = ?", [dexieSetting.id], ); if (existingResult?.values?.length) { if (overwriteExisting) { // Update existing setting with only the specified fields const updateData: Record = {}; fieldsToMigrate.forEach((field) => { if (dexieSetting[field as keyof Settings] !== undefined) { updateData[field] = dexieSetting[field as keyof Settings]; } }); if (Object.keys(updateData).length > 0) { const { sql, params } = generateUpdateStatement( updateData as unknown as Record, "settings", "id = ?", [dexieSetting.id], ); await platformService.dbExec(sql, params); result.settingsMigrated++; logger.info( `[MigrationService] Updated settings: ${dexieSetting.id}`, ); } } else { result.warnings.push( `Settings ${dexieSetting.id} already exists, skipping`, ); } } else { // Insert new setting const { sql, params } = generateInsertStatement( dexieSetting as unknown as Record, "settings", ); await platformService.dbExec(sql, params); result.settingsMigrated++; logger.info(`[MigrationService] Added settings: ${dexieSetting.id}`); } } catch (error) { const errorMsg = `Failed to migrate settings ${dexieSetting.id}: ${error}`; logger.error("[MigrationService]", errorMsg); result.errors.push(errorMsg); result.success = false; } } logger.info("[MigrationService] Settings migration completed", { settingsMigrated: result.settingsMigrated, errors: result.errors.length, warnings: result.warnings.length, }); return result; } catch (error) { const errorMsg = `Settings migration failed: ${error}`; logger.error("[MigrationService]", errorMsg); result.errors.push(errorMsg); result.success = false; 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(); for (const account of dexieAccounts) { try { // Check if account already exists const existingResult = await platformService.dbQuery( "SELECT id FROM accounts WHERE id = ?", [account.id], ); if (existingResult?.values?.length) { if (overwriteExisting) { // Update existing account const { sql, params } = generateUpdateStatement( account as unknown as Record, "accounts", "id = ?", [account.id], ); await platformService.dbExec(sql, params); result.accountsMigrated++; logger.info(`[MigrationService] Updated account: ${account.id}`); } else { result.warnings.push( `Account ${account.id} already exists, skipping`, ); } } else { // For new accounts with mnemonic, use importFromMnemonic for proper key derivation if (account.mnemonic && account.derivationPath) { try { // Use importFromMnemonic to ensure proper key derivation and identity creation await importFromMnemonic( account.mnemonic, account.derivationPath, false, // Don't erase existing accounts during migration ); logger.info( `[MigrationService] Imported account with mnemonic: ${account.id}`, ); } catch (importError) { // Fall back to direct insertion if importFromMnemonic fails logger.warn( `[MigrationService] importFromMnemonic failed for account ${account.id}, falling back to direct insertion: ${importError}`, ); const { sql, params } = generateInsertStatement( account as unknown as Record, "accounts", ); await platformService.dbExec(sql, params); } } else { // Insert new account without mnemonic const { sql, params } = generateInsertStatement( account as unknown as Record, "accounts", ); await platformService.dbExec(sql, params); } result.accountsMigrated++; logger.info(`[MigrationService] Added account: ${account.id}`); } } catch (error) { const errorMsg = `Failed to migrate account ${account.id}: ${error}`; logger.error("[MigrationService]", errorMsg); result.errors.push(errorMsg); result.success = false; } } logger.info("[MigrationService] Account migration completed", { accountsMigrated: result.accountsMigrated, errors: result.errors.length, warnings: result.warnings.length, }); return result; } catch (error) { const errorMsg = `Account migration failed: ${error}`; logger.error("[MigrationService]", errorMsg); result.errors.push(errorMsg); result.success = false; return result; } } /** * Generates SQL INSERT statement and parameters from a model object * * 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], }; } /** * Migration interface for database schema migrations */ interface Migration { name: string; sql: string; } /** * Migration registry to store and manage database migrations */ class MigrationRegistry { private migrations: Migration[] = []; /** * Register a migration with the registry * * @param migration - The migration to register */ registerMigration(migration: Migration): void { this.migrations.push(migration); logger.info(`[MigrationService] Registered migration: ${migration.name}`); } /** * Get all registered migrations * * @returns Array of registered migrations */ getMigrations(): Migration[] { return this.migrations; } /** * Clear all registered migrations */ clearMigrations(): void { this.migrations = []; logger.info("[MigrationService] Cleared all registered migrations"); } } // Create a singleton instance of the migration registry const migrationRegistry = new MigrationRegistry(); /** * Register a migration with the migration service * * This function is used by the migration system to register database * schema migrations that need to be applied to the database. * * @param migration - The migration to register */ export function registerMigration(migration: Migration): void { migrationRegistry.registerMigration(migration); } /** * Run all registered migrations against the database * * This function executes all registered migrations in order, checking * which ones have already been applied to avoid duplicate execution. * It creates a migrations table if it doesn't exist to track applied * migrations. * * @param sqlExec - Function to execute SQL statements * @param sqlQuery - Function to query SQL data * @param extractMigrationNames - Function to extract migration names from query results * @returns Promise that resolves when all migrations are complete */ export async function runMigrations( sqlExec: (sql: string, params?: unknown[]) => Promise, sqlQuery: (sql: string, params?: unknown[]) => Promise, extractMigrationNames: (result: T) => Set, ): Promise { try { // Create migrations table if it doesn't exist await sqlExec(` CREATE TABLE IF NOT EXISTS migrations ( name TEXT PRIMARY KEY, applied_at TEXT DEFAULT CURRENT_TIMESTAMP ); `); // Get list of already applied migrations const appliedMigrationsResult = await sqlQuery( "SELECT name FROM migrations", ); const appliedMigrations = extractMigrationNames(appliedMigrationsResult); logger.info( `[MigrationService] Found ${appliedMigrations.size} applied migrations`, ); // Get all registered migrations const migrations = migrationRegistry.getMigrations(); if (migrations.length === 0) { logger.warn("[MigrationService] No migrations registered"); return; } logger.info( `[MigrationService] Running ${migrations.length} registered migrations`, ); // Run each migration that hasn't been applied yet for (const migration of migrations) { if (appliedMigrations.has(migration.name)) { logger.info( `[MigrationService] Skipping already applied migration: ${migration.name}`, ); continue; } logger.info(`[MigrationService] Applying migration: ${migration.name}`); try { // Execute the migration SQL await sqlExec(migration.sql); // Record that the migration was applied await sqlExec("INSERT INTO migrations (name) VALUES (?)", [ migration.name, ]); logger.info( `[MigrationService] Successfully applied migration: ${migration.name}`, ); } catch (error) { logger.error( `[MigrationService] Failed to apply migration ${migration.name}:`, error, ); throw new Error(`Migration ${migration.name} failed: ${error}`); } } logger.info("[MigrationService] All migrations completed successfully"); } catch (error) { logger.error("[MigrationService] Migration process failed:", error); throw error; } } /** * 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; } }