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 @@
+
+
+
+
+ Beware: you have unexpected existing data in the SQLite database that will be overwritten. You should talk with Trent.
+
+
+
+
+
-
-
-
Added
+
Add
{{
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 }}):
{{
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 }}):
{{
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