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:
@@ -401,6 +401,30 @@ public static class Strings
|
||||
$"🔍 التحقق من SHA-256 v{version}: {percent}%"
|
||||
);
|
||||
|
||||
// ==================== DB MIGRATIONS ====================
|
||||
public static string StatusApplyingMigrations(string version) => T(
|
||||
$"Application des migrations base de données v{version}…",
|
||||
$"Applying database migrations for v{version}…",
|
||||
$"正在应用 v{version} 的数据库迁移…",
|
||||
$"กำลังปรับใช้การโยกย้ายฐานข้อมูล v{version}…",
|
||||
$"جارٍ تطبيق ترقيات قاعدة البيانات v{version}…"
|
||||
);
|
||||
public static string ProgressMigration(int done, int total, string filename) => T(
|
||||
$"🗄 Migration {done}/{total} : {filename}",
|
||||
$"🗄 Migration {done}/{total}: {filename}",
|
||||
$"🗄 迁移 {done}/{total}:{filename}",
|
||||
$"🗄 การโยกย้าย {done}/{total}: {filename}",
|
||||
$"🗄 الترقية {done}/{total}: {filename}"
|
||||
);
|
||||
public static string MsgBoxMigrationFailed => T("Migration DB échouée", "DB migration failed", "数据库迁移失败", "การโยกย้าย DB ล้มเหลว", "فشل ترقية قاعدة البيانات");
|
||||
public static string MsgMigrationFailed(string detail) => T(
|
||||
$"Une migration de la base de données a échoué :\n\n{detail}\n\nLa version a été extraite mais la base n'a PAS été modifiée (rollback automatique).\nVérifie que XAMPP est démarré et que les paramètres de connexion sont corrects (Settings → Base de données), puis clique « Rejouer les migrations » dans Settings.",
|
||||
$"A database migration failed:\n\n{detail}\n\nThe version was extracted but the database was NOT modified (automatic rollback).\nMake sure XAMPP is running and the connection settings are correct (Settings → Database), then click « Replay migrations » in Settings.",
|
||||
$"数据库迁移失败:\n\n{detail}\n\n版本已解压,但数据库未被修改(自动回滚)。\n请确保 XAMPP 正在运行并且连接设置正确(设置 → 数据库),然后点击设置中的「重新执行迁移」。",
|
||||
$"การโยกย้ายฐานข้อมูลล้มเหลว:\n\n{detail}\n\nเวอร์ชันถูกแตกแล้วแต่ฐานข้อมูลไม่ถูกแก้ไข (rollback อัตโนมัติ)\nตรวจสอบว่า XAMPP กำลังทำงานและการตั้งค่าการเชื่อมต่อถูกต้อง (ตั้งค่า → ฐานข้อมูล) จากนั้นคลิก « เรียกใช้การโยกย้ายอีกครั้ง » ในการตั้งค่า",
|
||||
$"فشلت ترقية قاعدة البيانات:\n\n{detail}\n\nتم استخراج النسخة لكن قاعدة البيانات لم تُعدَّل (rollback تلقائي).\nتأكد من تشغيل XAMPP وأن إعدادات الاتصال صحيحة (الإعدادات → قاعدة البيانات)، ثم انقر « إعادة تشغيل الترقيات » في الإعدادات."
|
||||
);
|
||||
|
||||
public static string StatusPreparingDownload(string version) => T(
|
||||
$"Préparation du téléchargement v{version}…",
|
||||
$"Preparing download for v{version}…",
|
||||
|
||||
313
src/PSLauncher.Core/Migrations/DatabaseMigrationService.cs
Normal file
313
src/PSLauncher.Core/Migrations/DatabaseMigrationService.cs
Normal file
@@ -0,0 +1,313 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MySqlConnector;
|
||||
using PSLauncher.Models;
|
||||
|
||||
namespace PSLauncher.Core.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Implémentation MySQL/MariaDB du service de migration.
|
||||
/// Workflow :
|
||||
/// <code>
|
||||
/// 1. CREATE TABLE IF NOT EXISTS _launcher_migrations (filename, applied_at, checksum, duration_ms)
|
||||
/// 2. SELECT déjà-appliquées
|
||||
/// 3. Liste {installFolder}/_migrations/*.sql triées alphabétiquement
|
||||
/// 4. Pour chaque non-appliquée :
|
||||
/// START TRANSACTION
|
||||
/// Exécute le script (peut contenir plusieurs statements séparés par ;)
|
||||
/// INSERT INTO _launcher_migrations
|
||||
/// COMMIT
|
||||
/// Si exception → ROLLBACK + abort + reporte l'erreur
|
||||
/// 5. Pour chaque appliquée dont le checksum a changé : log warning (script modifié après publication)
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public sealed class DatabaseMigrationService : IDatabaseMigrationService
|
||||
{
|
||||
private const string TrackingTable = "_launcher_migrations";
|
||||
|
||||
private readonly Func<DatabaseConfig> _configProvider;
|
||||
private readonly Func<string?> _passwordProvider;
|
||||
private readonly ILogger<DatabaseMigrationService> _logger;
|
||||
|
||||
public DatabaseMigrationService(
|
||||
Func<DatabaseConfig> configProvider,
|
||||
Func<string?> passwordProvider,
|
||||
ILogger<DatabaseMigrationService> logger)
|
||||
{
|
||||
_configProvider = configProvider;
|
||||
_passwordProvider = passwordProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string?> TestConnectionAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new MySqlCommand("SELECT 1", conn);
|
||||
await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MigrationResult> ApplyMigrationsAsync(
|
||||
string installFolder,
|
||||
IProgress<MigrationProgress>? progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var migrationsDir = Path.Combine(installFolder, "_migrations");
|
||||
if (!Directory.Exists(migrationsDir))
|
||||
{
|
||||
_logger.LogInformation("No _migrations/ directory in {Folder}, skipping DB migration step", installFolder);
|
||||
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
|
||||
}
|
||||
|
||||
// Liste les .sql, ignore les autres extensions (README, etc.)
|
||||
var files = Directory.EnumerateFiles(migrationsDir, "*.sql", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (files.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("_migrations/ exists but empty in {Folder}", installFolder);
|
||||
return new MigrationResult(Array.Empty<MigrationOutcome>(), AllSucceeded: true, FailureMessage: null);
|
||||
}
|
||||
|
||||
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
|
||||
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
|
||||
|
||||
var applied = await LoadAppliedAsync(conn, ct).ConfigureAwait(false);
|
||||
var outcomes = new List<MigrationOutcome>();
|
||||
|
||||
for (int i = 0; i < files.Count; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var filepath = files[i];
|
||||
var filename = Path.GetFileName(filepath);
|
||||
progress?.Report(new MigrationProgress(i, files.Count, filename));
|
||||
|
||||
var sql = await File.ReadAllTextAsync(filepath, Encoding.UTF8, ct).ConfigureAwait(false);
|
||||
var checksum = ComputeSha256(sql);
|
||||
|
||||
if (applied.TryGetValue(filename, out var prevChecksum))
|
||||
{
|
||||
if (!string.Equals(prevChecksum, checksum, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Détection d'une modif après-publication : on skip mais on log fort.
|
||||
_logger.LogWarning(
|
||||
"Migration {File} was modified after being applied (stored sha256={Stored}, current={Now}). " +
|
||||
"Ignored to preserve historical truth — write a NEW migration file instead.",
|
||||
filename, prevChecksum[..12], checksum[..12]);
|
||||
}
|
||||
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Skipped, 0, null));
|
||||
continue;
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await using var tx = await conn.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await ExecuteScriptAsync(conn, tx, sql, ct).ConfigureAwait(false);
|
||||
await using var insertCmd = new MySqlCommand(
|
||||
$"INSERT INTO {TrackingTable} (filename, applied_at, checksum, duration_ms) VALUES (@f, NOW(), @c, @d)",
|
||||
conn, tx);
|
||||
insertCmd.Parameters.AddWithValue("@f", filename);
|
||||
insertCmd.Parameters.AddWithValue("@c", checksum);
|
||||
insertCmd.Parameters.AddWithValue("@d", sw.ElapsedMilliseconds);
|
||||
await insertCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
sw.Stop();
|
||||
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Applied, sw.ElapsedMilliseconds, null));
|
||||
_logger.LogInformation("Migration {File} applied in {Ms} ms", filename, sw.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
try { await tx.RollbackAsync(ct).ConfigureAwait(false); } catch { /* swallow */ }
|
||||
outcomes.Add(new MigrationOutcome(filename, MigrationStatus.Failed, sw.ElapsedMilliseconds, ex.Message));
|
||||
_logger.LogError(ex, "Migration {File} FAILED after {Ms} ms — DB state rolled back", filename, sw.ElapsedMilliseconds);
|
||||
progress?.Report(new MigrationProgress(i + 1, files.Count, filename));
|
||||
return new MigrationResult(outcomes, AllSucceeded: false, FailureMessage: $"{filename} : {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
progress?.Report(new MigrationProgress(files.Count, files.Count, string.Empty));
|
||||
return new MigrationResult(outcomes, AllSucceeded: true, FailureMessage: null);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AppliedMigration>> ListAppliedAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await OpenAsync(ct).ConfigureAwait(false);
|
||||
await EnsureTrackingTableAsync(conn, ct).ConfigureAwait(false);
|
||||
|
||||
var list = new List<AppliedMigration>();
|
||||
await using var cmd = new MySqlCommand(
|
||||
$"SELECT filename, applied_at, checksum, duration_ms FROM {TrackingTable} ORDER BY id",
|
||||
conn);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
list.Add(new AppliedMigration(
|
||||
reader.GetString(0),
|
||||
DateTime.SpecifyKind(reader.GetDateTime(1), DateTimeKind.Utc),
|
||||
reader.GetString(2),
|
||||
reader.GetInt64(3)));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// ----- helpers -----
|
||||
|
||||
private async Task<MySqlConnection> OpenAsync(CancellationToken ct)
|
||||
{
|
||||
var cfg = _configProvider();
|
||||
var pwd = _passwordProvider() ?? string.Empty;
|
||||
var csb = new MySqlConnectionStringBuilder
|
||||
{
|
||||
Server = cfg.Host,
|
||||
Port = cfg.Port,
|
||||
UserID = cfg.User,
|
||||
Password = pwd,
|
||||
Database = cfg.Database,
|
||||
// Désactive le pooling : on ouvre une connexion par opération courte,
|
||||
// pas besoin de garder un pool ouvert sur la machine du user.
|
||||
Pooling = false,
|
||||
ConnectionTimeout = 10,
|
||||
DefaultCommandTimeout = 600, // 10 min : laisse de la marge pour les grosses migrations
|
||||
AllowUserVariables = true,
|
||||
};
|
||||
var conn = new MySqlConnection(csb.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
return conn;
|
||||
}
|
||||
|
||||
private static async Task EnsureTrackingTableAsync(MySqlConnection conn, CancellationToken ct)
|
||||
{
|
||||
const string ddl = $"""
|
||||
CREATE TABLE IF NOT EXISTS {TrackingTable} (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||
applied_at DATETIME NOT NULL,
|
||||
checksum CHAR(64) NOT NULL,
|
||||
duration_ms BIGINT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
""";
|
||||
await using var cmd = new MySqlCommand(ddl, conn);
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<string, string>> LoadAppliedAsync(MySqlConnection conn, CancellationToken ct)
|
||||
{
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
await using var cmd = new MySqlCommand($"SELECT filename, checksum FROM {TrackingTable}", conn);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
map[reader.GetString(0)] = reader.GetString(1);
|
||||
return map;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MySqlCommand exécute UNE commande à la fois. Pour un fichier .sql contenant
|
||||
/// plusieurs statements séparés par <c>;</c>, on parse manuellement (en respectant
|
||||
/// les chaînes / commentaires) et on exécute statement-par-statement dans la
|
||||
/// même transaction. Évite la dépendance à <c>MySqlScript</c> et permet de
|
||||
/// supporter <see cref="CancellationToken"/> proprement.
|
||||
/// </summary>
|
||||
private static async Task ExecuteScriptAsync(MySqlConnection conn, MySqlTransaction tx, string sql, CancellationToken ct)
|
||||
{
|
||||
foreach (var stmt in SplitStatements(sql))
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var trimmed = stmt.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed)) continue;
|
||||
// Skip si juste un commentaire ou une ligne blanche
|
||||
if (IsAllCommentsOrWhitespace(trimmed)) continue;
|
||||
await using var cmd = new MySqlCommand(trimmed, conn, tx);
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllCommentsOrWhitespace(string s)
|
||||
{
|
||||
// Heuristique simple : si après strip des commentaires il reste des trucs non-blancs.
|
||||
var stripped = System.Text.RegularExpressions.Regex.Replace(s, @"(--[^\n]*)|(/\*[\s\S]*?\*/)", "");
|
||||
return string.IsNullOrWhiteSpace(stripped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splitter SQL minimaliste : sépare par <c>;</c> en respectant les chaînes
|
||||
/// (' et "), les identifiants (`...`) et les commentaires (-- ... et /* ... */).
|
||||
/// Suffisant pour la plupart des fichiers de migration courants ; pour des
|
||||
/// triggers/procs avec DELIMITER, écrire chaque statement dans un fichier séparé.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> SplitStatements(string sql)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
int i = 0;
|
||||
char? quote = null;
|
||||
bool inLineComment = false;
|
||||
bool inBlockComment = false;
|
||||
|
||||
while (i < sql.Length)
|
||||
{
|
||||
char c = sql[i];
|
||||
char next = i + 1 < sql.Length ? sql[i + 1] : '\0';
|
||||
|
||||
if (inLineComment)
|
||||
{
|
||||
sb.Append(c);
|
||||
if (c == '\n') inLineComment = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (inBlockComment)
|
||||
{
|
||||
sb.Append(c);
|
||||
if (c == '*' && next == '/') { sb.Append(next); i += 2; inBlockComment = false; continue; }
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (quote is not null)
|
||||
{
|
||||
sb.Append(c);
|
||||
if (c == '\\' && i + 1 < sql.Length) { sb.Append(sql[i + 1]); i += 2; continue; }
|
||||
if (c == quote) quote = null;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// hors-quote, hors-commentaire
|
||||
if (c == '-' && next == '-') { sb.Append(c).Append(next); i += 2; inLineComment = true; continue; }
|
||||
if (c == '/' && next == '*') { sb.Append(c).Append(next); i += 2; inBlockComment = true; continue; }
|
||||
if (c == '\'' || c == '"' || c == '`') { quote = c; sb.Append(c); i++; continue; }
|
||||
|
||||
if (c == ';')
|
||||
{
|
||||
yield return sb.ToString();
|
||||
sb.Clear();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
sb.Append(c);
|
||||
i++;
|
||||
}
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
var tail = sb.ToString().Trim();
|
||||
if (tail.Length > 0) yield return tail;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string text)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(text);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
36
src/PSLauncher.Core/Migrations/DatabasePasswordProtector.cs
Normal file
36
src/PSLauncher.Core/Migrations/DatabasePasswordProtector.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace PSLauncher.Core.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Chiffre / déchiffre le mot de passe MySQL stocké dans <see cref="PSLauncher.Models.DatabaseConfig.EncryptedPassword"/>
|
||||
/// via DPAPI scope CurrentUser. Même mécanisme que pour la clé de license : un user
|
||||
/// qui copierait le config.json sur une autre machine ne pourrait pas déchiffrer.
|
||||
/// </summary>
|
||||
public static class DatabasePasswordProtector
|
||||
{
|
||||
public static string? Protect(string? clearPassword)
|
||||
{
|
||||
if (string.IsNullOrEmpty(clearPassword)) return null;
|
||||
var data = Encoding.UTF8.GetBytes(clearPassword);
|
||||
var protectedBytes = ProtectedData.Protect(data, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
|
||||
return Convert.ToBase64String(protectedBytes);
|
||||
}
|
||||
|
||||
public static string? Unprotect(string? base64)
|
||||
{
|
||||
if (string.IsNullOrEmpty(base64)) return null;
|
||||
try
|
||||
{
|
||||
var protectedBytes = Convert.FromBase64String(base64);
|
||||
var data = ProtectedData.Unprotect(protectedBytes, optionalEntropy: null, scope: DataProtectionScope.CurrentUser);
|
||||
return Encoding.UTF8.GetString(data);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Cache déplacé sur autre machine / autre user → non déchiffrable
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/PSLauncher.Core/Migrations/IDatabaseMigrationService.cs
Normal file
57
src/PSLauncher.Core/Migrations/IDatabaseMigrationService.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
namespace PSLauncher.Core.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Applique les scripts SQL livrés dans le sous-répertoire <c>_migrations/</c>
|
||||
/// d'un build PROSERVE installé. Chaque script tournant en transaction, le
|
||||
/// résultat est tracké dans une table <c>_launcher_migrations</c> pour qu'un
|
||||
/// même script ne soit pas rejoué deux fois (mêmes garanties que Flyway/Doctrine).
|
||||
/// </summary>
|
||||
public interface IDatabaseMigrationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Cherche <c>{installFolder}/_migrations/*.sql</c>, applique en ordre alphabétique
|
||||
/// les scripts pas encore présents dans <c>_launcher_migrations</c>, et reporte la
|
||||
/// progression. Retourne la liste détaillée des opérations (appliquées + skippées).
|
||||
/// </summary>
|
||||
Task<MigrationResult> ApplyMigrationsAsync(
|
||||
string installFolder,
|
||||
IProgress<MigrationProgress>? progress,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Liste les migrations déjà appliquées en base. Utile pour la vue Settings.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AppliedMigration>> ListAppliedAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Test de connexion. Retourne null si OK, sinon le message d'erreur formaté.
|
||||
/// </summary>
|
||||
Task<string?> TestConnectionAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record MigrationProgress(int Done, int Total, string CurrentFilename);
|
||||
|
||||
public sealed record MigrationResult(
|
||||
IReadOnlyList<MigrationOutcome> Outcomes,
|
||||
bool AllSucceeded,
|
||||
string? FailureMessage)
|
||||
{
|
||||
public int AppliedCount => Outcomes.Count(o => o.Status == MigrationStatus.Applied);
|
||||
public int SkippedCount => Outcomes.Count(o => o.Status == MigrationStatus.Skipped);
|
||||
public int FailedCount => Outcomes.Count(o => o.Status == MigrationStatus.Failed);
|
||||
public bool HasNoMigrationsFolder => !Outcomes.Any() && AllSucceeded;
|
||||
}
|
||||
|
||||
public sealed record MigrationOutcome(
|
||||
string Filename,
|
||||
MigrationStatus Status,
|
||||
long DurationMs,
|
||||
string? Error);
|
||||
|
||||
public enum MigrationStatus { Skipped, Applied, Failed }
|
||||
|
||||
public sealed record AppliedMigration(
|
||||
string Filename,
|
||||
DateTime AppliedAt,
|
||||
string Checksum,
|
||||
long DurationMs);
|
||||
@@ -12,6 +12,8 @@
|
||||
<PackageReference Include="Polly" Version="8.4.2" />
|
||||
<PackageReference Include="NSec.Cryptography" Version="24.4.0" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
<!-- MySQL client : MySqlConnector (BSD-3-Clause), pas Oracle MySql.Data (GPL) -->
|
||||
<PackageReference Include="MySqlConnector" Version="2.3.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user