From 3df5e19d9da9a6fbd6bc8d9c392522d05321d560 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Thu, 19 Jun 2025 11:11:59 -0600 Subject: [PATCH] IndexedDB migration: extract IndexedDB code away from the ongoing SQLite migrations --- src/services/indexedDBMigrationService.ts | 1643 +++++++++++++++++++++ src/services/migrationService.ts | 1640 +------------------- src/views/DatabaseMigration.vue | 60 +- src/views/StartView.vue | 3 +- 4 files changed, 1684 insertions(+), 1662 deletions(-) create mode 100644 src/services/indexedDBMigrationService.ts diff --git a/src/services/indexedDBMigrationService.ts b/src/services/indexedDBMigrationService.ts new file mode 100644 index 00000000..4005773f --- /dev/null +++ b/src/services/indexedDBMigrationService.ts @@ -0,0 +1,1643 @@ +/** + * 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, 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[]; +} + +/** + * 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; + } +} diff --git a/src/services/migrationService.ts b/src/services/migrationService.ts index 45fcdb95..00587967 100644 --- a/src/services/migrationService.ts +++ b/src/services/migrationService.ts @@ -1,1552 +1,8 @@ /** - * 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 + * Manage database migrations as people upgrade their app over time */ -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[]; -} - -/** - * 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], - }; -} /** * Migration interface for database schema migrations @@ -1692,97 +148,3 @@ export async function runMigrations( 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; - } -} diff --git a/src/views/DatabaseMigration.vue b/src/views/DatabaseMigration.vue index c65719f6..d836203c 100644 --- a/src/views/DatabaseMigration.vue +++ b/src/views/DatabaseMigration.vue @@ -10,6 +10,15 @@

+ +
@@ -44,12 +53,21 @@
+
+

+ Beware: you have unexpected existing data in the SQLite database that will be overwritten. You should talk with Trent. +

+
+
+
+ -
-
{{ comparison.differences.accounts.added.length @@ -454,7 +472,7 @@ class="mt-4" >

- Added Accounts ({{ comparison.differences.accounts.added.length }}): + Add Accounts ({{ comparison.differences.accounts.added.length }}):

- Added + Add
{{ comparison.differences.settings.added.length @@ -583,7 +601,7 @@ class="mt-4" >

- Added Settings ({{ comparison.differences.settings.added.length }}): + Add Settings ({{ comparison.differences.settings.added.length }}):

- Added + Add
{{ comparison.differences.contacts.added.length @@ -706,7 +724,7 @@ class="mt-4" >

- Added Contacts ({{ comparison.differences.contacts.added.length }}): + Add Contacts ({{ comparison.differences.contacts.added.length }}):

-

Database Tools

- Database Migration + Migrate My Old Data