Browse Source

feat: Add comprehensive Database Migration UI component

- Create DatabaseMigration.vue with vue-facing-decorator and Tailwind CSS
- Add complete UI for comparing and migrating data between Dexie and SQLite
- Implement real-time loading states, error handling, and success feedback
- Add navigation link to Account page for easy access
- Include export functionality for comparison data
- Create comprehensive documentation in doc/database-migration-guide.md
- Fix all linting issues and ensure code quality standards
- Support both contact and settings migration with overwrite options
- Add visual difference analysis with summary cards and detailed breakdowns

The component provides a professional interface for the migrationService.ts,
enabling users to safely transfer data between database systems during
the transition from Dexie to SQLite storage.
migrate-dexie-to-sqlite
Matthew Raymer 1 week ago
parent
commit
40a2491d68
  1. 295
      doc/database-migration-guide.md
  2. 5
      src/router/index.ts
  3. 118
      src/services/migrationService.ts
  4. 6
      src/views/AccountViewView.vue
  5. 860
      src/views/DatabaseMigration.vue

295
doc/database-migration-guide.md

@ -0,0 +1,295 @@
# Database Migration Guide
## Overview
The Database Migration feature allows you to compare and migrate data between Dexie (IndexedDB) and SQLite databases in the TimeSafari application. This is particularly useful during the transition from the old Dexie-based storage system to the new SQLite-based system.
## Features
### 1. Database Comparison
- Compare data between Dexie and SQLite databases
- View detailed differences in contacts and settings
- Identify added, modified, and missing records
- Export comparison results for analysis
### 2. Data Migration
- Migrate contacts from Dexie to SQLite
- Migrate settings from Dexie to SQLite
- Option to overwrite existing records or skip them
- Comprehensive error handling and reporting
### 3. User Interface
- Modern, responsive UI built with Tailwind CSS
- Real-time loading states and progress indicators
- Clear success and error messaging
- Export functionality for comparison data
## Prerequisites
### Enable Dexie Database
Before using the migration features, you must enable the Dexie database by setting:
```typescript
// In constants/app.ts
export const USE_DEXIE_DB = true;
```
**Note**: This should only be enabled temporarily during migration. Remember to set it back to `false` after migration is complete.
## Accessing the Migration Interface
1. Navigate to the **Account** page in the TimeSafari app
2. Scroll down to find the **Database Migration** link
3. Click the link to open the migration interface
## Using the Migration Interface
### Step 1: Compare Databases
1. Click the **"Compare Databases"** button
2. The system will retrieve data from both Dexie and SQLite databases
3. Review the comparison results showing:
- Summary counts for each database
- Detailed differences (added, modified, missing records)
- Specific records that need attention
### Step 2: Review Differences
The comparison results are displayed in several sections:
#### Summary Cards
- **Dexie Contacts**: Number of contacts in Dexie database
- **SQLite Contacts**: Number of contacts in SQLite database
- **Dexie Settings**: Number of settings in Dexie database
- **SQLite Settings**: Number of settings in SQLite database
#### Contact Differences
- **Added**: Contacts in Dexie but not in SQLite
- **Modified**: Contacts that differ between databases
- **Missing**: Contacts in SQLite but not in Dexie
#### Settings Differences
- **Added**: Settings in Dexie but not in SQLite
- **Modified**: Settings that differ between databases
- **Missing**: Settings in SQLite but not in Dexie
### Step 3: Configure Migration Options
Before migrating data, configure the migration options:
- **Overwrite existing records**: When enabled, existing records in SQLite will be updated with data from Dexie. When disabled, existing records will be skipped.
### Step 4: Migrate Data
#### Migrate Contacts
1. Click the **"Migrate Contacts"** button
2. The system will transfer contacts from Dexie to SQLite
3. Review the migration results showing:
- Number of contacts successfully migrated
- Any warnings or errors encountered
#### Migrate Settings
1. Click the **"Migrate Settings"** button
2. The system will transfer settings from Dexie to SQLite
3. Review the migration results showing:
- Number of settings successfully migrated
- Any warnings or errors encountered
### Step 5: Export Comparison (Optional)
1. Click the **"Export Comparison"** button
2. A JSON file will be downloaded containing the complete comparison data
3. This file can be used for analysis or backup purposes
## Migration Process Details
### Contact Migration
The contact migration process:
1. **Retrieves** all contacts from Dexie database
2. **Checks** for existing contacts in SQLite by DID
3. **Inserts** new contacts or **updates** existing ones (if overwrite is enabled)
4. **Handles** complex fields like `contactMethods` (JSON arrays)
5. **Reports** success/failure for each contact
### Settings Migration
The settings migration process:
1. **Retrieves** all settings from Dexie database
2. **Focuses** on key user-facing settings:
- `firstName`
- `isRegistered`
- `profileImageUrl`
- `showShortcutBvc`
- `searchBoxes`
3. **Preserves** other settings in SQLite
4. **Reports** success/failure for each setting
## Error Handling
### Common Issues
#### Dexie Database Not Enabled
**Error**: "Dexie database is not enabled"
**Solution**: Set `USE_DEXIE_DB = true` in `constants/app.ts`
#### Database Connection Issues
**Error**: "Failed to retrieve Dexie contacts"
**Solution**: Check that the Dexie database is properly initialized and accessible
#### SQLite Query Errors
**Error**: "Failed to retrieve SQLite contacts"
**Solution**: Verify that the SQLite database is properly set up and the platform service is working
#### Migration Failures
**Error**: "Migration failed: [specific error]"
**Solution**: Review the error details and check data integrity in both databases
### Error Recovery
1. **Review** the error messages carefully
2. **Check** the browser console for additional details
3. **Verify** database connectivity and permissions
4. **Retry** the operation if appropriate
5. **Export** comparison data for manual review if needed
## Best Practices
### Before Migration
1. **Backup** your data if possible
2. **Test** the migration on a small dataset first
3. **Verify** that both databases are accessible
4. **Review** the comparison results before migrating
### During Migration
1. **Don't** interrupt the migration process
2. **Monitor** the progress and error messages
3. **Note** any warnings or skipped records
4. **Export** comparison data for reference
### After Migration
1. **Verify** that data was migrated correctly
2. **Test** the application functionality
3. **Disable** Dexie database (`USE_DEXIE_DB = false`)
4. **Clean up** any temporary files or exports
## Technical Details
### Database Schema
The migration handles the following data structures:
#### Contacts Table
```typescript
interface Contact {
did: string; // Decentralized Identifier
name: string; // Contact name
contactMethods: ContactMethod[]; // Array of contact methods
nextPubKeyHashB64: string; // Next public key hash
notes: string; // Contact notes
profileImageUrl: string; // Profile image URL
publicKeyBase64: string; // Public key in base64
seesMe: boolean; // Visibility flag
registered: boolean; // Registration status
}
```
#### Settings Table
```typescript
interface Settings {
id: number; // Settings ID
accountDid: string; // Account DID
activeDid: string; // Active DID
firstName: string; // User's first name
isRegistered: boolean; // Registration status
profileImageUrl: string; // Profile image URL
showShortcutBvc: boolean; // UI preference
searchBoxes: any[]; // Search configuration
// ... other fields
}
```
### Migration Logic
The migration service uses sophisticated comparison logic:
1. **Primary Key Matching**: Uses DID for contacts, ID for settings
2. **Deep Comparison**: Compares all fields including complex objects
3. **JSON Handling**: Properly handles JSON fields like `contactMethods` and `searchBoxes`
4. **Conflict Resolution**: Provides options for handling existing records
### Performance Considerations
- **Batch Processing**: Processes records one by one for reliability
- **Error Isolation**: Individual record failures don't stop the entire migration
- **Memory Management**: Handles large datasets efficiently
- **Progress Reporting**: Provides real-time feedback during migration
## Troubleshooting
### Migration Stuck
If the migration appears to be stuck:
1. **Check** the browser console for errors
2. **Refresh** the page and try again
3. **Verify** database connectivity
4. **Check** for large datasets that might take time
### Incomplete Migration
If migration doesn't complete:
1. **Review** error messages
2. **Check** data integrity in both databases
3. **Export** comparison data for manual review
4. **Consider** migrating in smaller batches
### Data Inconsistencies
If you notice data inconsistencies:
1. **Export** comparison data
2. **Review** the differences carefully
3. **Manually** verify critical records
4. **Consider** selective migration of specific records
## Support
For issues with the Database Migration feature:
1. **Check** this documentation first
2. **Review** the browser console for error details
3. **Export** comparison data for analysis
4. **Contact** the development team with specific error details
## Security Considerations
- **Data Privacy**: Migration data is processed locally and not sent to external servers
- **Access Control**: Only users with access to the account can perform migration
- **Data Integrity**: Migration preserves data integrity and handles conflicts gracefully
- **Audit Trail**: Export functionality provides an audit trail of migration operations
---
**Note**: This migration tool is designed for the transition period between database systems. Once migration is complete and verified, the Dexie database should be disabled to avoid confusion and potential data conflicts.

