You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

374 lines
10 KiB

/**
* SQLite Migration Utilities
*
* This module handles the migration of data from Dexie to SQLite,
* including data transformation, validation, and rollback capabilities.
*/
import { Database } from '@wa-sqlite/sql.js';
import { initDatabase, createSchema, createBackup } from './init';
import {
MigrationData,
MigrationResult,
SQLiteAccount,
SQLiteContact,
SQLiteContactMethod,
SQLiteSettings,
SQLiteLog,
SQLiteSecret,
isSQLiteAccount,
isSQLiteContact,
isSQLiteSettings
} from './types';
import { logger } from '../../utils/logger';
// ============================================================================
// Migration Types
// ============================================================================
interface MigrationContext {
db: Database;
startTime: number;
stats: MigrationResult['stats'];
errors: Error[];
}
// ============================================================================
// Migration Functions
// ============================================================================
/**
* Migrate data from Dexie to SQLite
*/
export async function migrateFromDexie(data: MigrationData): Promise<MigrationResult> {
const startTime = Date.now();
const context: MigrationContext = {
db: (await initDatabase()).db,
startTime,
stats: {
accounts: 0,
contacts: 0,
contactMethods: 0,
settings: 0,
logs: 0,
secrets: 0
},
errors: []
};
try {
// Create backup before migration
const backup = await createBackup();
// Create schema if needed
await createSchema();
// Perform migration in a transaction
await context.db.transaction(async () => {
// Migrate in order of dependencies
await migrateAccounts(context, data.accounts);
await migrateContacts(context, data.contacts);
await migrateContactMethods(context, data.contactMethods);
await migrateSettings(context, data.settings);
await migrateLogs(context, data.logs);
await migrateSecrets(context, data.secrets);
});
// Verify migration
const verificationResult = await verifyMigration(context, data);
if (!verificationResult.success) {
throw new Error(`Migration verification failed: ${verificationResult.error}`);
}
return {
success: true,
stats: context.stats,
duration: Date.now() - startTime
};
} catch (error) {
logger.error('[SQLite] Migration failed:', error);
// Attempt rollback
try {
await rollbackMigration(backup);
} catch (rollbackError) {
logger.error('[SQLite] Rollback failed:', rollbackError);
context.errors.push(new Error('Migration and rollback failed'));
}
return {
success: false,
error: error instanceof Error ? error : new Error('Unknown migration error'),
stats: context.stats,
duration: Date.now() - startTime
};
}
}
// ============================================================================
// Migration Helpers
// ============================================================================
/**
* Migrate accounts
*/
async function migrateAccounts(context: MigrationContext, accounts: SQLiteAccount[]): Promise<void> {
for (const account of accounts) {
try {
if (!isSQLiteAccount(account)) {
throw new Error(`Invalid account data: ${JSON.stringify(account)}`);
}
await context.db.exec(`
INSERT INTO accounts (
did, public_key_hex, created_at, updated_at,
identity_json, mnemonic_encrypted, passkey_cred_id_hex, derivation_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [
account.did,
account.public_key_hex,
account.created_at,
account.updated_at,
account.identity_json || null,
account.mnemonic_encrypted || null,
account.passkey_cred_id_hex || null,
account.derivation_path || null
]);
context.stats.accounts++;
} catch (error) {
context.errors.push(new Error(`Failed to migrate account ${account.did}: ${error}`));
throw error; // Re-throw to trigger transaction rollback
}
}
}
/**
* Migrate contacts
*/
async function migrateContacts(context: MigrationContext, contacts: SQLiteContact[]): Promise<void> {
for (const contact of contacts) {
try {
if (!isSQLiteContact(contact)) {
throw new Error(`Invalid contact data: ${JSON.stringify(contact)}`);
}
await context.db.exec(`
INSERT INTO contacts (
id, did, name, notes, profile_image_url,
public_key_base64, next_pub_key_hash_b64,
sees_me, registered, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
contact.id,
contact.did,
contact.name || null,
contact.notes || null,
contact.profile_image_url || null,
contact.public_key_base64 || null,
contact.next_pub_key_hash_b64 || null,
contact.sees_me ? 1 : 0,
contact.registered ? 1 : 0,
contact.created_at,
contact.updated_at
]);
context.stats.contacts++;
} catch (error) {
context.errors.push(new Error(`Failed to migrate contact ${contact.id}: ${error}`));
throw error;
}
}
}
/**
* Migrate contact methods
*/
async function migrateContactMethods(
context: MigrationContext,
methods: SQLiteContactMethod[]
): Promise<void> {
for (const method of methods) {
try {
await context.db.exec(`
INSERT INTO contact_methods (
id, contact_id, label, type, value,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
`, [
method.id,
method.contact_id,
method.label,
method.type,
method.value,
method.created_at,
method.updated_at
]);
context.stats.contactMethods++;
} catch (error) {
context.errors.push(new Error(`Failed to migrate contact method ${method.id}: ${error}`));
throw error;
}
}
}
/**
* Migrate settings
*/
async function migrateSettings(context: MigrationContext, settings: SQLiteSettings[]): Promise<void> {
for (const setting of settings) {
try {
if (!isSQLiteSettings(setting)) {
throw new Error(`Invalid settings data: ${JSON.stringify(setting)}`);
}
await context.db.exec(`
INSERT INTO settings (
key, account_did, value_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?)
`, [
setting.key,
setting.account_did || null,
setting.value_json,
setting.created_at,
setting.updated_at
]);
context.stats.settings++;
} catch (error) {
context.errors.push(new Error(`Failed to migrate setting ${setting.key}: ${error}`));
throw error;
}
}
}
/**
* Migrate logs
*/
async function migrateLogs(context: MigrationContext, logs: SQLiteLog[]): Promise<void> {
for (const log of logs) {
try {
await context.db.exec(`
INSERT INTO logs (
id, level, message, metadata_json, created_at
) VALUES (?, ?, ?, ?, ?)
`, [
log.id,
log.level,
log.message,
log.metadata_json || null,
log.created_at
]);
context.stats.logs++;
} catch (error) {
context.errors.push(new Error(`Failed to migrate log ${log.id}: ${error}`));
throw error;
}
}
}
/**
* Migrate secrets
*/
async function migrateSecrets(context: MigrationContext, secrets: SQLiteSecret[]): Promise<void> {
for (const secret of secrets) {
try {
await context.db.exec(`
INSERT INTO secrets (
key, value_encrypted, created_at, updated_at
) VALUES (?, ?, ?, ?)
`, [
secret.key,
secret.value_encrypted,
secret.created_at,
secret.updated_at
]);
context.stats.secrets++;
} catch (error) {
context.errors.push(new Error(`Failed to migrate secret ${secret.key}: ${error}`));
throw error;
}
}
}
// ============================================================================
// Verification and Rollback
// ============================================================================
/**
* Verify migration success
*/
async function verifyMigration(
context: MigrationContext,
data: MigrationData
): Promise<{ success: boolean; error?: string }> {
try {
// Verify counts
const counts = await context.db.selectAll<{ table: string; count: number }>(`
SELECT 'accounts' as table, COUNT(*) as count FROM accounts
UNION ALL
SELECT 'contacts', COUNT(*) FROM contacts
UNION ALL
SELECT 'contact_methods', COUNT(*) FROM contact_methods
UNION ALL
SELECT 'settings', COUNT(*) FROM settings
UNION ALL
SELECT 'logs', COUNT(*) FROM logs
UNION ALL
SELECT 'secrets', COUNT(*) FROM secrets
`);
const countMap = new Map(counts.map(c => [c.table, c.count]));
if (countMap.get('accounts') !== data.accounts.length) {
return { success: false, error: 'Account count mismatch' };
}
if (countMap.get('contacts') !== data.contacts.length) {
return { success: false, error: 'Contact count mismatch' };
}
if (countMap.get('contact_methods') !== data.contactMethods.length) {
return { success: false, error: 'Contact method count mismatch' };
}
if (countMap.get('settings') !== data.settings.length) {
return { success: false, error: 'Settings count mismatch' };
}
if (countMap.get('logs') !== data.logs.length) {
return { success: false, error: 'Log count mismatch' };
}
if (countMap.get('secrets') !== data.secrets.length) {
return { success: false, error: 'Secret count mismatch' };
}
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown verification error'
};
}
}
/**
* Rollback migration
*/
async function rollbackMigration(backup: Uint8Array): Promise<void> {
const { db } = await initDatabase();
try {
// Close current connection
await db.close();
// Restore from backup
const sqlite3 = await import('@wa-sqlite/sql.js');
await sqlite3.open(backup);
logger.info('[SQLite] Migration rollback successful');
} catch (error) {
logger.error('[SQLite] Migration rollback failed:', error);
throw new Error('Failed to rollback migration');
}
}