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
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');
|
|
}
|
|
}
|