Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Daqifi.Desktop/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
using Daqifi.Desktop.Common.Loggers;
using Daqifi.Desktop.DialogService;
using Daqifi.Desktop.Logger;
using Daqifi.Desktop.View;
using Daqifi.Desktop.WindowViewModelMapping;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using System.IO;
using System.Net.Http;
Expand Down Expand Up @@ -53,6 +55,7 @@ protected override void OnStartup(StartupEventArgs e)
var serviceCollection = new ServiceCollection();
serviceCollection.AddDbContextFactory<LoggingContext>(options =>
options.UseSqlite($"Data source={DatabasePath}")
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
);

serviceCollection.AddLogging();
Expand All @@ -71,6 +74,25 @@ protected override void OnStartup(StartupEventArgs e)
ServiceLocator.RegisterSingleton<IWindowViewModelMappings, WindowViewModelMappings>();

ServiceProvider = serviceCollection.BuildServiceProvider();

// Apply database migrations before any DB access.
// Temporarily switch to OnExplicitShutdown so closing the migration
// status window does not terminate the application.
var contextFactory = ServiceProvider.GetRequiredService<IDbContextFactory<LoggingContext>>();
if (DatabaseMigrator.PrepareMigration(contextFactory, DatabasePath))
{
ShutdownMode = ShutdownMode.OnExplicitShutdown;

var statusWindow = new MigrationStatusWindow();
statusWindow.Show();

DatabaseMigrator.ApplyMigrations(contextFactory, DatabasePath);

statusWindow.Close();

ShutdownMode = ShutdownMode.OnLastWindowClose;
}

// Create and show main window
var view = new MainWindow();
view.Show();
Expand Down
228 changes: 228 additions & 0 deletions Daqifi.Desktop/Loggers/DatabaseMigrator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
using System.IO;
using Daqifi.Desktop.Common.Loggers;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

namespace Daqifi.Desktop.Logger;

