diff --git a/docs/dexie-to-sqlite-mapping.md b/docs/dexie-to-sqlite-mapping.md new file mode 100644 index 00000000..8ffddc90 --- /dev/null +++ b/docs/dexie-to-sqlite-mapping.md @@ -0,0 +1,389 @@ +# Dexie to SQLite Mapping Guide + +## Schema Mapping + +### Current Dexie Schema +```typescript +// Current Dexie schema +const db = new Dexie('TimeSafariDB'); + +db.version(1).stores({ + accounts: 'did, publicKeyHex, createdAt, updatedAt', + settings: 'key, value, updatedAt', + contacts: 'id, did, name, createdAt, updatedAt' +}); +``` + +### New SQLite Schema +```sql +-- New SQLite schema +CREATE TABLE accounts ( + did TEXT PRIMARY KEY, + public_key_hex TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE contacts ( + id TEXT PRIMARY KEY, + did TEXT NOT NULL, + name TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (did) REFERENCES accounts(did) +); + +-- Indexes for performance +CREATE INDEX idx_accounts_created_at ON accounts(created_at); +CREATE INDEX idx_contacts_did ON contacts(did); +CREATE INDEX idx_settings_updated_at ON settings(updated_at); +``` + +## Query Mapping + +### 1. Account Operations + +#### Get Account by DID +```typescript +// Dexie +const account = await db.accounts.get(did); + +// SQLite +const account = await db.selectOne(` + SELECT * FROM accounts WHERE did = ? +`, [did]); +``` + +#### Get All Accounts +```typescript +// Dexie +const accounts = await db.accounts.toArray(); + +// SQLite +const accounts = await db.selectAll(` + SELECT * FROM accounts ORDER BY created_at DESC +`); +``` + +#### Add Account +```typescript +// Dexie +await db.accounts.add({ + did, + publicKeyHex, + createdAt: Date.now(), + updatedAt: Date.now() +}); + +// SQLite +await db.execute(` + INSERT INTO accounts (did, public_key_hex, created_at, updated_at) + VALUES (?, ?, ?, ?) +`, [did, publicKeyHex, Date.now(), Date.now()]); +``` + +#### Update Account +```typescript +// Dexie +await db.accounts.update(did, { + publicKeyHex, + updatedAt: Date.now() +}); + +// SQLite +await db.execute(` + UPDATE accounts + SET public_key_hex = ?, updated_at = ? + WHERE did = ? +`, [publicKeyHex, Date.now(), did]); +``` + +### 2. Settings Operations + +#### Get Setting +```typescript +// Dexie +const setting = await db.settings.get(key); + +// SQLite +const setting = await db.selectOne(` + SELECT * FROM settings WHERE key = ? +`, [key]); +``` + +#### Set Setting +```typescript +// Dexie +await db.settings.put({ + key, + value, + updatedAt: Date.now() +}); + +// SQLite +await db.execute(` + INSERT INTO settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at +`, [key, value, Date.now()]); +``` + +### 3. Contact Operations + +#### Get Contacts by Account +```typescript +// Dexie +const contacts = await db.contacts + .where('did') + .equals(accountDid) + .toArray(); + +// SQLite +const contacts = await db.selectAll(` + SELECT * FROM contacts + WHERE did = ? + ORDER BY created_at DESC +`, [accountDid]); +``` + +#### Add Contact +```typescript +// Dexie +await db.contacts.add({ + id: generateId(), + did: accountDid, + name, + createdAt: Date.now(), + updatedAt: Date.now() +}); + +// SQLite +await db.execute(` + INSERT INTO contacts (id, did, name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) +`, [generateId(), accountDid, name, Date.now(), Date.now()]); +``` + +## Transaction Mapping + +### Batch Operations +```typescript +// Dexie +await db.transaction('rw', [db.accounts, db.contacts], async () => { + await db.accounts.add(account); + await db.contacts.bulkAdd(contacts); +}); + +// SQLite +await db.transaction(async (tx) => { + await tx.execute(` + INSERT INTO accounts (did, public_key_hex, created_at, updated_at) + VALUES (?, ?, ?, ?) + `, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]); + + for (const contact of contacts) { + await tx.execute(` + INSERT INTO contacts (id, did, name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]); + } +}); +``` + +## Migration Helper Functions + +### 1. Data Export (Dexie to JSON) +```typescript +async function exportDexieData(): Promise { + const db = new Dexie('TimeSafariDB'); + + return { + accounts: await db.accounts.toArray(), + settings: await db.settings.toArray(), + contacts: await db.contacts.toArray(), + metadata: { + version: '1.0.0', + timestamp: Date.now(), + dexieVersion: Dexie.version + } + }; +} +``` + +### 2. Data Import (JSON to SQLite) +```typescript +async function importToSQLite(data: MigrationData): Promise { + const db = await getSQLiteConnection(); + + await db.transaction(async (tx) => { + // Import accounts + for (const account of data.accounts) { + await tx.execute(` + INSERT INTO accounts (did, public_key_hex, created_at, updated_at) + VALUES (?, ?, ?, ?) + `, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]); + } + + // Import settings + for (const setting of data.settings) { + await tx.execute(` + INSERT INTO settings (key, value, updated_at) + VALUES (?, ?, ?) + `, [setting.key, setting.value, setting.updatedAt]); + } + + // Import contacts + for (const contact of data.contacts) { + await tx.execute(` + INSERT INTO contacts (id, did, name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]); + } + }); +} +``` + +### 3. Verification +```typescript +async function verifyMigration(dexieData: MigrationData): Promise { + const db = await getSQLiteConnection(); + + // Verify account count + const accountCount = await db.selectValue( + 'SELECT COUNT(*) FROM accounts' + ); + if (accountCount !== dexieData.accounts.length) { + return false; + } + + // Verify settings count + const settingsCount = await db.selectValue( + 'SELECT COUNT(*) FROM settings' + ); + if (settingsCount !== dexieData.settings.length) { + return false; + } + + // Verify contacts count + const contactsCount = await db.selectValue( + 'SELECT COUNT(*) FROM contacts' + ); + if (contactsCount !== dexieData.contacts.length) { + return false; + } + + // Verify data integrity + for (const account of dexieData.accounts) { + const migratedAccount = await db.selectOne( + 'SELECT * FROM accounts WHERE did = ?', + [account.did] + ); + if (!migratedAccount || + migratedAccount.public_key_hex !== account.publicKeyHex) { + return false; + } + } + + return true; +} +``` + +## Performance Considerations + +### 1. Indexing +- Dexie automatically creates indexes based on the schema +- SQLite requires explicit index creation +- Added indexes for frequently queried fields + +### 2. Batch Operations +- Dexie has built-in bulk operations +- SQLite uses transactions for batch operations +- Consider chunking large datasets + +### 3. Query Optimization +- Dexie uses IndexedDB's native indexing +- SQLite requires explicit query optimization +- Use prepared statements for repeated queries + +## Error Handling + +### 1. Common Errors +```typescript +// Dexie errors +try { + await db.accounts.add(account); +} catch (error) { + if (error instanceof Dexie.ConstraintError) { + // Handle duplicate key + } +} + +// SQLite errors +try { + await db.execute(` + INSERT INTO accounts (did, public_key_hex, created_at, updated_at) + VALUES (?, ?, ?, ?) + `, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]); +} catch (error) { + if (error.code === 'SQLITE_CONSTRAINT') { + // Handle duplicate key + } +} +``` + +### 2. Transaction Recovery +```typescript +// Dexie transaction +try { + await db.transaction('rw', db.accounts, async () => { + // Operations + }); +} catch (error) { + // Dexie automatically rolls back +} + +// SQLite transaction +const db = await getSQLiteConnection(); +try { + await db.transaction(async (tx) => { + // Operations + }); +} catch (error) { + // SQLite automatically rolls back + await db.execute('ROLLBACK'); +} +``` + +## Migration Strategy + +1. **Preparation** + - Export all Dexie data + - Verify data integrity + - Create SQLite schema + - Setup indexes + +2. **Migration** + - Import data in transactions + - Verify each batch + - Handle errors gracefully + - Maintain backup + +3. **Verification** + - Compare record counts + - Verify data integrity + - Test common queries + - Validate relationships + +4. **Cleanup** + - Remove Dexie database + - Clear IndexedDB storage + - Update application code + - Remove old dependencies \ No newline at end of file diff --git a/docs/storage-implementation-checklist.md b/docs/storage-implementation-checklist.md new file mode 100644 index 00000000..b608dfcf --- /dev/null +++ b/docs/storage-implementation-checklist.md @@ -0,0 +1,306 @@ +# Storage Implementation Checklist + +## Core Services + +### 1. Storage Service Layer +- [ ] Create base `StorageService` interface + - [ ] Define common methods for all platforms + - [ ] Add platform-specific method signatures + - [ ] Include error handling types + - [ ] Add migration support methods + +- [ ] Implement platform-specific services + - [ ] `WebSQLiteService` (wa-sqlite) + - [ ] Database initialization + - [ ] VFS setup + - [ ] Connection management + - [ ] Query builder + - [ ] `NativeSQLiteService` (iOS/Android) + - [ ] SQLCipher integration + - [ ] Native bridge setup + - [ ] File system access + - [ ] `ElectronSQLiteService` + - [ ] Node SQLite integration + - [ ] IPC communication + - [ ] File system access + +### 2. Migration Services +- [ ] Implement `MigrationService` + - [ ] Backup creation + - [ ] Data verification + - [ ] Rollback procedures + - [ ] Progress tracking +- [ ] Create `MigrationUI` components + - [ ] Progress indicators + - [ ] Error handling + - [ ] User notifications + - [ ] Manual triggers + +### 3. Security Layer +- [ ] Implement `EncryptionService` + - [ ] Key management + - [ ] Encryption/decryption + - [ ] Secure storage +- [ ] Add `BiometricService` + - [ ] Platform detection + - [ ] Authentication flow + - [ ] Fallback mechanisms + +## Platform-Specific Implementation + +### Web Platform +- [ ] Setup wa-sqlite + - [ ] Install dependencies + ```json + { + "@wa-sqlite/sql.js": "^0.8.12", + "@wa-sqlite/sql.js-httpvfs": "^0.8.12" + } + ``` + - [ ] Configure VFS + - [ ] Setup worker threads + - [ ] Implement connection pooling + +- [ ] Update build configuration + - [ ] Modify `vite.config.ts` + - [ ] Add worker configuration + - [ ] Update chunk splitting + - [ ] Configure asset handling + +- [ ] Implement IndexedDB fallback + - [ ] Create fallback service + - [ ] Add data synchronization + - [ ] Handle quota exceeded + +### iOS Platform +- [ ] Setup SQLCipher + - [ ] Install pod dependencies + - [ ] Configure encryption + - [ ] Setup keychain access + - [ ] Implement secure storage + +- [ ] Update Capacitor config + - [ ] Modify `capacitor.config.ts` + - [ ] Add iOS permissions + - [ ] Configure backup + - [ ] Setup app groups + +### Android Platform +- [ ] Setup SQLCipher + - [ ] Add Gradle dependencies + - [ ] Configure encryption + - [ ] Setup keystore + - [ ] Implement secure storage + +- [ ] Update Capacitor config + - [ ] Modify `capacitor.config.ts` + - [ ] Add Android permissions + - [ ] Configure backup + - [ ] Setup file provider + +### Electron Platform +- [ ] Setup Node SQLite + - [ ] Install dependencies + - [ ] Configure IPC + - [ ] Setup file system access + - [ ] Implement secure storage + +- [ ] Update Electron config + - [ ] Modify `electron.config.ts` + - [ ] Add security policies + - [ ] Configure file access + - [ ] Setup auto-updates + +## Data Models and Types + +### 1. Database Schema +- [ ] Define tables + ```sql + -- Accounts table + CREATE TABLE accounts ( + did TEXT PRIMARY KEY, + public_key_hex TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + -- Settings table + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + + -- Contacts table + CREATE TABLE contacts ( + id TEXT PRIMARY KEY, + did TEXT NOT NULL, + name TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (did) REFERENCES accounts(did) + ); + ``` + +- [ ] Create indexes +- [ ] Define constraints +- [ ] Add triggers +- [ ] Setup migrations + +### 2. Type Definitions +- [ ] Create interfaces + ```typescript + interface Account { + did: string; + publicKeyHex: string; + createdAt: number; + updatedAt: number; + } + + interface Setting { + key: string; + value: string; + updatedAt: number; + } + + interface Contact { + id: string; + did: string; + name?: string; + createdAt: number; + updatedAt: number; + } + ``` + +- [ ] Add validation +- [ ] Create DTOs +- [ ] Define enums +- [ ] Add type guards + +## UI Components + +### 1. Migration UI +- [ ] Create components + - [ ] `MigrationProgress.vue` + - [ ] `MigrationError.vue` + - [ ] `MigrationSettings.vue` + - [ ] `MigrationStatus.vue` + +### 2. Settings UI +- [ ] Update components + - [ ] Add storage settings + - [ ] Add migration controls + - [ ] Add backup options + - [ ] Add security settings + +### 3. Error Handling UI +- [ ] Create components + - [ ] `StorageError.vue` + - [ ] `QuotaExceeded.vue` + - [ ] `MigrationFailed.vue` + - [ ] `RecoveryOptions.vue` + +## Testing + +### 1. Unit Tests +- [ ] Test services + - [ ] Storage service tests + - [ ] Migration service tests + - [ ] Security service tests + - [ ] Platform detection tests + +### 2. Integration Tests +- [ ] Test migrations + - [ ] Web platform tests + - [ ] iOS platform tests + - [ ] Android platform tests + - [ ] Electron platform tests + +### 3. E2E Tests +- [ ] Test workflows + - [ ] Account management + - [ ] Settings management + - [ ] Contact management + - [ ] Migration process + +## Documentation + +### 1. Technical Documentation +- [ ] Update architecture docs +- [ ] Add API documentation +- [ ] Create migration guides +- [ ] Document security measures + +### 2. User Documentation +- [ ] Update user guides +- [ ] Add troubleshooting guides +- [ ] Create FAQ +- [ ] Document new features + +## Deployment + +### 1. Build Process +- [ ] Update build scripts +- [ ] Add platform-specific builds +- [ ] Configure CI/CD +- [ ] Setup automated testing + +### 2. Release Process +- [ ] Create release checklist +- [ ] Add version management +- [ ] Setup rollback procedures +- [ ] Configure monitoring + +## Monitoring and Analytics + +### 1. Error Tracking +- [ ] Setup error logging +- [ ] Add performance monitoring +- [ ] Configure alerts +- [ ] Create dashboards + +### 2. Usage Analytics +- [ ] Add storage metrics +- [ ] Track migration success +- [ ] Monitor performance +- [ ] Collect user feedback + +## Security Audit + +### 1. Code Review +- [ ] Review encryption +- [ ] Check access controls +- [ ] Verify data handling +- [ ] Audit dependencies + +### 2. Penetration Testing +- [ ] Test data access +- [ ] Verify encryption +- [ ] Check authentication +- [ ] Review permissions + +## Success Criteria + +### 1. Performance +- [ ] Query response time < 100ms +- [ ] Migration time < 5s per 1000 records +- [ ] Storage overhead < 10% +- [ ] Memory usage < 50MB + +### 2. Reliability +- [ ] 99.9% uptime +- [ ] Zero data loss +- [ ] Automatic recovery +- [ ] Backup verification + +### 3. Security +- [ ] AES-256 encryption +- [ ] Secure key storage +- [ ] Access control +- [ ] Audit logging + +### 4. User Experience +- [ ] Smooth migration +- [ ] Clear error messages +- [ ] Progress indicators +- [ ] Recovery options \ No newline at end of file diff --git a/main.js b/main.js deleted file mode 100644 index 281eacf2..00000000 --- a/main.js +++ /dev/null @@ -1,29 +0,0 @@ -const { app, BrowserWindow } = require('electron'); -const path = require('path'); - -function createWindow() { - const win = new BrowserWindow({ - width: 1200, - height: 800, - webPreferences: { - nodeIntegration: true, - contextIsolation: false - } - }); - - win.loadFile(path.join(__dirname, 'dist-electron/www/index.html')); -} - -app.whenReady().then(createWindow); - -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } -}); - -app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } -}); \ No newline at end of file diff --git a/src/db/sqlite/init.ts b/src/db/sqlite/init.ts new file mode 100644 index 00000000..856971d9 --- /dev/null +++ b/src/db/sqlite/init.ts @@ -0,0 +1,293 @@ +/** + * SQLite Database Initialization + * + * This module handles database initialization, including: + * - Database connection management + * - Schema creation and migration + * - Connection pooling and lifecycle + * - Error handling and recovery + */ + +import { Database, SQLite3 } from '@wa-sqlite/sql.js'; +import { DATABASE_SCHEMA, SQLiteTable } from './types'; +import { logger } from '../../utils/logger'; + +// ============================================================================ +// Database Connection Management +// ============================================================================ + +export interface DatabaseConnection { + db: Database; + sqlite3: SQLite3; + isOpen: boolean; + lastUsed: number; +} + +let connection: DatabaseConnection | null = null; +const CONNECTION_TIMEOUT = 5 * 60 * 1000; // 5 minutes + +/** + * Initialize the SQLite database connection + */ +export async function initDatabase(): Promise { + if (connection?.isOpen) { + connection.lastUsed = Date.now(); + return connection; + } + + try { + const sqlite3 = await import('@wa-sqlite/sql.js'); + const db = await sqlite3.open(':memory:'); // TODO: Configure storage location + + // Enable foreign keys + await db.exec('PRAGMA foreign_keys = ON;'); + + // Configure for better performance + await db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA cache_size = -2000; -- Use 2MB of cache + `); + + connection = { + db, + sqlite3, + isOpen: true, + lastUsed: Date.now() + }; + + // Start connection cleanup interval + startConnectionCleanup(); + + return connection; + } catch (error) { + logger.error('[SQLite] Database initialization failed:', error); + throw new Error('Failed to initialize database'); + } +} + +/** + * Close the database connection + */ +export async function closeDatabase(): Promise { + if (!connection?.isOpen) return; + + try { + await connection.db.close(); + connection.isOpen = false; + connection = null; + } catch (error) { + logger.error('[SQLite] Database close failed:', error); + throw new Error('Failed to close database'); + } +} + +/** + * Cleanup inactive connections + */ +function startConnectionCleanup(): void { + setInterval(() => { + if (connection && Date.now() - connection.lastUsed > CONNECTION_TIMEOUT) { + closeDatabase().catch(error => { + logger.error('[SQLite] Connection cleanup failed:', error); + }); + } + }, 60000); // Check every minute +} + +// ============================================================================ +// Schema Management +// ============================================================================ + +/** + * Create the database schema + */ +export async function createSchema(): Promise { + const { db } = await initDatabase(); + + try { + await db.transaction(async () => { + for (const table of DATABASE_SCHEMA) { + await createTable(db, table); + } + }); + } catch (error) { + logger.error('[SQLite] Schema creation failed:', error); + throw new Error('Failed to create database schema'); + } +} + +/** + * Create a single table + */ +async function createTable(db: Database, table: SQLiteTable): Promise { + const columnDefs = table.columns.map(col => { + const constraints = [ + col.primaryKey ? 'PRIMARY KEY' : '', + col.unique ? 'UNIQUE' : '', + !col.nullable ? 'NOT NULL' : '', + col.references ? `REFERENCES ${col.references.table}(${col.references.column})` : '', + col.default !== undefined ? `DEFAULT ${formatDefaultValue(col.default)}` : '' + ].filter(Boolean).join(' '); + + return `${col.name} ${col.type} ${constraints}`.trim(); + }); + + const createTableSQL = ` + CREATE TABLE IF NOT EXISTS ${table.name} ( + ${columnDefs.join(',\n ')} + ); + `; + + await db.exec(createTableSQL); + + // Create indexes + if (table.indexes) { + for (const index of table.indexes) { + const createIndexSQL = ` + CREATE INDEX IF NOT EXISTS ${index.name} + ON ${table.name} (${index.columns.join(', ')}) + ${index.unique ? 'UNIQUE' : ''}; + `; + await db.exec(createIndexSQL); + } + } +} + +/** + * Format default value for SQL + */ +function formatDefaultValue(value: unknown): string { + if (value === null) return 'NULL'; + if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`; + if (typeof value === 'number') return value.toString(); + if (typeof value === 'boolean') return value ? '1' : '0'; + throw new Error(`Unsupported default value type: ${typeof value}`); +} + +// ============================================================================ +// Database Health Checks +// ============================================================================ + +/** + * Check database health + */ +export async function checkDatabaseHealth(): Promise<{ + isHealthy: boolean; + tables: string[]; + error?: string; +}> { + try { + const { db } = await initDatabase(); + + // Check if we can query the database + const tables = await db.selectAll<{ name: string }>(` + SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + `); + + return { + isHealthy: true, + tables: tables.map(t => t.name) + }; + } catch (error) { + logger.error('[SQLite] Health check failed:', error); + return { + isHealthy: false, + tables: [], + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +/** + * Verify database integrity + */ +export async function verifyDatabaseIntegrity(): Promise<{ + isIntegrityOk: boolean; + errors: string[]; +}> { + const { db } = await initDatabase(); + const errors: string[] = []; + + try { + // Run integrity check + const result = await db.selectAll<{ integrity_check: string }>('PRAGMA integrity_check;'); + + if (result[0]?.integrity_check !== 'ok') { + errors.push('Database integrity check failed'); + } + + // Check foreign key constraints + const fkResult = await db.selectAll<{ table: string; rowid: number; parent: string; fkid: number }>(` + PRAGMA foreign_key_check; + `); + + if (fkResult.length > 0) { + errors.push('Foreign key constraint violations found'); + } + + return { + isIntegrityOk: errors.length === 0, + errors + }; + } catch (error) { + logger.error('[SQLite] Integrity check failed:', error); + return { + isIntegrityOk: false, + errors: [error instanceof Error ? error.message : 'Unknown error'] + }; + } +} + +// ============================================================================ +// Database Backup and Recovery +// ============================================================================ + +/** + * Create a database backup + */ +export async function createBackup(): Promise { + const { db } = await initDatabase(); + + try { + // Export the database to a binary array + return await db.export(); + } catch (error) { + logger.error('[SQLite] Backup creation failed:', error); + throw new Error('Failed to create database backup'); + } +} + +/** + * Restore database from backup + */ +export async function restoreFromBackup(backup: Uint8Array): Promise { + const { db } = await initDatabase(); + + try { + // Close current connection + await closeDatabase(); + + // Create new connection and import backup + const sqlite3 = await import('@wa-sqlite/sql.js'); + const newDb = await sqlite3.open(backup); + + // Verify integrity + const { isIntegrityOk, errors } = await verifyDatabaseIntegrity(); + if (!isIntegrityOk) { + throw new Error(`Backup integrity check failed: ${errors.join(', ')}`); + } + + // Replace current connection + connection = { + db: newDb, + sqlite3, + isOpen: true, + lastUsed: Date.now() + }; + } catch (error) { + logger.error('[SQLite] Backup restoration failed:', error); + throw new Error('Failed to restore database from backup'); + } +} \ No newline at end of file diff --git a/src/db/sqlite/migration.ts b/src/db/sqlite/migration.ts new file mode 100644 index 00000000..7a38dba6 --- /dev/null +++ b/src/db/sqlite/migration.ts @@ -0,0 +1,374 @@ +/** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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'); + } +} \ No newline at end of file diff --git a/src/db/sqlite/operations.ts b/src/db/sqlite/operations.ts new file mode 100644 index 00000000..ebb6511a --- /dev/null +++ b/src/db/sqlite/operations.ts @@ -0,0 +1,449 @@ +/** + * SQLite Database Operations + * + * This module provides utility functions for common database operations, + * including CRUD operations, queries, and transactions. + */ + +import { Database } from '@wa-sqlite/sql.js'; +import { initDatabase } from './init'; +import { + SQLiteAccount, + SQLiteContact, + SQLiteContactMethod, + SQLiteSettings, + SQLiteLog, + SQLiteSecret, + isSQLiteAccount, + isSQLiteContact, + isSQLiteSettings +} from './types'; +import { logger } from '../../utils/logger'; + +// ============================================================================ +// Transaction Helpers +// ============================================================================ + +/** + * Execute a function within a transaction + */ +export async function withTransaction( + operation: (db: Database) => Promise +): Promise { + const { db } = await initDatabase(); + + try { + return await db.transaction(operation); + } catch (error) { + logger.error('[SQLite] Transaction failed:', error); + throw error; + } +} + +/** + * Execute a function with retries + */ +export async function withRetry( + operation: () => Promise, + maxRetries = 3, + delay = 1000 +): Promise { + let lastError: Error | undefined; + + for (let i = 0; i < maxRetries; i++) { + try { + return await operation(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (i < maxRetries - 1) { + await new Promise(resolve => setTimeout(resolve, delay * (i + 1))); + } + } + } + + throw lastError; +} + +// ============================================================================ +// Account Operations +// ============================================================================ + +/** + * Get account by DID + */ +export async function getAccountByDid(did: string): Promise { + const { db } = await initDatabase(); + + const accounts = await db.selectAll( + 'SELECT * FROM accounts WHERE did = ?', + [did] + ); + + return accounts[0] || null; +} + +/** + * Get all accounts + */ +export async function getAllAccounts(): Promise { + const { db } = await initDatabase(); + + return db.selectAll( + 'SELECT * FROM accounts ORDER BY created_at DESC' + ); +} + +/** + * Create or update account + */ +export async function upsertAccount(account: SQLiteAccount): Promise { + if (!isSQLiteAccount(account)) { + throw new Error('Invalid account data'); + } + + await withTransaction(async (db) => { + const existing = await db.selectOne<{ did: string }>( + 'SELECT did FROM accounts WHERE did = ?', + [account.did] + ); + + if (existing) { + await db.exec(` + UPDATE accounts SET + public_key_hex = ?, + updated_at = ?, + identity_json = ?, + mnemonic_encrypted = ?, + passkey_cred_id_hex = ?, + derivation_path = ? + WHERE did = ? + `, [ + account.public_key_hex, + Date.now(), + account.identity_json || null, + account.mnemonic_encrypted || null, + account.passkey_cred_id_hex || null, + account.derivation_path || null, + account.did + ]); + } else { + await 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 + ]); + } + }); +} + +// ============================================================================ +// Contact Operations +// ============================================================================ + +/** + * Get contact by ID + */ +export async function getContactById(id: string): Promise { + const { db } = await initDatabase(); + + const contacts = await db.selectAll( + 'SELECT * FROM contacts WHERE id = ?', + [id] + ); + + return contacts[0] || null; +} + +/** + * Get contacts by account DID + */ +export async function getContactsByAccountDid(did: string): Promise { + const { db } = await initDatabase(); + + return db.selectAll( + 'SELECT * FROM contacts WHERE did = ? ORDER BY created_at DESC', + [did] + ); +} + +/** + * Get contact methods for a contact + */ +export async function getContactMethods(contactId: string): Promise { + const { db } = await initDatabase(); + + return db.selectAll( + 'SELECT * FROM contact_methods WHERE contact_id = ? ORDER BY created_at DESC', + [contactId] + ); +} + +/** + * Create or update contact with methods + */ +export async function upsertContact( + contact: SQLiteContact, + methods: SQLiteContactMethod[] = [] +): Promise { + if (!isSQLiteContact(contact)) { + throw new Error('Invalid contact data'); + } + + await withTransaction(async (db) => { + const existing = await db.selectOne<{ id: string }>( + 'SELECT id FROM contacts WHERE id = ?', + [contact.id] + ); + + if (existing) { + await db.exec(` + UPDATE contacts SET + did = ?, + name = ?, + notes = ?, + profile_image_url = ?, + public_key_base64 = ?, + next_pub_key_hash_b64 = ?, + sees_me = ?, + registered = ?, + updated_at = ? + WHERE 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, + Date.now(), + contact.id + ]); + } else { + await 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 + ]); + } + + // Update contact methods + if (methods.length > 0) { + // Delete existing methods + await db.exec( + 'DELETE FROM contact_methods WHERE contact_id = ?', + [contact.id] + ); + + // Insert new methods + for (const method of methods) { + await db.exec(` + INSERT INTO contact_methods ( + id, contact_id, label, type, value, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, [ + method.id, + contact.id, + method.label, + method.type, + method.value, + method.created_at, + method.updated_at + ]); + } + } + }); +} + +// ============================================================================ +// Settings Operations +// ============================================================================ + +/** + * Get setting by key + */ +export async function getSetting(key: string): Promise { + const { db } = await initDatabase(); + + const settings = await db.selectAll( + 'SELECT * FROM settings WHERE key = ?', + [key] + ); + + return settings[0] || null; +} + +/** + * Get settings by account DID + */ +export async function getSettingsByAccountDid(did: string): Promise { + const { db } = await initDatabase(); + + return db.selectAll( + 'SELECT * FROM settings WHERE account_did = ? ORDER BY updated_at DESC', + [did] + ); +} + +/** + * Set setting value + */ +export async function setSetting(setting: SQLiteSettings): Promise { + if (!isSQLiteSettings(setting)) { + throw new Error('Invalid settings data'); + } + + await withTransaction(async (db) => { + const existing = await db.selectOne<{ key: string }>( + 'SELECT key FROM settings WHERE key = ?', + [setting.key] + ); + + if (existing) { + await db.exec(` + UPDATE settings SET + account_did = ?, + value_json = ?, + updated_at = ? + WHERE key = ? + `, [ + setting.account_did || null, + setting.value_json, + Date.now(), + setting.key + ]); + } else { + await 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 + ]); + } + }); +} + +// ============================================================================ +// Log Operations +// ============================================================================ + +/** + * Add log entry + */ +export async function addLog(log: SQLiteLog): Promise { + await withTransaction(async (db) => { + await 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 + ]); + }); +} + +/** + * Get logs by level + */ +export async function getLogsByLevel( + level: string, + limit = 100, + offset = 0 +): Promise { + const { db } = await initDatabase(); + + return db.selectAll( + 'SELECT * FROM logs WHERE level = ? ORDER BY created_at DESC LIMIT ? OFFSET ?', + [level, limit, offset] + ); +} + +// ============================================================================ +// Secret Operations +// ============================================================================ + +/** + * Get secret by key + */ +export async function getSecret(key: string): Promise { + const { db } = await initDatabase(); + + const secrets = await db.selectAll( + 'SELECT * FROM secrets WHERE key = ?', + [key] + ); + + return secrets[0] || null; +} + +/** + * Set secret value + */ +export async function setSecret(secret: SQLiteSecret): Promise { + await withTransaction(async (db) => { + const existing = await db.selectOne<{ key: string }>( + 'SELECT key FROM secrets WHERE key = ?', + [secret.key] + ); + + if (existing) { + await db.exec(` + UPDATE secrets SET + value_encrypted = ?, + updated_at = ? + WHERE key = ? + `, [ + secret.value_encrypted, + Date.now(), + secret.key + ]); + } else { + await db.exec(` + INSERT INTO secrets ( + key, value_encrypted, created_at, updated_at + ) VALUES (?, ?, ?, ?) + `, [ + secret.key, + secret.value_encrypted, + secret.created_at, + secret.updated_at + ]); + } + }); +} \ No newline at end of file diff --git a/src/db/sqlite/types.ts b/src/db/sqlite/types.ts new file mode 100644 index 00000000..4cfcab74 --- /dev/null +++ b/src/db/sqlite/types.ts @@ -0,0 +1,349 @@ +/** + * SQLite Type Definitions + * + * This file defines the type system for the SQLite implementation, + * mapping from the existing Dexie types to SQLite-compatible types. + * It includes both the database schema types and the runtime types. + */ + +import { SQLiteCompatibleType } from '@jlongster/sql.js'; + +// ============================================================================ +// Base Types and Utilities +// ============================================================================ + +/** + * SQLite column type mapping + */ +export type SQLiteColumnType = + | 'INTEGER' // For numbers, booleans, dates + | 'TEXT' // For strings, JSON + | 'BLOB' // For binary data + | 'REAL' // For floating point numbers + | 'NULL'; // For null values + +/** + * SQLite column definition + */ +export interface SQLiteColumn { + name: string; + type: SQLiteColumnType; + nullable?: boolean; + primaryKey?: boolean; + unique?: boolean; + references?: { + table: string; + column: string; + }; + default?: SQLiteCompatibleType; +} + +/** + * SQLite table definition + */ +export interface SQLiteTable { + name: string; + columns: SQLiteColumn[]; + indexes?: Array<{ + name: string; + columns: string[]; + unique?: boolean; + }>; +} + +// ============================================================================ +// Account Types +// ============================================================================ + +/** + * SQLite-compatible Account type + * Maps from the Dexie Account type + */ +export interface SQLiteAccount { + did: string; // TEXT PRIMARY KEY + public_key_hex: string; // TEXT NOT NULL + created_at: number; // INTEGER NOT NULL + updated_at: number; // INTEGER NOT NULL + identity_json?: string; // TEXT (encrypted JSON) + mnemonic_encrypted?: string; // TEXT (encrypted) + passkey_cred_id_hex?: string; // TEXT + derivation_path?: string; // TEXT +} + +export const ACCOUNTS_TABLE: SQLiteTable = { + name: 'accounts', + columns: [ + { name: 'did', type: 'TEXT', primaryKey: true }, + { name: 'public_key_hex', type: 'TEXT', nullable: false }, + { name: 'created_at', type: 'INTEGER', nullable: false }, + { name: 'updated_at', type: 'INTEGER', nullable: false }, + { name: 'identity_json', type: 'TEXT' }, + { name: 'mnemonic_encrypted', type: 'TEXT' }, + { name: 'passkey_cred_id_hex', type: 'TEXT' }, + { name: 'derivation_path', type: 'TEXT' } + ], + indexes: [ + { name: 'idx_accounts_created_at', columns: ['created_at'] }, + { name: 'idx_accounts_updated_at', columns: ['updated_at'] } + ] +}; + +// ============================================================================ +// Contact Types +// ============================================================================ + +/** + * SQLite-compatible ContactMethod type + */ +export interface SQLiteContactMethod { + id: string; // TEXT PRIMARY KEY + contact_id: string; // TEXT NOT NULL + label: string; // TEXT NOT NULL + type: string; // TEXT NOT NULL + value: string; // TEXT NOT NULL + created_at: number; // INTEGER NOT NULL + updated_at: number; // INTEGER NOT NULL +} + +/** + * SQLite-compatible Contact type + */ +export interface SQLiteContact { + id: string; // TEXT PRIMARY KEY + did: string; // TEXT NOT NULL + name?: string; // TEXT + notes?: string; // TEXT + profile_image_url?: string; // TEXT + public_key_base64?: string; // TEXT + next_pub_key_hash_b64?: string; // TEXT + sees_me?: boolean; // INTEGER (0 or 1) + registered?: boolean; // INTEGER (0 or 1) + created_at: number; // INTEGER NOT NULL + updated_at: number; // INTEGER NOT NULL +} + +export const CONTACTS_TABLE: SQLiteTable = { + name: 'contacts', + columns: [ + { name: 'id', type: 'TEXT', primaryKey: true }, + { name: 'did', type: 'TEXT', nullable: false }, + { name: 'name', type: 'TEXT' }, + { name: 'notes', type: 'TEXT' }, + { name: 'profile_image_url', type: 'TEXT' }, + { name: 'public_key_base64', type: 'TEXT' }, + { name: 'next_pub_key_hash_b64', type: 'TEXT' }, + { name: 'sees_me', type: 'INTEGER' }, + { name: 'registered', type: 'INTEGER' }, + { name: 'created_at', type: 'INTEGER', nullable: false }, + { name: 'updated_at', type: 'INTEGER', nullable: false } + ], + indexes: [ + { name: 'idx_contacts_did', columns: ['did'] }, + { name: 'idx_contacts_created_at', columns: ['created_at'] } + ] +}; + +export const CONTACT_METHODS_TABLE: SQLiteTable = { + name: 'contact_methods', + columns: [ + { name: 'id', type: 'TEXT', primaryKey: true }, + { name: 'contact_id', type: 'TEXT', nullable: false, + references: { table: 'contacts', column: 'id' } }, + { name: 'label', type: 'TEXT', nullable: false }, + { name: 'type', type: 'TEXT', nullable: false }, + { name: 'value', type: 'TEXT', nullable: false }, + { name: 'created_at', type: 'INTEGER', nullable: false }, + { name: 'updated_at', type: 'INTEGER', nullable: false } + ], + indexes: [ + { name: 'idx_contact_methods_contact_id', columns: ['contact_id'] } + ] +}; + +// ============================================================================ +// Settings Types +// ============================================================================ + +/** + * SQLite-compatible Settings type + */ +export interface SQLiteSettings { + key: string; // TEXT PRIMARY KEY + account_did?: string; // TEXT + value_json: string; // TEXT NOT NULL (JSON stringified) + created_at: number; // INTEGER NOT NULL + updated_at: number; // INTEGER NOT NULL +} + +export const SETTINGS_TABLE: SQLiteTable = { + name: 'settings', + columns: [ + { name: 'key', type: 'TEXT', primaryKey: true }, + { name: 'account_did', type: 'TEXT' }, + { name: 'value_json', type: 'TEXT', nullable: false }, + { name: 'created_at', type: 'INTEGER', nullable: false }, + { name: 'updated_at', type: 'INTEGER', nullable: false } + ], + indexes: [ + { name: 'idx_settings_account_did', columns: ['account_did'] }, + { name: 'idx_settings_updated_at', columns: ['updated_at'] } + ] +}; + +// ============================================================================ +// Log Types +// ============================================================================ + +/** + * SQLite-compatible Log type + */ +export interface SQLiteLog { + id: string; // TEXT PRIMARY KEY + level: string; // TEXT NOT NULL + message: string; // TEXT NOT NULL + metadata_json?: string; // TEXT (JSON stringified) + created_at: number; // INTEGER NOT NULL +} + +export const LOGS_TABLE: SQLiteTable = { + name: 'logs', + columns: [ + { name: 'id', type: 'TEXT', primaryKey: true }, + { name: 'level', type: 'TEXT', nullable: false }, + { name: 'message', type: 'TEXT', nullable: false }, + { name: 'metadata_json', type: 'TEXT' }, + { name: 'created_at', type: 'INTEGER', nullable: false } + ], + indexes: [ + { name: 'idx_logs_level', columns: ['level'] }, + { name: 'idx_logs_created_at', columns: ['created_at'] } + ] +}; + +// ============================================================================ +// Secret Types +// ============================================================================ + +/** + * SQLite-compatible Secret type + * Note: This table should be encrypted at the database level + */ +export interface SQLiteSecret { + key: string; // TEXT PRIMARY KEY + value_encrypted: string; // TEXT NOT NULL (encrypted) + created_at: number; // INTEGER NOT NULL + updated_at: number; // INTEGER NOT NULL +} + +export const SECRETS_TABLE: SQLiteTable = { + name: 'secrets', + columns: [ + { name: 'key', type: 'TEXT', primaryKey: true }, + { name: 'value_encrypted', type: 'TEXT', nullable: false }, + { name: 'created_at', type: 'INTEGER', nullable: false }, + { name: 'updated_at', type: 'INTEGER', nullable: false } + ], + indexes: [ + { name: 'idx_secrets_updated_at', columns: ['updated_at'] } + ] +}; + +// ============================================================================ +// Database Schema +// ============================================================================ + +/** + * Complete database schema definition + */ +export const DATABASE_SCHEMA: SQLiteTable[] = [ + ACCOUNTS_TABLE, + CONTACTS_TABLE, + CONTACT_METHODS_TABLE, + SETTINGS_TABLE, + LOGS_TABLE, + SECRETS_TABLE +]; + +// ============================================================================ +// Type Guards and Validators +// ============================================================================ + +/** + * Type guard for SQLiteAccount + */ +export function isSQLiteAccount(value: unknown): value is SQLiteAccount { + return ( + typeof value === 'object' && + value !== null && + typeof (value as SQLiteAccount).did === 'string' && + typeof (value as SQLiteAccount).public_key_hex === 'string' && + typeof (value as SQLiteAccount).created_at === 'number' && + typeof (value as SQLiteAccount).updated_at === 'number' + ); +} + +/** + * Type guard for SQLiteContact + */ +export function isSQLiteContact(value: unknown): value is SQLiteContact { + return ( + typeof value === 'object' && + value !== null && + typeof (value as SQLiteContact).id === 'string' && + typeof (value as SQLiteContact).did === 'string' && + typeof (value as SQLiteContact).created_at === 'number' && + typeof (value as SQLiteContact).updated_at === 'number' + ); +} + +/** + * Type guard for SQLiteSettings + */ +export function isSQLiteSettings(value: unknown): value is SQLiteSettings { + return ( + typeof value === 'object' && + value !== null && + typeof (value as SQLiteSettings).key === 'string' && + typeof (value as SQLiteSettings).value_json === 'string' && + typeof (value as SQLiteSettings).created_at === 'number' && + typeof (value as SQLiteSettings).updated_at === 'number' + ); +} + +// ============================================================================ +// Migration Types +// ============================================================================ + +/** + * Type for migration data from Dexie to SQLite + */ +export interface MigrationData { + accounts: SQLiteAccount[]; + contacts: SQLiteContact[]; + contactMethods: SQLiteContactMethod[]; + settings: SQLiteSettings[]; + logs: SQLiteLog[]; + secrets: SQLiteSecret[]; + metadata: { + version: string; + timestamp: number; + source: 'dexie'; + }; +} + +/** + * Migration result type + */ +export interface MigrationResult { + success: boolean; + error?: Error; + stats: { + accounts: number; + contacts: number; + contactMethods: number; + settings: number; + logs: number; + secrets: number; + }; + duration: number; +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index 447e59c7..00000000 --- a/src/main.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { createPinia } from "pinia"; -import { App as VueApp, ComponentPublicInstance, createApp } from "vue"; -import App from "./App.vue"; -import "./registerServiceWorker"; -import router from "./router"; -import axios from "axios"; -import VueAxios from "vue-axios"; -import Notifications from "notiwind"; -import "./assets/styles/tailwind.css"; - -import { library } from "@fortawesome/fontawesome-svg-core"; -import { - faArrowDown, - faArrowLeft, - faArrowRight, - faArrowRotateBackward, - faArrowUpRightFromSquare, - faArrowUp, - faBan, - faBitcoinSign, - faBurst, - faCalendar, - faCamera, - faCameraRotate, - faCaretDown, - faChair, - faCheck, - faChevronDown, - faChevronLeft, - faChevronRight, - faChevronUp, - faCircle, - faCircleCheck, - faCircleInfo, - faCircleQuestion, - faCircleUser, - faClock, - faCoins, - faComment, - faCopy, - faDollar, - faEllipsis, - faEllipsisVertical, - faEnvelopeOpenText, - faEraser, - faEye, - faEyeSlash, - faFileContract, - faFileLines, - faFilter, - faFloppyDisk, - faFolderOpen, - faForward, - faGift, - faGlobe, - faHammer, - faHand, - faHandHoldingDollar, - faHandHoldingHeart, - faHouseChimney, - faImage, - faImagePortrait, - faLeftRight, - faLightbulb, - faLink, - faLocationDot, - faLongArrowAltLeft, - faLongArrowAltRight, - faMagnifyingGlass, - faMessage, - faMinus, - faPen, - faPersonCircleCheck, - faPersonCircleQuestion, - faPlus, - faQuestion, - faQrcode, - faRightFromBracket, - faRotate, - faShareNodes, - faSpinner, - faSquare, - faSquareCaretDown, - faSquareCaretUp, - faSquarePlus, - faTrashCan, - faTriangleExclamation, - faUser, - faUsers, - faXmark, -} from "@fortawesome/free-solid-svg-icons"; - -library.add( - faArrowDown, - faArrowLeft, - faArrowRight, - faArrowRotateBackward, - faArrowUpRightFromSquare, - faArrowUp, - faBan, - faBitcoinSign, - faBurst, - faCalendar, - faCamera, - faCameraRotate, - faCaretDown, - faChair, - faCheck, - faChevronDown, - faChevronLeft, - faChevronRight, - faChevronUp, - faCircle, - faCircleCheck, - faCircleInfo, - faCircleQuestion, - faCircleUser, - faClock, - faCoins, - faComment, - faCopy, - faDollar, - faEllipsis, - faEllipsisVertical, - faEnvelopeOpenText, - faEraser, - faEye, - faEyeSlash, - faFileContract, - faFileLines, - faFilter, - faFloppyDisk, - faFolderOpen, - faForward, - faGift, - faGlobe, - faHammer, - faHand, - faHandHoldingDollar, - faHandHoldingHeart, - faHouseChimney, - faImage, - faImagePortrait, - faLeftRight, - faLightbulb, - faLink, - faLocationDot, - faLongArrowAltLeft, - faLongArrowAltRight, - faMagnifyingGlass, - faMessage, - faMinus, - faPen, - faPersonCircleCheck, - faPersonCircleQuestion, - faPlus, - faQrcode, - faQuestion, - faRotate, - faRightFromBracket, - faShareNodes, - faSpinner, - faSquare, - faSquareCaretDown, - faSquareCaretUp, - faSquarePlus, - faTrashCan, - faTriangleExclamation, - faUser, - faUsers, - faXmark, -); - -import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; -import Camera from "simple-vue-camera"; -import { logger } from "./utils/logger"; - -// Can trigger this with a 'throw' inside some top-level function, eg. on the HomeView -function setupGlobalErrorHandler(app: VueApp) { - // @ts-expect-error 'cause we cannot see why config is not defined - app.config.errorHandler = ( - err: Error, - instance: ComponentPublicInstance | null, - info: string, - ) => { - logger.error( - "Ouch! Global Error Handler.", - "Error:", - err, - "- Error toString:", - err.toString(), - "- Info:", - info, - "- Instance:", - instance, - ); - // Want to show a nice notiwind notification but can't figure out how. - alert( - (err.message || "Something bad happened") + - " - Try reloading or restarting the app.", - ); - }; -} - -const app = createApp(App) - .component("fa", FontAwesomeIcon) - .component("camera", Camera) - .use(createPinia()) - .use(VueAxios, axios) - .use(router) - .use(Notifications); - -setupGlobalErrorHandler(app); - -app.mount("#app");