Browse Source

feat: Enhance database migration system with better logging and schema detection

- Add comprehensive console logging for Electron with emojis for better visibility
- Use CREATE TABLE IF NOT EXISTS and INSERT OR IGNORE to prevent duplicate creation errors
- Add specialized column existence checking for ALTER TABLE ADD COLUMN operations
- Improve migration tracking with detailed status reporting (applied/skipped counts)
- Add proper error handling for existing schema scenarios
- Enhanced documentation and type safety for migration system

This resolves issues where migrations would fail with 'table already exists' or
'duplicate column' errors when the database schema was already properly set up.
The enhanced logging makes it clear to users when migrations are being skipped
vs. applied, improving the debugging experience in Electron.
pull/142/head
Matthew Raymer 4 weeks ago
parent
commit
4120f5a94e
  1. 45
      src/db-sql/migration.ts
  2. 90
      src/services/migrationService.ts

45
src/db-sql/migration.ts

@ -1,3 +1,12 @@
/**
* Database Migration System for TimeSafari
*
* This module manages database schema migrations as users upgrade their app.
* It ensures that database changes are applied safely and only when needed.
*
* @author Matthew Raymer
*/
import { import {
registerMigration, registerMigration,
runMigrations as runMigrationsService, runMigrations as runMigrationsService,
@ -35,6 +44,7 @@ const MIGRATIONS = [
{ {
name: "001_initial", name: "001_initial",
sql: ` sql: `
-- Create accounts table only if it doesn't exist
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
dateCreated TEXT NOT NULL, dateCreated TEXT NOT NULL,
@ -48,13 +58,16 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did); CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did);
-- Create secret table only if it doesn't exist
CREATE TABLE IF NOT EXISTS secret ( CREATE TABLE IF NOT EXISTS secret (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
secretBase64 TEXT NOT NULL secretBase64 TEXT NOT NULL
); );
-- Insert secret only if table is empty
INSERT OR IGNORE INTO secret (id, secretBase64) VALUES (1, '${secretBase64}'); INSERT OR IGNORE INTO secret (id, secretBase64) VALUES (1, '${secretBase64}');
-- Create settings table only if it doesn't exist
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
accountDid TEXT, accountDid TEXT,
@ -89,8 +102,10 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid); CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
-- Insert default settings only if table is empty
INSERT OR IGNORE INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}'); INSERT OR IGNORE INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
-- Create contacts table only if it doesn't exist
CREATE TABLE IF NOT EXISTS contacts ( CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
did TEXT NOT NULL, did TEXT NOT NULL,
@ -107,11 +122,13 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did); CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name); CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
-- Create logs table only if it doesn't exist
CREATE TABLE IF NOT EXISTS logs ( CREATE TABLE IF NOT EXISTS logs (
date TEXT NOT NULL, date TEXT NOT NULL,
message TEXT NOT NULL message TEXT NOT NULL
); );
-- Create temp table only if it doesn't exist
CREATE TABLE IF NOT EXISTS temp ( CREATE TABLE IF NOT EXISTS temp (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
blobB64 TEXT blobB64 TEXT
@ -121,27 +138,41 @@ const MIGRATIONS = [
{ {
name: "002_add_iViewContent_to_contacts", name: "002_add_iViewContent_to_contacts",
sql: ` sql: `
-- We need to handle the case where iViewContent column might already exist -- Add iViewContent column to contacts table
-- SQLite doesn't support IF NOT EXISTS for ALTER TABLE ADD COLUMN -- The migration service will check if the column already exists
-- So we'll use a more robust approach with error handling in the migration service
-- First, try to add the column - this will fail silently if it already exists
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE; ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
`, `,
}, },
]; ];
/** /**
* Runs all registered database migrations
*
* This function ensures that the database schema is up-to-date by running
* all pending migrations. It uses the migration service to track which
* migrations have been applied and avoid running them multiple times.
*
* @param sqlExec - A function that executes a SQL statement and returns the result * @param sqlExec - A function that executes a SQL statement and returns the result
* @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations" * @param sqlQuery - A function that executes a SQL query and returns the result
* @param extractMigrationNames - A function that extracts migration names from query results
* @returns Promise that resolves when all migrations are complete
*/ */
export async function runMigrations<T>( export async function runMigrations<T>(
sqlExec: (sql: string, params?: unknown[]) => Promise<unknown>, sqlExec: (sql: string, params?: unknown[]) => Promise<unknown>,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>, sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>, extractMigrationNames: (result: T) => Set<string>,
): Promise<void> { ): Promise<void> {
console.log("🔄 [Migration] Starting database migration process...");
for (const migration of MIGRATIONS) { for (const migration of MIGRATIONS) {
registerMigration(migration); registerMigration(migration);
} }
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
try {
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
console.log("✅ [Migration] Database migration process completed successfully");
} catch (error) {
console.error("❌ [Migration] Database migration process failed:", error);
throw error;
}
} }

90
src/services/migrationService.ts

@ -1,5 +1,11 @@
/** /**
* Manage database migrations as people upgrade their app over time * Database Migration Service for TimeSafari
*
* Manages database migrations as people upgrade their app over time.
* Provides safe, tracked migrations with rollback capabilities and
* detailed logging for debugging.
*
* @author Matthew Raymer
*/ */
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@ -78,6 +84,8 @@ export async function runMigrations<T>(
extractMigrationNames: (result: T) => Set<string>, extractMigrationNames: (result: T) => Set<string>,
): Promise<void> { ): Promise<void> {
try { try {
console.log("📋 [Migration] Checking migration status...");
// Create migrations table if it doesn't exist // Create migrations table if it doesn't exist
await sqlExec(` await sqlExec(`
CREATE TABLE IF NOT EXISTS migrations ( CREATE TABLE IF NOT EXISTS migrations (
@ -96,28 +104,45 @@ export async function runMigrations<T>(
const migrations = migrationRegistry.getMigrations(); const migrations = migrationRegistry.getMigrations();
if (migrations.length === 0) { if (migrations.length === 0) {
console.log("⚠️ [Migration] No migrations registered");
logger.warn("[MigrationService] No migrations registered"); logger.warn("[MigrationService] No migrations registered");
return; return;
} }
console.log(`📊 [Migration] Found ${migrations.length} total migrations, ${appliedMigrations.size} already applied`);
let appliedCount = 0;
let skippedCount = 0;
// Run each migration that hasn't been applied yet // Run each migration that hasn't been applied yet
for (const migration of migrations) { for (const migration of migrations) {
if (appliedMigrations.has(migration.name)) { if (appliedMigrations.has(migration.name)) {
console.log(`⏭️ [Migration] Skipping already applied: ${migration.name}`);
skippedCount++;
continue; continue;
} }
console.log(`🔄 [Migration] Applying migration: ${migration.name}`);
try { try {
// Execute the migration SQL // Special handling for column addition migrations
await sqlExec(migration.sql); if (migration.sql.includes("ALTER TABLE") && migration.sql.includes("ADD COLUMN")) {
await handleColumnAddition(migration, sqlExec, sqlQuery);
} else {
// Execute the migration SQL
await sqlExec(migration.sql);
}
// Record that the migration was applied // Record that the migration was applied
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [ await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name, migration.name,
]); ]);
console.log(`✅ [Migration] Successfully applied: ${migration.name}`);
logger.info( logger.info(
`[MigrationService] Successfully applied migration: ${migration.name}`, `[MigrationService] Successfully applied migration: ${migration.name}`,
); );
appliedCount++;
} catch (error) { } catch (error) {
// Handle specific cases where the migration might be partially applied // Handle specific cases where the migration might be partially applied
const errorMessage = String(error).toLowerCase(); const errorMessage = String(error).toLowerCase();
@ -128,6 +153,7 @@ export async function runMigrations<T>(
errorMessage.includes("column already exists") || errorMessage.includes("column already exists") ||
errorMessage.includes("already exists") errorMessage.includes("already exists")
) { ) {
console.log(`⚠️ [Migration] ${migration.name} appears already applied (${errorMessage}). Marking as complete.`);
logger.warn( logger.warn(
`[MigrationService] Migration ${migration.name} appears to be already applied (${errorMessage}). Marking as complete.`, `[MigrationService] Migration ${migration.name} appears to be already applied (${errorMessage}). Marking as complete.`,
); );
@ -137,11 +163,14 @@ export async function runMigrations<T>(
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [ await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name, migration.name,
]); ]);
console.log(`✅ [Migration] Marked as applied: ${migration.name}`);
logger.info( logger.info(
`[MigrationService] Successfully marked migration as applied: ${migration.name}`, `[MigrationService] Successfully marked migration as applied: ${migration.name}`,
); );
appliedCount++;
} catch (insertError) { } catch (insertError) {
// If we can't insert the migration record, log it but don't fail // If we can't insert the migration record, log it but don't fail
console.warn(`⚠️ [Migration] Could not record ${migration.name} as applied:`, insertError);
logger.warn( logger.warn(
`[MigrationService] Could not record migration ${migration.name} as applied:`, `[MigrationService] Could not record migration ${migration.name} as applied:`,
insertError, insertError,
@ -149,6 +178,7 @@ export async function runMigrations<T>(
} }
} else { } else {
// For other types of errors, still fail the migration // For other types of errors, still fail the migration
console.error(`❌ [Migration] Failed to apply ${migration.name}:`, error);
logger.error( logger.error(
`[MigrationService] Failed to apply migration ${migration.name}:`, `[MigrationService] Failed to apply migration ${migration.name}:`,
error, error,
@ -157,8 +187,62 @@ export async function runMigrations<T>(
} }
} }
} }
console.log(`🎉 [Migration] Migration process complete! Applied: ${appliedCount}, Skipped: ${skippedCount}`);
} catch (error) { } catch (error) {
console.error("💥 [Migration] Migration process failed:", error);
logger.error("[MigrationService] Migration process failed:", error); logger.error("[MigrationService] Migration process failed:", error);
throw error; throw error;
} }
} }
/**
* Handle column addition migrations with proper existence checking
*
* @param migration - The migration containing column addition
* @param sqlExec - Function to execute SQL statements
* @param sqlQuery - Function to query SQL data
*/
async function handleColumnAddition<T>(
migration: Migration,
sqlExec: (sql: string, params?: unknown[]) => Promise<unknown>,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<void> {
// Extract table name and column name from ALTER TABLE statement
const alterMatch = migration.sql.match(/ALTER TABLE\s+(\w+)\s+ADD COLUMN\s+(\w+)/i);
if (!alterMatch) {
// If we can't parse it, just try to execute normally
await sqlExec(migration.sql);
return;
}
const [, tableName, columnName] = alterMatch;
try {
// Check if column already exists
const columnCheckResult = await sqlQuery(
`SELECT COUNT(*) as count FROM pragma_table_info('${tableName}') WHERE name = ?`,
[columnName]
) as any;
const columnExists = columnCheckResult?.values?.[0]?.count > 0 ||
columnCheckResult?.count > 0 ||
(Array.isArray(columnCheckResult) && columnCheckResult.length > 0);
if (columnExists) {
console.log(`⏭️ [Migration] Column ${columnName} already exists in table ${tableName}, skipping`);
return;
}
// Column doesn't exist, so add it
console.log(`🔄 [Migration] Adding column ${columnName} to table ${tableName}`);
await sqlExec(migration.sql);
console.log(`✅ [Migration] Successfully added column ${columnName} to table ${tableName}`);
} catch (error) {
// If the column check fails, try the original migration and let error handling catch duplicates
console.log(`⚠️ [Migration] Column check failed, attempting migration anyway: ${error}`);
await sqlExec(migration.sql);
}
}

Loading…
Cancel
Save