5
src/router/index.ts

@ -143,6 +143,11 @@ const routes: Array<RouteRecordRaw> = [
name: "logs", name: "logs",
component: () => import("../views/LogView.vue"), component: () => import("../views/LogView.vue"),
}, },
{
path: "/database-migration",
name: "database-migration",
component: () => import("../views/DatabaseMigration.vue"),
},
{ {
path: "/new-activity", path: "/new-activity",
name: "new-activity", name: "new-activity",

118
src/services/migrationService.ts

@ -123,7 +123,9 @@ export async function getDexieContacts(): Promise<Contact[]> {
try { try {
await db.open(); await db.open();
const contacts = await db.contacts.toArray(); const contacts = await db.contacts.toArray();
logger.info(`[MigrationService] Retrieved ${contacts.length} contacts from Dexie`); logger.info(
`[MigrationService] Retrieved ${contacts.length} contacts from Dexie`,
);
return contacts; return contacts;
} catch (error) { } catch (error) {
logger.error("[MigrationService] Error retrieving Dexie contacts:", error); logger.error("[MigrationService] Error retrieving Dexie contacts:", error);
@ -169,7 +171,10 @@ export async function getSqliteContacts(): Promise<Contact[]> {
return { return {
did: contact.did || "", did: contact.did || "",
name: contact.name || "", name: contact.name || "",
contactMethods: parseJsonField(contact.contactMethods, []) as ContactMethod[], contactMethods: parseJsonField(
contact.contactMethods,
[],
) as ContactMethod[],
nextPubKeyHashB64: contact.nextPubKeyHashB64 || "", nextPubKeyHashB64: contact.nextPubKeyHashB64 || "",
notes: contact.notes || "", notes: contact.notes || "",
profileImageUrl: contact.profileImageUrl || "", profileImageUrl: contact.profileImageUrl || "",
@ -179,7 +184,9 @@ export async function getSqliteContacts(): Promise<Contact[]> {
} as Contact; } as Contact;
}); });
logger.info(`[MigrationService] Retrieved ${contacts.length} contacts from SQLite`); logger.info(
`[MigrationService] Retrieved ${contacts.length} contacts from SQLite`,
);
return contacts; return contacts;
} catch (error) { } catch (error) {
logger.error("[MigrationService] Error retrieving SQLite contacts:", error); logger.error("[MigrationService] Error retrieving SQLite contacts:", error);
@ -218,7 +225,9 @@ export async function getDexieSettings(): Promise<Settings[]> {
try { try {
await db.open(); await db.open();
const settings = await db.settings.toArray(); const settings = await db.settings.toArray();
logger.info(`[MigrationService] Retrieved ${settings.length} settings from Dexie`); logger.info(
`[MigrationService] Retrieved ${settings.length} settings from Dexie`,
);
return settings; return settings;
} catch (error) { } catch (error) {
logger.error("[MigrationService] Error retrieving Dexie settings:", error); logger.error("[MigrationService] Error retrieving Dexie settings:", error);
@ -270,11 +279,13 @@ export async function getSqliteSettings(): Promise<Settings[]> {
filterFeedByVisible: setting.filterFeedByVisible || false, filterFeedByVisible: setting.filterFeedByVisible || false,
finishedOnboarding: setting.finishedOnboarding || false, finishedOnboarding: setting.finishedOnboarding || false,
firstName: setting.firstName || "", firstName: setting.firstName || "",
hideRegisterPromptOnNewContact: setting.hideRegisterPromptOnNewContact || false, hideRegisterPromptOnNewContact:
setting.hideRegisterPromptOnNewContact || false,
isRegistered: setting.isRegistered || false, isRegistered: setting.isRegistered || false,
lastName: setting.lastName || "", lastName: setting.lastName || "",
lastAckedOfferToUserJwtId: setting.lastAckedOfferToUserJwtId || "", lastAckedOfferToUserJwtId: setting.lastAckedOfferToUserJwtId || "",
lastAckedOfferToUserProjectsJwtId: setting.lastAckedOfferToUserProjectsJwtId || "", lastAckedOfferToUserProjectsJwtId:
setting.lastAckedOfferToUserProjectsJwtId || "",
lastNotifiedClaimId: setting.lastNotifiedClaimId || "", lastNotifiedClaimId: setting.lastNotifiedClaimId || "",
lastViewedClaimId: setting.lastViewedClaimId || "", lastViewedClaimId: setting.lastViewedClaimId || "",
notifyingNewActivityTime: setting.notifyingNewActivityTime || "", notifyingNewActivityTime: setting.notifyingNewActivityTime || "",
@ -294,7 +305,9 @@ export async function getSqliteSettings(): Promise<Settings[]> {
} as Settings; } as Settings;
}); });
logger.info(`[MigrationService] Retrieved ${settings.length} settings from SQLite`); logger.info(
`[MigrationService] Retrieved ${settings.length} settings from SQLite`,
);
return settings; return settings;
} catch (error) { } catch (error) {
logger.error("[MigrationService] Error retrieving SQLite settings:", error); logger.error("[MigrationService] Error retrieving SQLite settings:", error);
@ -333,7 +346,8 @@ export async function getSqliteSettings(): Promise<Settings[]> {
export async function compareDatabases(): Promise<DataComparison> { export async function compareDatabases(): Promise<DataComparison> {
logger.info("[MigrationService] Starting database comparison"); logger.info("[MigrationService] Starting database comparison");
const [dexieContacts, sqliteContacts, dexieSettings, sqliteSettings] = await Promise.all([ const [dexieContacts, sqliteContacts, dexieSettings, sqliteSettings] =
await Promise.all([
getDexieContacts(), getDexieContacts(),
getSqliteContacts(), getSqliteContacts(),
getDexieSettings(), getDexieSettings(),
@ -402,7 +416,9 @@ function compareContacts(dexieContacts: Contact[], sqliteContacts: Contact[]) {
// Find contacts that exist in Dexie but not in SQLite // Find contacts that exist in Dexie but not in SQLite
for (const dexieContact of dexieContacts) { for (const dexieContact of dexieContacts) {
const sqliteContact = sqliteContacts.find(c => c.did === dexieContact.did); const sqliteContact = sqliteContacts.find(
(c) => c.did === dexieContact.did,
);
if (!sqliteContact) { if (!sqliteContact) {
added.push(dexieContact); added.push(dexieContact);
} else if (!contactsEqual(dexieContact, sqliteContact)) { } else if (!contactsEqual(dexieContact, sqliteContact)) {
@ -412,7 +428,7 @@ function compareContacts(dexieContacts: Contact[], sqliteContacts: Contact[]) {
// Find contacts that exist in SQLite but not in Dexie // Find contacts that exist in SQLite but not in Dexie
for (const sqliteContact of sqliteContacts) { for (const sqliteContact of sqliteContacts) {
const dexieContact = dexieContacts.find(c => c.did === sqliteContact.did); const dexieContact = dexieContacts.find((c) => c.did === sqliteContact.did);
if (!dexieContact) { if (!dexieContact) {
missing.push(sqliteContact); missing.push(sqliteContact);
} }
@ -446,14 +462,17 @@ function compareContacts(dexieContacts: Contact[], sqliteContacts: Contact[]) {
* console.log(`Missing: ${differences.missing.length}`); * console.log(`Missing: ${differences.missing.length}`);
* ``` * ```
*/ */
function compareSettings(dexieSettings: Settings[], sqliteSettings: Settings[]) { function compareSettings(
dexieSettings: Settings[],
sqliteSettings: Settings[],
) {
const added: Settings[] = []; const added: Settings[] = [];
const modified: Settings[] = []; const modified: Settings[] = [];
const missing: Settings[] = []; const missing: Settings[] = [];
// Find settings that exist in Dexie but not in SQLite // Find settings that exist in Dexie but not in SQLite
for (const dexieSetting of dexieSettings) { for (const dexieSetting of dexieSettings) {
const sqliteSetting = sqliteSettings.find(s => s.id === dexieSetting.id); const sqliteSetting = sqliteSettings.find((s) => s.id === dexieSetting.id);
if (!sqliteSetting) { if (!sqliteSetting) {
added.push(dexieSetting); added.push(dexieSetting);
} else if (!settingsEqual(dexieSetting, sqliteSetting)) { } else if (!settingsEqual(dexieSetting, sqliteSetting)) {
@ -463,7 +482,7 @@ function compareSettings(dexieSettings: Settings[], sqliteSettings: Settings[])
// Find settings that exist in SQLite but not in Dexie // Find settings that exist in SQLite but not in Dexie
for (const sqliteSetting of sqliteSettings) { for (const sqliteSetting of sqliteSettings) {
const dexieSetting = dexieSettings.find(s => s.id === sqliteSetting.id); const dexieSetting = dexieSettings.find((s) => s.id === sqliteSetting.id);
if (!dexieSetting) { if (!dexieSetting) {
missing.push(sqliteSetting); missing.push(sqliteSetting);
} }
@ -506,7 +525,8 @@ function contactsEqual(contact1: Contact, contact2: Contact): boolean {
contact1.nextPubKeyHashB64 === contact2.nextPubKeyHashB64 && contact1.nextPubKeyHashB64 === contact2.nextPubKeyHashB64 &&
contact1.seesMe === contact2.seesMe && contact1.seesMe === contact2.seesMe &&
contact1.registered === contact2.registered && contact1.registered === contact2.registered &&
JSON.stringify(contact1.contactMethods) === JSON.stringify(contact2.contactMethods) JSON.stringify(contact1.contactMethods) ===
JSON.stringify(contact2.contactMethods)
); );
} }
@ -544,11 +564,14 @@ function settingsEqual(settings1: Settings, settings2: Settings): boolean {
settings1.filterFeedByVisible === settings2.filterFeedByVisible && settings1.filterFeedByVisible === settings2.filterFeedByVisible &&
settings1.finishedOnboarding === settings2.finishedOnboarding && settings1.finishedOnboarding === settings2.finishedOnboarding &&
settings1.firstName === settings2.firstName && settings1.firstName === settings2.firstName &&
settings1.hideRegisterPromptOnNewContact === settings2.hideRegisterPromptOnNewContact && settings1.hideRegisterPromptOnNewContact ===
settings2.hideRegisterPromptOnNewContact &&
settings1.isRegistered === settings2.isRegistered && settings1.isRegistered === settings2.isRegistered &&
settings1.lastName === settings2.lastName && settings1.lastName === settings2.lastName &&
settings1.lastAckedOfferToUserJwtId === settings2.lastAckedOfferToUserJwtId && settings1.lastAckedOfferToUserJwtId ===
settings1.lastAckedOfferToUserProjectsJwtId === settings2.lastAckedOfferToUserProjectsJwtId && settings2.lastAckedOfferToUserJwtId &&
settings1.lastAckedOfferToUserProjectsJwtId ===
settings2.lastAckedOfferToUserProjectsJwtId &&
settings1.lastNotifiedClaimId === settings2.lastNotifiedClaimId && settings1.lastNotifiedClaimId === settings2.lastNotifiedClaimId &&
settings1.lastViewedClaimId === settings2.lastViewedClaimId && settings1.lastViewedClaimId === settings2.lastViewedClaimId &&
settings1.notifyingNewActivityTime === settings2.notifyingNewActivityTime && settings1.notifyingNewActivityTime === settings2.notifyingNewActivityTime &&
@ -564,7 +587,8 @@ function settingsEqual(settings1: Settings, settings2: Settings): boolean {
settings1.warnIfProdServer === settings2.warnIfProdServer && settings1.warnIfProdServer === settings2.warnIfProdServer &&
settings1.warnIfTestServer === settings2.warnIfTestServer && settings1.warnIfTestServer === settings2.warnIfTestServer &&
settings1.webPushServer === settings2.webPushServer && settings1.webPushServer === settings2.webPushServer &&
JSON.stringify(settings1.searchBoxes) === JSON.stringify(settings2.searchBoxes) JSON.stringify(settings1.searchBoxes) ===
JSON.stringify(settings2.searchBoxes)
); );
} }
@ -612,7 +636,7 @@ export function generateComparisonYaml(comparison: DataComparison): string {
}, },
}, },
contacts: { contacts: {
dexie: comparison.dexieContacts.map(c => ({ dexie: comparison.dexieContacts.map((c) => ({
did: c.did, did: c.did,
name: c.name, name: c.name,
notes: c.notes, notes: c.notes,
@ -621,7 +645,7 @@ export function generateComparisonYaml(comparison: DataComparison): string {
registered: c.registered, registered: c.registered,
contactMethods: c.contactMethods, contactMethods: c.contactMethods,
})), })),
sqlite: comparison.sqliteContacts.map(c => ({ sqlite: comparison.sqliteContacts.map((c) => ({
did: c.did, did: c.did,
name: c.name, name: c.name,
notes: c.notes, notes: c.notes,
@ -632,7 +656,7 @@ export function generateComparisonYaml(comparison: DataComparison): string {
})), })),
}, },
settings: { settings: {
dexie: comparison.dexieSettings.map(s => ({ dexie: comparison.dexieSettings.map((s) => ({
id: s.id, id: s.id,
accountDid: s.accountDid, accountDid: s.accountDid,
activeDid: s.activeDid, activeDid: s.activeDid,
@ -642,7 +666,7 @@ export function generateComparisonYaml(comparison: DataComparison): string {
showShortcutBvc: s.showShortcutBvc, showShortcutBvc: s.showShortcutBvc,
searchBoxes: s.searchBoxes, searchBoxes: s.searchBoxes,
})), })),
sqlite: comparison.sqliteSettings.map(s => ({ sqlite: comparison.sqliteSettings.map((s) => ({
id: s.id, id: s.id,
accountDid: s.accountDid, accountDid: s.accountDid,
activeDid: s.activeDid, activeDid: s.activeDid,
@ -690,8 +714,12 @@ export function generateComparisonYaml(comparison: DataComparison): string {
* } * }
* ``` * ```
*/ */
export async function migrateContacts(overwriteExisting: boolean = false): Promise<MigrationResult> { export async function migrateContacts(
logger.info("[MigrationService] Starting contact migration", { overwriteExisting }); overwriteExisting: boolean = false,
): Promise<MigrationResult> {
logger.info("[MigrationService] Starting contact migration", {
overwriteExisting,
});
const result: MigrationResult = { const result: MigrationResult = {
success: true, success: true,
@ -710,7 +738,7 @@ export async function migrateContacts(overwriteExisting: boolean = false): Promi
// Check if contact already exists // Check if contact already exists
const existingResult = await platformService.dbQuery( const existingResult = await platformService.dbQuery(
"SELECT did FROM contacts WHERE did = ?", "SELECT did FROM contacts WHERE did = ?",
[contact.did] [contact.did],
); );
if (existingResult?.values?.length) { if (existingResult?.values?.length) {
@ -720,19 +748,21 @@ export async function migrateContacts(overwriteExisting: boolean = false): Promi
contact as unknown as Record<string, unknown>, contact as unknown as Record<string, unknown>,
"contacts", "contacts",
"did = ?", "did = ?",
[contact.did] [contact.did],
); );
await platformService.dbExec(sql, params); await platformService.dbExec(sql, params);
result.contactsMigrated++; result.contactsMigrated++;
logger.info(`[MigrationService] Updated contact: ${contact.did}`); logger.info(`[MigrationService] Updated contact: ${contact.did}`);
} else { } else {
result.warnings.push(`Contact ${contact.did} already exists, skipping`); result.warnings.push(
`Contact ${contact.did} already exists, skipping`,
);
} }
} else { } else {
// Insert new contact // Insert new contact
const { sql, params } = generateInsertStatement( const { sql, params } = generateInsertStatement(
contact as unknown as Record<string, unknown>, contact as unknown as Record<string, unknown>,
"contacts" "contacts",
); );
await platformService.dbExec(sql, params); await platformService.dbExec(sql, params);
result.contactsMigrated++; result.contactsMigrated++;
@ -793,8 +823,12 @@ export async function migrateContacts(overwriteExisting: boolean = false): Promi
* } * }
* ``` * ```
*/ */
export async function migrateSettings(overwriteExisting: boolean = false): Promise<MigrationResult> { export async function migrateSettings(
logger.info("[MigrationService] Starting settings migration", { overwriteExisting }); overwriteExisting: boolean = false,
): Promise<MigrationResult> {
logger.info("[MigrationService] Starting settings migration", {
overwriteExisting,
});
const result: MigrationResult = { const result: MigrationResult = {
success: true, success: true,
@ -809,21 +843,27 @@ export async function migrateSettings(overwriteExisting: boolean = false): Promi
const platformService = PlatformServiceFactory.getInstance(); const platformService = PlatformServiceFactory.getInstance();
// Fields to migrate - these are the most important user-facing settings // Fields to migrate - these are the most important user-facing settings
const fieldsToMigrate = ['firstName', 'isRegistered', 'profileImageUrl', 'showShortcutBvc', 'searchBoxes']; const fieldsToMigrate = [
"firstName",
"isRegistered",
"profileImageUrl",
"showShortcutBvc",
"searchBoxes",
];
for (const dexieSetting of dexieSettings) { for (const dexieSetting of dexieSettings) {
try { try {
// Check if setting already exists // Check if setting already exists
const existingResult = await platformService.dbQuery( const existingResult = await platformService.dbQuery(
"SELECT id FROM settings WHERE id = ?", "SELECT id FROM settings WHERE id = ?",
[dexieSetting.id] [dexieSetting.id],
); );
if (existingResult?.values?.length) { if (existingResult?.values?.length) {
if (overwriteExisting) { if (overwriteExisting) {
// Update existing setting with only the specified fields // Update existing setting with only the specified fields
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
fieldsToMigrate.forEach(field => { fieldsToMigrate.forEach((field) => {
if (dexieSetting[field as keyof Settings] !== undefined) { if (dexieSetting[field as keyof Settings] !== undefined) {
updateData[field] = dexieSetting[field as keyof Settings]; updateData[field] = dexieSetting[field as keyof Settings];
} }
@ -834,20 +874,24 @@ export async function migrateSettings(overwriteExisting: boolean = false): Promi
updateData as unknown as Record<string, unknown>, updateData as unknown as Record<string, unknown>,
"settings", "settings",
"id = ?", "id = ?",
[dexieSetting.id] [dexieSetting.id],
); );
await platformService.dbExec(sql, params); await platformService.dbExec(sql, params);
result.settingsMigrated++; result.settingsMigrated++;
logger.info(`[MigrationService] Updated settings: ${dexieSetting.id}`); logger.info(
`[MigrationService] Updated settings: ${dexieSetting.id}`,
);
} }
} else { } else {
result.warnings.push(`Settings ${dexieSetting.id} already exists, skipping`); result.warnings.push(
`Settings ${dexieSetting.id} already exists, skipping`,
);
} }
} else { } else {
// Insert new setting // Insert new setting
const { sql, params } = generateInsertStatement( const { sql, params } = generateInsertStatement(
dexieSetting as unknown as Record<string, unknown>, dexieSetting as unknown as Record<string, unknown>,
"settings" "settings",
); );
await platformService.dbExec(sql, params); await platformService.dbExec(sql, params);
result.settingsMigrated++; result.settingsMigrated++;

6
src/views/AccountViewView.vue

@ -953,6 +953,12 @@
> >
Logs Logs
</router-link> </router-link>
<router-link
:to="{ name: 'database-migration' }"
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"
>
Database Migration
</router-link>
<router-link <router-link
:to="{ name: 'test' }" :to="{ name: 'test' }"
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2" class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"

860
src/views/DatabaseMigration.vue

@ -0,0 +1,860 @@
<template>
<div class="min-h-screen bg-gray-50 py-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Database Migration</h1>
<p class="mt-2 text-gray-600">
Compare and migrate data between Dexie (IndexedDB) and SQLite
databases
</p>
</div>
<!-- Status Banner -->
<div
v-if="!isDexieEnabled"
class="mb-6 bg-yellow-50 border border-yellow-200 rounded-lg p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-yellow-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">
Dexie Database Disabled
</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>
To use migration features, enable Dexie database by setting
<code class="bg-yellow-100 px-1 rounded"
>USE_DEXIE_DB = true</code
>
in
<code class="bg-yellow-100 px-1 rounded">constants/app.ts</code>
</p>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="mb-8 flex flex-wrap gap-4">
<button
:disabled="isLoading || !isDexieEnabled"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
@click="compareDatabases"
>
<svg
v-if="isLoading"
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<svg
v-else
class="-ml-1 mr-3 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
Compare Databases
</button>
<button
:disabled="isLoading || !isDexieEnabled || !comparison"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
@click="migrateContacts"
>
<svg
class="-ml-1 mr-3 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Migrate Contacts
</button>
<button
:disabled="isLoading || !isDexieEnabled || !comparison"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 disabled:opacity-50 disabled:cursor-not-allowed"
@click="migrateSettings"
>
<svg
class="-ml-1 mr-3 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Migrate Settings
</button>
<button
:disabled="!comparison"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
@click="exportComparison"
>
<svg
class="-ml-1 mr-3 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Export Comparison
</button>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="text-center py-12">
<div
class="inline-flex items-center px-4 py-2 font-semibold leading-6 text-sm shadow rounded-md text-white bg-blue-500 hover:bg-blue-400 transition ease-in-out duration-150 cursor-not-allowed"
>
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ loadingMessage }}
</div>
</div>
<!-- Error State -->
<div
v-if="error"
class="mb-6 bg-red-50 border border-red-200 rounded-lg p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-red-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Error</h3>
<div class="mt-2 text-sm text-red-700">
<p>{{ error }}</p>
</div>
</div>
</div>
</div>
<!-- Success State -->
<div
v-if="successMessage"
class="mb-6 bg-green-50 border border-green-200 rounded-lg p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-green-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-green-800">Success</h3>
<div class="mt-2 text-sm text-green-700">
<p>{{ successMessage }}</p>
</div>
</div>
</div>
</div>
<!-- Comparison Results -->
<div v-if="comparison" class="space-y-6">
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Dexie Contacts
</dt>
<dd class="text-lg font-medium text-gray-900">
{{ comparison.dexieContacts.length }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
SQLite Contacts
</dt>
<dd class="text-lg font-medium text-gray-900">
{{ comparison.sqliteContacts.length }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
Dexie Settings
</dt>
<dd class="text-lg font-medium text-gray-900">
{{ comparison.dexieSettings.length }}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-indigo-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
SQLite Settings
</dt>
<dd class="text-lg font-medium text-gray-900">
{{ comparison.sqliteSettings.length }}
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- Differences Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Contacts Differences -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
Contact Differences
</h3>
<div class="space-y-4">
<div
class="flex items-center justify-between p-3 bg-blue-50 rounded-lg"
>
<div class="flex items-center">
<svg
class="h-5 w-5 text-blue-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
<span class="text-sm font-medium text-blue-900">Added</span>
</div>
<span class="text-sm font-bold text-blue-900">{{
comparison.differences.contacts.added.length
}}</span>
</div>
<div
class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg"
>
<div class="flex items-center">
<svg
class="h-5 w-5 text-yellow-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span class="text-sm font-medium text-yellow-900"
>Modified</span
>
</div>
<span class="text-sm font-bold text-yellow-900">{{
comparison.differences.contacts.modified.length
}}</span>
</div>
<div
class="flex items-center justify-between p-3 bg-red-50 rounded-lg"
>
<div class="flex items-center">
<svg
class="h-5 w-5 text-red-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span class="text-sm font-medium text-red-900"
>Missing</span
>
</div>
<span class="text-sm font-bold text-red-900">{{
comparison.differences.contacts.missing.length
}}</span>
</div>
</div>
<!-- Contact Details -->
<div
v-if="comparison.differences.contacts.added.length > 0"
class="mt-4"
>
<h4 class="text-sm font-medium text-gray-900 mb-2">
Added Contacts:
</h4>
<div class="max-h-32 overflow-y-auto space-y-1">
<div
v-for="contact in comparison.differences.contacts.added"
:key="contact.did"
class="text-xs text-gray-600 bg-gray-50 p-2 rounded"
>
{{ contact.name || "Unnamed" }} ({{
contact.did.substring(0, 20)
}}...)
</div>
</div>
</div>
</div>
</div>
<!-- Settings Differences -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
Settings Differences
</h3>
<div class="space-y-4">
<div
class="flex items-center justify-between p-3 bg-blue-50 rounded-lg"
>
<div class="flex items-center">
<svg
class="h-5 w-5 text-blue-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
<span class="text-sm font-medium text-blue-900">Added</span>
</div>
<span class="text-sm font-bold text-blue-900">{{
comparison.differences.settings.added.length
}}</span>
</div>
<div
class="flex items-center justify-between p-3 bg-yellow-50 rounded-lg"
>
<div class="flex items-center">
<svg
class="h-5 w-5 text-yellow-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span class="text-sm font-medium text-yellow-900"
>Modified</span
>
</div>
<span class="text-sm font-bold text-yellow-900">{{
comparison.differences.settings.modified.length
}}</span>
</div>
<div
class="flex items-center justify-between p-3 bg-red-50 rounded-lg"
>
<div class="flex items-center">
<svg
class="h-5 w-5 text-red-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span class="text-sm font-medium text-red-900"
>Missing</span
>
</div>
<span class="text-sm font-bold text-red-900">{{
comparison.differences.settings.missing.length
}}</span>
</div>
</div>
<!-- Settings Details -->
<div
v-if="comparison.differences.settings.added.length > 0"
class="mt-4"
>
<h4 class="text-sm font-medium text-gray-900 mb-2">
Added Settings:
</h4>
<div class="max-h-32 overflow-y-auto space-y-1">
<div
v-for="setting in comparison.differences.settings.added"
:key="setting.id"
class="text-xs text-gray-600 bg-gray-50 p-2 rounded"
>
ID: {{ setting.id }} - {{ setting.firstName || "Unnamed" }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Migration Options -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
Migration Options
</h3>
<div class="space-y-4">
<div class="flex items-center">
<input
id="overwrite-existing"
v-model="overwriteExisting"
type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label
for="overwrite-existing"
class="ml-2 block text-sm text-gray-900"
>
Overwrite existing records in SQLite
</label>
</div>
<p class="text-sm text-gray-600">
When enabled, existing records in SQLite will be updated with
data from Dexie. When disabled, existing records will be skipped
during migration.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import {
compareDatabases,
migrateContacts,
migrateSettings,
generateComparisonYaml,
type DataComparison,
type MigrationResult,
} from "../services/migrationService";
import { USE_DEXIE_DB } from "../constants/app";
import { logger } from "../utils/logger";
/**
* Database Migration View Component
*
* This component provides a user interface for comparing and migrating data
* between Dexie (IndexedDB) and SQLite databases. It allows users to:
*
* 1. Compare data between the two databases
* 2. View differences in contacts and settings
* 3. Migrate contacts from Dexie to SQLite
* 4. Migrate settings from Dexie to SQLite
* 5. Export comparison results
*
* The component includes comprehensive error handling, loading states,
* and user-friendly feedback for all operations.
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2024
*/
@Component({
name: "DatabaseMigration",
})
export default class DatabaseMigration extends Vue {
// Component state
private isLoading = false;
private loadingMessage = "";
private error = "";
private successMessage = "";
private comparison: DataComparison | null = null;
private overwriteExisting = false;
/**
* Computed property to check if Dexie database is enabled
*
* @returns {boolean} True if Dexie database is enabled, false otherwise
*/
get isDexieEnabled(): boolean {
return USE_DEXIE_DB;
}
/**
* Compares data between Dexie and SQLite databases
*
* This method retrieves data from both databases and identifies
* differences. It provides comprehensive feedback and error handling.
*
* @async
* @returns {Promise<void>}
*/
async compareDatabases(): Promise<void> {
this.setLoading("Comparing databases...");
this.clearMessages();
try {
this.comparison = await compareDatabases();
this.successMessage = `Comparison completed successfully. Found ${this.comparison.differences.contacts.added.length + this.comparison.differences.settings.added.length} items to migrate.`;
logger.info(
"[DatabaseMigration] Database comparison completed successfully",
);
} catch (error) {
this.error = `Failed to compare databases: ${error}`;
logger.error("[DatabaseMigration] Database comparison failed:", error);
} finally {
this.setLoading("");
}
}
/**
* Migrates contacts from Dexie to SQLite database
*
* This method transfers contacts from the Dexie database to SQLite,
* with options to overwrite existing records.
*
* @async
* @returns {Promise<void>}
*/
async migrateContacts(): Promise<void> {
this.setLoading("Migrating contacts...");
this.clearMessages();
try {
const result: MigrationResult = await migrateContacts(
this.overwriteExisting,
);
if (result.success) {
this.successMessage = `Successfully migrated ${result.contactsMigrated} contacts.`;
if (result.warnings.length > 0) {
this.successMessage += ` ${result.warnings.length} warnings.`;
}
logger.info(
"[DatabaseMigration] Contact migration completed successfully",
result,
);
} else {
this.error = `Migration failed: ${result.errors.join(", ")}`;
logger.error(
"[DatabaseMigration] Contact migration failed:",
result.errors,
);
}
} catch (error) {
this.error = `Failed to migrate contacts: ${error}`;
logger.error("[DatabaseMigration] Contact migration failed:", error);
} finally {
this.setLoading("");
}
}
/**
* Migrates settings from Dexie to SQLite database
*
* This method transfers settings from the Dexie database to SQLite,
* with options to overwrite existing records.
*
* @async
* @returns {Promise<void>}
*/
async migrateSettings(): Promise<void> {
this.setLoading("Migrating settings...");
this.clearMessages();
try {
const result: MigrationResult = await migrateSettings(
this.overwriteExisting,
);
if (result.success) {
this.successMessage = `Successfully migrated ${result.settingsMigrated} settings.`;
if (result.warnings.length > 0) {
this.successMessage += ` ${result.warnings.length} warnings.`;
}
logger.info(
"[DatabaseMigration] Settings migration completed successfully",
result,
);
} else {
this.error = `Migration failed: ${result.errors.join(", ")}`;
logger.error(
"[DatabaseMigration] Settings migration failed:",
result.errors,
);
}
} catch (error) {
this.error = `Failed to migrate settings: ${error}`;
logger.error("[DatabaseMigration] Settings migration failed:", error);
} finally {
this.setLoading("");
}
}
/**
* Exports comparison results to a file
*
* This method generates a YAML-formatted comparison and triggers
* a file download for the user.
*
* @async
* @returns {Promise<void>}
*/
async exportComparison(): Promise<void> {
if (!this.comparison) {
this.error = "No comparison data available to export";
return;
}
try {
const yamlData = generateComparisonYaml(this.comparison);
const blob = new Blob([yamlData], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `database-comparison-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
this.successMessage = "Comparison exported successfully";
logger.info("[DatabaseMigration] Comparison exported successfully");
} catch (error) {
this.error = `Failed to export comparison: ${error}`;
logger.error("[DatabaseMigration] Export failed:", error);
}
}
/**
* Sets the loading state and message
*
* @param {string} message - The loading message to display
*/
private setLoading(message: string): void {
this.isLoading = message !== "";
this.loadingMessage = message;
}
/**
* Clears all error and success messages
*/
private clearMessages(): void {
this.error = "";
this.successMessage = "";
}
}
</script>
Loading…
Cancel
Save