DB migrations: bundled SQL applied automatically after install

Each PROSERVE ZIP can now ship a _migrations/ subfolder with versioned SQL
scripts. The launcher applies them right after extraction, in the same
install session as the binary copy, so the user never lands on a PROSERVE
build whose schema doesn't match its code.

Service (PSLauncher.Core/Migrations)
- IDatabaseMigrationService + DatabaseMigrationService (MySqlConnector,
  BSD-licensed). Each .sql runs in a transaction; on failure the DB
  state is rolled back and the install completes (files only) but the
  user is warned to fix the connection / replay later.
- Tracking table _launcher_migrations (filename, applied_at, checksum,
  duration_ms) — same model as Flyway / Doctrine. Already-applied scripts
  are skipped on subsequent installs. Modified scripts trigger a warning
  log without blocking.
- Custom SQL splitter that respects strings/comments/backticks so a single
  .sql file can contain multiple statements separated by `;`.
- DatabasePasswordProtector: DPAPI CurrentUser scope for the MySQL password
  in config.json (same protection as the license key).

Config (PSLauncher.Models/LocalConfig.cs)
- New DatabaseConfig section: Host=localhost, Port=3306, User=root, empty
  password, Database=proserve, AutoApplyMigrations=true. Defaults match a
  fresh XAMPP install. Override via Settings (next commit).

Install pipeline (MainViewModel)
- After ZipInstaller.InstallAsync and before declaring the install complete,
  if AutoApplyMigrations and a _migrations/ folder exists, run
  ApplyMigrationsAsync with progress reporting (per-file %, filename in
  footer). Failure shows MsgMigrationFailed dialog explaining XAMPP must
  be running and pointing to Settings → Database for connection params.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 08:21:53 +02:00
parent 48d601176d
commit 6f8a320d69
8 changed files with 526 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ using PSLauncher.Core.Installations;
using PSLauncher.Core.Licensing;
using PSLauncher.Core.Localization;
using PSLauncher.Core.Manifests;
using PSLauncher.Core.Migrations;
using PSLauncher.Core.Process;
using PSLauncher.Core.Updates;
using PSLauncher.Models;
@@ -33,6 +34,7 @@ public sealed partial class MainViewModel : ObservableObject
private readonly ILicenseService _licenseService;
private readonly ILauncherSelfUpdater _selfUpdater;
private readonly IToastService _toastService;
private readonly IDatabaseMigrationService _migrationService;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<MainViewModel> _logger;
@@ -173,6 +175,7 @@ public sealed partial class MainViewModel : ObservableObject
ILicenseService licenseService,
ILauncherSelfUpdater selfUpdater,
IToastService toastService,
IDatabaseMigrationService migrationService,
IServiceProvider serviceProvider,
ILogger<MainViewModel> logger)
{
@@ -187,6 +190,7 @@ public sealed partial class MainViewModel : ObservableObject
_licenseService = licenseService;
_selfUpdater = selfUpdater;
_toastService = toastService;
_migrationService = migrationService;
_serviceProvider = serviceProvider;
_logger = logger;
@@ -655,6 +659,45 @@ public sealed partial class MainViewModel : ObservableObject
try { File.Delete(zipPath); } catch { /* non critique */ }
// 5) Migrations DB MySQL bundled dans _migrations/. Skip si la config
// a désactivé l'auto-apply ou si le ZIP ne contient pas de répertoire.
if (_config.Database.AutoApplyMigrations)
{
StatusMessage = Strings.StatusApplyingMigrations(row.Version);
ProgressDetail = null;
ProgressPercent = 0;
var migProgress = new Progress<MigrationProgress>(mp =>
{
if (mp.Total > 0)
{
var pct = (double)mp.Done / mp.Total * 100.0;
ProgressPercent = pct;
row.ProgressPercent = pct;
}
var label = string.IsNullOrEmpty(mp.CurrentFilename)
? Strings.StatusApplyingMigrations(row.Version)
: Strings.ProgressMigration(mp.Done + 1, mp.Total, mp.CurrentFilename);
ProgressDetail = label;
row.ProgressDetail = label;
});
var migResult = await _migrationService.ApplyMigrationsAsync(target, migProgress, ct);
if (!migResult.AllSucceeded)
{
// L'install a réussi côté fichiers ; on garde la version installée mais on
// notifie l'utilisateur que la DB n'a pas été migrée — la prochaine relance
// de l'install (ou un Settings → "Rejouer migrations") tentera à nouveau.
_logger.LogError("DB migration failed for v{Version}: {Detail}", row.Version, migResult.FailureMessage);
MessageBox.Show(
Strings.MsgMigrationFailed(migResult.FailureMessage ?? "?"),
Strings.MsgBoxMigrationFailed,
MessageBoxButton.OK, MessageBoxImage.Warning);
}
else if (migResult.AppliedCount > 0)
{
_logger.LogInformation("Applied {N} DB migration(s) for v{Version}", migResult.AppliedCount, row.Version);
}
}
StatusMessage = Strings.StatusInstalledSuccess(row.Version);
ProgressDetail = null;
ProgressPercent = 0;