/// <summary>
/// Handles database migration at application startup, including upgrade path
/// for existing databases created by <c>EnsureCreated()</c>.
/// </summary>
public static class DatabaseMigrator
{
#region Constants
private const string INITIAL_MIGRATION_ID = "20250812090000_InitialSQLiteMigration";
private const string EF_PRODUCT_VERSION = "9.0.14";
#endregion

#region Public Methods
/// <summary>
/// Seeds migration history for existing databases and checks whether
/// there are pending migrations. Call this before <see cref="ApplyMigrations"/>
/// to determine if a status UI should be shown.
/// </summary>
/// <param name="contextFactory">The DbContext factory registered in DI.</param>
/// <param name="databasePath">Full path to the SQLite database file.</param>
/// <returns><c>true</c> if there are pending migrations to apply.</returns>
public static bool PrepareMigration(IDbContextFactory<LoggingContext> contextFactory, string databasePath)
{
SeedMigrationHistoryIfNeeded(databasePath);
SqliteConnection.ClearAllPools();
return HasPendingMigrations(contextFactory);
}

/// <summary>
/// Applies pending EF Core migrations with backup and rollback support.
/// Call <see cref="PrepareMigration"/> first to seed history and check
/// whether migrations are needed.
/// </summary>
/// <param name="contextFactory">The DbContext factory registered in DI.</param>
/// <param name="databasePath">Full path to the SQLite database file.</param>
public static void ApplyMigrations(IDbContextFactory<LoggingContext> contextFactory, string databasePath)
{
var backupPath = BackupDatabase(databasePath);
CheckpointWal(databasePath);

try
{
using var context = contextFactory.CreateDbContext();
context.Database.Migrate();
}
catch (Exception ex)
{
AppLogger.Instance.Error(ex, "Database migration failed");
RestoreBackup(backupPath, databasePath);
throw;
}

CleanupBackup(backupPath);
AppLogger.Instance.AddBreadcrumb("database", "Database migration completed successfully");
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}
#endregion

#region Private Methods
/// <summary>
/// Checks whether there are any pending migrations to apply.
/// </summary>
private static bool HasPendingMigrations(IDbContextFactory<LoggingContext> contextFactory)
{
using var context = contextFactory.CreateDbContext();
var pending = context.Database.GetPendingMigrations();
return pending.Any();
Comment thread
tylerkron marked this conversation as resolved.
}

/// <summary>
/// Creates a backup copy of the SQLite database file before applying migrations.
/// </summary>
/// <returns>The backup file path, or null if no backup was created.</returns>
private static string BackupDatabase(string databasePath)
{
try
{
if (!File.Exists(databasePath))
{
return null;
}

var backupPath = databasePath + ".migration-backup";
File.Copy(databasePath, backupPath, overwrite: true);
return backupPath;
}
catch (Exception ex)
{
AppLogger.Instance.Error(ex, "Failed to back up database before migration");
return null;
}
}

/// <summary>
/// Removes the backup file after a successful migration.
/// </summary>
private static void CleanupBackup(string backupPath)
{
try
{
if (!string.IsNullOrEmpty(backupPath) && File.Exists(backupPath))
{
File.Delete(backupPath);
}
}
catch (Exception ex)
{
AppLogger.Instance.Error(ex, "Failed to delete migration backup file");
}
}

/// <summary>
/// Flushes the WAL journal into the main database file using PRAGMA wal_checkpoint.
/// This is safer than deleting WAL/SHM files directly, which could cause data loss
/// if uncommitted transactions exist.
/// </summary>
private static void CheckpointWal(string databasePath)
{
try
{
var connectionString = $"Data source={databasePath}";
using var connection = new SqliteConnection(connectionString);
connection.Open();

using var command = connection.CreateCommand();
command.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
command.ExecuteNonQuery();

connection.Close();
}
catch (Exception ex)
{
AppLogger.Instance.Error(ex, "Failed to checkpoint WAL — migration will proceed anyway");
}
}

/// <summary>
/// Restores the database from backup after a failed migration.
/// </summary>
private static void RestoreBackup(string backupPath, string databasePath)
{
try
{
if (!string.IsNullOrEmpty(backupPath) && File.Exists(backupPath))
{
SqliteConnection.ClearAllPools();
File.Copy(backupPath, databasePath, overwrite: true);
AppLogger.Instance.Error(null, "Database restored from backup after migration failure");
}
}
catch (Exception ex)
{
AppLogger.Instance.Error(ex, "Failed to restore database from backup — manual recovery may be needed");
}
}

/// <summary>
/// For databases created by <c>EnsureCreated()</c>, the <c>__EFMigrationsHistory</c>
/// table does not exist. This method creates it and seeds the initial migration entry
/// so that <c>Migrate()</c> only applies subsequent migrations.
/// Uses a raw <see cref="SqliteConnection"/> to avoid EF connection pooling locks.
/// </summary>
private static void SeedMigrationHistoryIfNeeded(string databasePath)
{
if (!File.Exists(databasePath))
{
return;
}

var connectionString = $"Data source={databasePath}";
using var connection = new SqliteConnection(connectionString);
connection.Open();

if (HasMigrationHistoryTable(connection))
{
connection.Close();
return;
}

if (!HasExistingTables(connection))
{
connection.Close();
return;
}

using var command = connection.CreateCommand();
command.CommandText =
"CREATE TABLE IF NOT EXISTS \"__EFMigrationsHistory\" " +
"(\"MigrationId\" TEXT NOT NULL PRIMARY KEY, \"ProductVersion\" TEXT NOT NULL); " +
"INSERT OR IGNORE INTO \"__EFMigrationsHistory\" " +
$"VALUES ('{INITIAL_MIGRATION_ID}', '{EF_PRODUCT_VERSION}');";
Comment thread
tylerkron marked this conversation as resolved.
command.ExecuteNonQuery();

connection.Close();
}

/// <summary>
/// Checks whether the <c>__EFMigrationsHistory</c> table exists in the database.
/// </summary>
private static bool HasMigrationHistoryTable(SqliteConnection connection)
{
using var command = connection.CreateCommand();
command.CommandText =
"SELECT COUNT(*) FROM sqlite_master " +
"WHERE type='table' AND name='__EFMigrationsHistory'";
var result = command.ExecuteScalar();
return result is long count && count > 0;
}

/// <summary>
/// Checks whether the database has existing application tables (created by <c>EnsureCreated()</c>).
/// </summary>
private static bool HasExistingTables(SqliteConnection connection)
{
using var command = connection.CreateCommand();
command.CommandText =
"SELECT COUNT(*) FROM sqlite_master " +
"WHERE type='table' AND name='Samples'";
var result = command.ExecuteScalar();
return result is long count && count > 0;
}
#endregion
}
19 changes: 15 additions & 4 deletions Daqifi.Desktop/Loggers/LoggingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,25 @@ public class LoggingContext : DbContext
{
public LoggingContext(DbContextOptions<LoggingContext> options) : base(options)
{
Database.EnsureCreated();
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<LoggingSession>().HasMany(c => c.DataSamples).WithOne(p => p.LoggingSession).IsRequired().OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<LoggingSession>().Property(ls => ls.Name).IsRequired();
modelBuilder.Entity<Channel.Channel>().Ignore(c => c.ChannelColorBrush);
modelBuilder.Entity<LoggingSession>(entity =>
{
entity.ToTable("Sessions");
entity.HasMany(c => c.DataSamples).WithOne(p => p.LoggingSession).IsRequired().OnDelete(DeleteBehavior.Cascade);
entity.Property(ls => ls.Name).IsRequired();
});

modelBuilder.Entity<DataSample>().ToTable("Samples");

modelBuilder.Entity<Channel.Channel>(entity =>
{
entity.ToTable("Channel");
entity.Ignore(c => c.ChannelColorBrush);
});

base.OnModelCreating(modelBuilder);
}

Expand Down
18 changes: 18 additions & 0 deletions Daqifi.Desktop/Loggers/LoggingContextDesignTimeFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

namespace Daqifi.Desktop.Logger;

/// <summary>
/// Factory used by EF Core tooling (dotnet ef migrations) to create
/// a LoggingContext at design time without starting the WPF application.
/// </summary>
public class LoggingContextDesignTimeFactory : IDesignTimeDbContextFactory<LoggingContext>
{
public LoggingContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<LoggingContext>();
optionsBuilder.UseSqlite("Data source=design_time.db");
return new LoggingContext(optionsBuilder.Options);
}
}
Loading
Loading