From 79c23e6c65c9f98c107b71261fa93dda02f1a102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 12:45:51 +0100 Subject: [PATCH 1/7] Start M14 prerelease line (0.5.0-preview.2) Co-Authored-By: Claude Opus 4.8 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index b4a880a..9719520 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Luís Amorim - 0.5.0-preview.1 + 0.5.0-preview.2 Apache-2.0 https://github.com/lgamorim/sqlbound https://github.com/lgamorim/sqlbound From 43be653e339c5bc850bd54026c162f9ede894459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 12:51:18 +0100 Subject: [PATCH 2/7] Add migration ledger write side Extend IMigrationLedger with RecordAppliedAsync and RemoveAsync, both taking the ambient DbTransaction so a ledger row commits atomically with the migration's own script. SqlServerMigrationLedger implements them against dbo._sqlbound_migrations. The read methods stay transaction-free since they run outside the per-migration transaction migrate run opens. Co-Authored-By: Claude Opus 4.8 --- src/SqlBound.Migrations/IMigrationLedger.cs | 21 +++++- .../SqlServerMigrationLedger.cs | 30 +++++++++ .../SqlServerMigrationLedgerTests.cs | 65 +++++++++++-------- 3 files changed, 88 insertions(+), 28 deletions(-) diff --git a/src/SqlBound.Migrations/IMigrationLedger.cs b/src/SqlBound.Migrations/IMigrationLedger.cs index 6109dd7..5f16fcc 100644 --- a/src/SqlBound.Migrations/IMigrationLedger.cs +++ b/src/SqlBound.Migrations/IMigrationLedger.cs @@ -4,8 +4,9 @@ namespace SqlBound.Migrations; /// /// The per-provider migration history table (_sqlbound_migrations): the durable record of -/// which migrations have been applied. M13 defines the read side; the write side arrives with -/// migrate run. Implementations own only the SQL — they never open or close the connection. +/// which migrations have been applied. Implementations own only the SQL — they never open or close +/// the connection. The write methods take the ambient transaction so the ledger row commits +/// atomically with the migration's own script; the read methods run outside any transaction. /// public interface IMigrationLedger { @@ -19,4 +20,20 @@ public interface IMigrationLedger /// A token to cancel the operation. /// The applied migrations; empty when none have been applied. Task> GetAppliedAsync(DbConnection connection, CancellationToken cancellationToken); + + /// Records a migration as applied, on the given transaction. + /// An open connection to the target database. + /// The transaction the migration's up-script is running on, or . + /// The migration to record. + /// A token to cancel the operation. + Task RecordAppliedAsync( + DbConnection connection, DbTransaction? transaction, AppliedMigration migration, CancellationToken cancellationToken); + + /// Removes a migration's row from the ledger, on the given transaction. + /// An open connection to the target database. + /// The transaction the migration's down-script is running on, or . + /// The version of the migration to remove. + /// A token to cancel the operation. + Task RemoveAsync( + DbConnection connection, DbTransaction? transaction, long version, CancellationToken cancellationToken); } diff --git a/src/SqlBound.SqlServer/SqlServerMigrationLedger.cs b/src/SqlBound.SqlServer/SqlServerMigrationLedger.cs index ad0e566..d574a44 100644 --- a/src/SqlBound.SqlServer/SqlServerMigrationLedger.cs +++ b/src/SqlBound.SqlServer/SqlServerMigrationLedger.cs @@ -53,6 +53,36 @@ public async Task> GetAppliedAsync( return applied; } + /// + public async Task RecordAppliedAsync( + DbConnection connection, DbTransaction? transaction, AppliedMigration migration, CancellationToken cancellationToken) + { + await using var command = AsSqlConnection(connection).CreateCommand(); + command.Transaction = (SqlTransaction?)transaction; + command.CommandText = + $""" + INSERT INTO dbo.{TableName} (version, name, checksum, applied_on_utc, execution_ms) + VALUES (@version, @name, @checksum, @appliedOnUtc, @executionMs); + """; + command.Parameters.AddWithValue("@version", migration.Version); + command.Parameters.AddWithValue("@name", migration.Name); + command.Parameters.AddWithValue("@checksum", migration.Checksum); + command.Parameters.AddWithValue("@appliedOnUtc", migration.AppliedOnUtc); + command.Parameters.AddWithValue("@executionMs", migration.ExecutionMs); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task RemoveAsync( + DbConnection connection, DbTransaction? transaction, long version, CancellationToken cancellationToken) + { + await using var command = AsSqlConnection(connection).CreateCommand(); + command.Transaction = (SqlTransaction?)transaction; + command.CommandText = $"DELETE FROM dbo.{TableName} WHERE version = @version;"; + command.Parameters.AddWithValue("@version", version); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + private static SqlConnection AsSqlConnection(DbConnection connection) => connection as SqlConnection ?? throw new ArgumentException( diff --git a/test/SqlBound.SqlServer.IntegrationTests/SqlServerMigrationLedgerTests.cs b/test/SqlBound.SqlServer.IntegrationTests/SqlServerMigrationLedgerTests.cs index ca90aa2..8b53d32 100644 --- a/test/SqlBound.SqlServer.IntegrationTests/SqlServerMigrationLedgerTests.cs +++ b/test/SqlBound.SqlServer.IntegrationTests/SqlServerMigrationLedgerTests.cs @@ -35,22 +35,52 @@ public async Task Should_NotThrow_When_EnsureCreatedCalledTwice() } [Fact] - public async Task Should_ReturnAppliedMigrationsOrderedByVersion_When_LedgerHasRows() + public async Task Should_RecordAppliedMigrationsAndReadThemBackOrderedByVersion() { await using var connection = await ResetAsync(); var ledger = new SqlServerMigrationLedger(); await ledger.EnsureCreatedAsync(connection, TestContext.Current.CancellationToken); - await SeedAsync(connection, 20260712150000, "second", 12); - await SeedAsync(connection, 20260712143000, "first", 7); + var second = new AppliedMigration(20260712150000, "second", SampleChecksum, new DateTime(2026, 7, 12, 15, 0, 0), 12); + var first = new AppliedMigration(20260712143000, "first", SampleChecksum, new DateTime(2026, 7, 12, 14, 30, 0), 7); + await ledger.RecordAppliedAsync(connection, null, second, TestContext.Current.CancellationToken); + await ledger.RecordAppliedAsync(connection, null, first, TestContext.Current.CancellationToken); var applied = await ledger.GetAppliedAsync(connection, TestContext.Current.CancellationToken); - Assert.Equal( - [ - new AppliedMigration(20260712143000, "first", SampleChecksum, new DateTime(2026, 7, 12, 14, 30, 0), 7), - new AppliedMigration(20260712150000, "second", SampleChecksum, new DateTime(2026, 7, 12, 15, 0, 0), 12), - ], - applied); + Assert.Equal([first, second], applied); + } + + [Fact] + public async Task Should_RemoveOnlyTheNamedMigration_When_RemoveCalled() + { + await using var connection = await ResetAsync(); + var ledger = new SqlServerMigrationLedger(); + await ledger.EnsureCreatedAsync(connection, TestContext.Current.CancellationToken); + var first = new AppliedMigration(20260712143000, "first", SampleChecksum, new DateTime(2026, 7, 12, 14, 30, 0), 7); + var second = new AppliedMigration(20260712150000, "second", SampleChecksum, new DateTime(2026, 7, 12, 15, 0, 0), 12); + await ledger.RecordAppliedAsync(connection, null, first, TestContext.Current.CancellationToken); + await ledger.RecordAppliedAsync(connection, null, second, TestContext.Current.CancellationToken); + + await ledger.RemoveAsync(connection, null, second.Version, TestContext.Current.CancellationToken); + + Assert.Equal([first], await ledger.GetAppliedAsync(connection, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Should_LeaveNoRow_When_TransactionRecordingAMigrationIsRolledBack() + { + await using var connection = await ResetAsync(); + var ledger = new SqlServerMigrationLedger(); + await ledger.EnsureCreatedAsync(connection, TestContext.Current.CancellationToken); + var migration = new AppliedMigration(20260712143000, "first", SampleChecksum, new DateTime(2026, 7, 12, 14, 30, 0), 7); + + await using (var transaction = (SqlTransaction)await connection.BeginTransactionAsync(TestContext.Current.CancellationToken)) + { + await ledger.RecordAppliedAsync(connection, transaction, migration, TestContext.Current.CancellationToken); + await transaction.RollbackAsync(TestContext.Current.CancellationToken); + } + + Assert.Empty(await ledger.GetAppliedAsync(connection, TestContext.Current.CancellationToken)); } private async Task ResetAsync() @@ -61,21 +91,4 @@ private async Task ResetAsync() await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); return connection; } - - private static async Task SeedAsync(SqlConnection connection, long version, string name, long executionMs) - { - await using var command = connection.CreateCommand(); - command.CommandText = - """ - INSERT INTO dbo._sqlbound_migrations (version, name, checksum, applied_on_utc, execution_ms) - VALUES (@version, @name, @checksum, - CASE @version WHEN 20260712143000 THEN '2026-07-12T14:30:00' ELSE '2026-07-12T15:00:00' END, - @executionMs); - """; - command.Parameters.AddWithValue("@version", version); - command.Parameters.AddWithValue("@name", name); - command.Parameters.AddWithValue("@checksum", SampleChecksum); - command.Parameters.AddWithValue("@executionMs", executionMs); - await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); - } } From 1993256f808850c928299d5377408ef376ba878c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 12:54:27 +0100 Subject: [PATCH 3/7] Add pure migration planner, status report, and reverter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factor the run/revert/status decision logic into pure, unit-tested functions over the loaded migrations and the applied ledger rows: - MigrationPlan.Create computes the ordered pending set and enforces the two safety rules — checksum drift on an applied migration and a pending migration ordered before an applied one both throw. - MigrationStatusReport.Build classifies each migration as Applied, Pending, Drifted, or Missing. - MigrationReverter.Plan selects the most recently applied migration to roll back, refusing when it is irreversible or its files are gone. MigrationInconsistencyException marks a directory that disagrees with the applied history, distinct from a malformed directory (MigrationFormatException). Co-Authored-By: Claude Opus 4.8 --- .../MigrationInconsistencyException.cs | 18 ++++++ src/SqlBound.Migrations/MigrationPlan.cs | 53 ++++++++++++++++ src/SqlBound.Migrations/MigrationReverter.cs | 40 ++++++++++++ src/SqlBound.Migrations/MigrationState.cs | 17 +++++ src/SqlBound.Migrations/MigrationStatus.cs | 8 +++ .../MigrationStatusReport.cs | 45 ++++++++++++++ .../MigrationPlanTests.cs | 62 +++++++++++++++++++ .../MigrationReverterTests.cs | 44 +++++++++++++ .../MigrationStatusReportTests.cs | 47 ++++++++++++++ 9 files changed, 334 insertions(+) create mode 100644 src/SqlBound.Migrations/MigrationInconsistencyException.cs create mode 100644 src/SqlBound.Migrations/MigrationPlan.cs create mode 100644 src/SqlBound.Migrations/MigrationReverter.cs create mode 100644 src/SqlBound.Migrations/MigrationState.cs create mode 100644 src/SqlBound.Migrations/MigrationStatus.cs create mode 100644 src/SqlBound.Migrations/MigrationStatusReport.cs create mode 100644 test/SqlBound.Migrations.UnitTests/MigrationPlanTests.cs create mode 100644 test/SqlBound.Migrations.UnitTests/MigrationReverterTests.cs create mode 100644 test/SqlBound.Migrations.UnitTests/MigrationStatusReportTests.cs diff --git a/src/SqlBound.Migrations/MigrationInconsistencyException.cs b/src/SqlBound.Migrations/MigrationInconsistencyException.cs new file mode 100644 index 0000000..758b598 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationInconsistencyException.cs @@ -0,0 +1,18 @@ +namespace SqlBound.Migrations; + +/// +/// Thrown when the migrations directory is inconsistent with the applied history: an applied +/// migration's up-script has been edited (checksum drift), a not-yet-applied migration is ordered +/// before one that is already applied, or the migration to revert is irreversible or missing. This +/// is distinct from , which is about the directory being +/// malformed in isolation, before any comparison with the ledger. +/// +public sealed class MigrationInconsistencyException : Exception +{ + /// Initializes a new instance of the class. + /// A description of how the directory and the applied history disagree. + public MigrationInconsistencyException(string message) + : base(message) + { + } +} diff --git a/src/SqlBound.Migrations/MigrationPlan.cs b/src/SqlBound.Migrations/MigrationPlan.cs new file mode 100644 index 0000000..e6699c7 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationPlan.cs @@ -0,0 +1,53 @@ +namespace SqlBound.Migrations; + +/// +/// The ordered set of migrations migrate run would apply, computed from the directory and +/// the ledger. Creating the plan is also where the run's safety checks live: it refuses to proceed +/// if an already-applied migration has been edited, or if a pending migration is ordered before one +/// that is already applied. +/// +/// The migrations to apply, ascending by version. +public sealed record MigrationPlan(IReadOnlyList Pending) +{ + /// Computes the plan, validating the directory against the applied history. + /// The migrations on disk, as loaded from the directory. + /// The migrations the ledger records as applied. + /// + /// An applied migration's checksum has drifted, or a pending migration is out of order. + /// + public static MigrationPlan Create( + IReadOnlyList migrations, IReadOnlyList applied) + { + var appliedByVersion = applied.ToDictionary(row => row.Version); + + foreach (var migration in migrations) + { + if (appliedByVersion.TryGetValue(migration.Version, out var appliedRow) + && migration.Checksum != appliedRow.Checksum) + { + throw new MigrationInconsistencyException( + $"migration {migration.Version}_{migration.Name} has been modified since it was applied " + + "(checksum mismatch); an applied migration must not be edited."); + } + } + + var pending = migrations + .Where(migration => !appliedByVersion.ContainsKey(migration.Version)) + .OrderBy(migration => migration.Version) + .ToList(); + + if (applied.Count > 0 && pending.Count > 0) + { + var highestApplied = applied.Max(row => row.Version); + var outOfOrder = pending.FirstOrDefault(migration => migration.Version < highestApplied); + if (outOfOrder is not null) + { + throw new MigrationInconsistencyException( + $"migration {outOfOrder.Version}_{outOfOrder.Name} is ordered before the already-applied " + + $"migration {highestApplied}; migrations must be applied in version order."); + } + } + + return new MigrationPlan(pending); + } +} diff --git a/src/SqlBound.Migrations/MigrationReverter.cs b/src/SqlBound.Migrations/MigrationReverter.cs new file mode 100644 index 0000000..4050e23 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationReverter.cs @@ -0,0 +1,40 @@ +namespace SqlBound.Migrations; + +/// +/// Selects the migration migrate revert would roll back: the most recently applied one. The +/// selection fails if that migration's files are gone or it ships no down script, so the caller +/// never runs a rollback it cannot honor. +/// +public static class MigrationReverter +{ + /// Chooses the migration to revert. + /// The migrations on disk, as loaded from the directory. + /// The migrations the ledger records as applied. + /// The migration to revert, or when nothing is applied. + /// + /// The most recently applied migration is missing from disk or is irreversible. + /// + public static Migration? Plan(IReadOnlyList migrations, IReadOnlyList applied) + { + if (applied.Count == 0) + { + return null; + } + + var target = applied.OrderByDescending(row => row.Version).First(); + var migration = migrations.FirstOrDefault(candidate => candidate.Version == target.Version); + if (migration is null) + { + throw new MigrationInconsistencyException( + $"cannot revert {target.Version}_{target.Name}: its migration files are missing."); + } + + if (!migration.IsReversible) + { + throw new MigrationInconsistencyException( + $"cannot revert {migration.Version}_{migration.Name}: it is irreversible (no down script)."); + } + + return migration; + } +} diff --git a/src/SqlBound.Migrations/MigrationState.cs b/src/SqlBound.Migrations/MigrationState.cs new file mode 100644 index 0000000..2e3d440 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationState.cs @@ -0,0 +1,17 @@ +namespace SqlBound.Migrations; + +/// A migration's state relative to the ledger, as reported by migrate status. +public enum MigrationState +{ + /// Present on disk and applied; the checksums agree. + Applied, + + /// Present on disk but not yet applied. + Pending, + + /// Applied, but the on-disk up-script no longer matches the applied checksum. + Drifted, + + /// Recorded as applied in the ledger, but no longer present on disk. + Missing, +} diff --git a/src/SqlBound.Migrations/MigrationStatus.cs b/src/SqlBound.Migrations/MigrationStatus.cs new file mode 100644 index 0000000..587597e --- /dev/null +++ b/src/SqlBound.Migrations/MigrationStatus.cs @@ -0,0 +1,8 @@ +namespace SqlBound.Migrations; + +/// One migration's state relative to the ledger. +/// The migration's timestamp version. +/// The migration's name slug. +/// The migration's state relative to the applied history. +/// When the migration was applied, or if it is pending. +public sealed record MigrationStatus(long Version, string Name, MigrationState State, DateTime? AppliedOnUtc); diff --git a/src/SqlBound.Migrations/MigrationStatusReport.cs b/src/SqlBound.Migrations/MigrationStatusReport.cs new file mode 100644 index 0000000..8837607 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationStatusReport.cs @@ -0,0 +1,45 @@ +namespace SqlBound.Migrations; + +/// +/// Classifies every migration — on disk, in the ledger, or both — into a , +/// ordered by version. This is the read-only view behind migrate status; unlike +/// it reports drift and missing migrations rather than throwing on them. +/// +public static class MigrationStatusReport +{ + /// Classifies every known migration into its state relative to the ledger. + /// The migrations on disk, as loaded from the directory. + /// The migrations the ledger records as applied. + /// One status per migration known to either source, ascending by version. + public static IReadOnlyList Build( + IReadOnlyList migrations, IReadOnlyList applied) + { + var migrationsByVersion = migrations.ToDictionary(migration => migration.Version); + var appliedByVersion = applied.ToDictionary(row => row.Version); + + var report = new List(); + foreach (var version in migrationsByVersion.Keys.Union(appliedByVersion.Keys).OrderBy(version => version)) + { + migrationsByVersion.TryGetValue(version, out var migration); + appliedByVersion.TryGetValue(version, out var appliedRow); + + report.Add((migration, appliedRow) switch + { + (null, not null) => + new MigrationStatus(version, appliedRow.Name, MigrationState.Missing, appliedRow.AppliedOnUtc), + (not null, not null) => + new MigrationStatus( + version, + migration.Name, + migration.Checksum == appliedRow.Checksum ? MigrationState.Applied : MigrationState.Drifted, + appliedRow.AppliedOnUtc), + (not null, null) => + new MigrationStatus(version, migration.Name, MigrationState.Pending, null), + (null, null) => + throw new InvalidOperationException($"Version {version} is in neither collection."), + }); + } + + return report; + } +} diff --git a/test/SqlBound.Migrations.UnitTests/MigrationPlanTests.cs b/test/SqlBound.Migrations.UnitTests/MigrationPlanTests.cs new file mode 100644 index 0000000..23a07ef --- /dev/null +++ b/test/SqlBound.Migrations.UnitTests/MigrationPlanTests.cs @@ -0,0 +1,62 @@ +namespace SqlBound.Migrations.UnitTests; + +public sealed class MigrationPlanTests +{ + [Fact] + public void Should_ReturnAllMigrationsAsPending_When_NothingApplied() + { + var plan = MigrationPlan.Create([Migration(1), Migration(2)], []); + + Assert.Equal([1, 2], plan.Pending.Select(migration => migration.Version)); + } + + [Fact] + public void Should_ReturnOnlyUnappliedMigrations_When_SomeApplied() + { + var plan = MigrationPlan.Create( + [Migration(1), Migration(2), Migration(3)], + [Applied(1)]); + + Assert.Equal([2, 3], plan.Pending.Select(migration => migration.Version)); + } + + [Fact] + public void Should_ReturnEmpty_When_AllApplied() + { + var plan = MigrationPlan.Create([Migration(1), Migration(2)], [Applied(1), Applied(2)]); + + Assert.Empty(plan.Pending); + } + + [Fact] + public void Should_TolerateAppliedMigrationWithNoFile_When_LedgerHasExtraVersion() + { + var plan = MigrationPlan.Create([Migration(2)], [Applied(1), Applied(2)]); + + Assert.Empty(plan.Pending); + } + + [Fact] + public void Should_ThrowInconsistency_When_AppliedMigrationChecksumDiffers() + { + var exception = Assert.Throws( + () => MigrationPlan.Create([Migration(1, "aaa")], [Applied(1, "bbb")])); + + Assert.Contains("checksum", exception.Message); + } + + [Fact] + public void Should_ThrowInconsistency_When_PendingMigrationIsOrderedBeforeAnApplied() + { + var exception = Assert.Throws( + () => MigrationPlan.Create([Migration(1), Migration(2)], [Applied(2)])); + + Assert.Contains("version order", exception.Message); + } + + private static Migration Migration(long version, string checksum = "checksum") => + new(version, $"m{version}", $"-- up {version}", $"-- down {version}", checksum); + + private static AppliedMigration Applied(long version, string checksum = "checksum") => + new(version, $"m{version}", checksum, new DateTime(2026, 7, 12, 0, 0, 0), 1); +} diff --git a/test/SqlBound.Migrations.UnitTests/MigrationReverterTests.cs b/test/SqlBound.Migrations.UnitTests/MigrationReverterTests.cs new file mode 100644 index 0000000..51258e0 --- /dev/null +++ b/test/SqlBound.Migrations.UnitTests/MigrationReverterTests.cs @@ -0,0 +1,44 @@ +namespace SqlBound.Migrations.UnitTests; + +public sealed class MigrationReverterTests +{ + [Fact] + public void Should_ReturnNull_When_NothingApplied() + { + Assert.Null(MigrationReverter.Plan([Migration(1)], [])); + } + + [Fact] + public void Should_ReturnMostRecentlyApplied_When_ReversibleMigrationsApplied() + { + var target = MigrationReverter.Plan([Migration(1), Migration(2)], [Applied(1), Applied(2)]); + + Assert.Equal(2, target!.Version); + } + + [Fact] + public void Should_ThrowInconsistency_When_TargetMigrationFilesAreMissing() + { + var exception = Assert.Throws( + () => MigrationReverter.Plan([], [Applied(1)])); + + Assert.Contains("missing", exception.Message); + } + + [Fact] + public void Should_ThrowInconsistency_When_TargetMigrationIsIrreversible() + { + var irreversible = new Migration(1, "m1", "-- up 1", DownScript: null, "checksum"); + + var exception = Assert.Throws( + () => MigrationReverter.Plan([irreversible], [Applied(1)])); + + Assert.Contains("irreversible", exception.Message); + } + + private static Migration Migration(long version, string checksum = "checksum") => + new(version, $"m{version}", $"-- up {version}", $"-- down {version}", checksum); + + private static AppliedMigration Applied(long version, string checksum = "checksum") => + new(version, $"m{version}", checksum, new DateTime(2026, 7, 12, 0, 0, 0), 1); +} diff --git a/test/SqlBound.Migrations.UnitTests/MigrationStatusReportTests.cs b/test/SqlBound.Migrations.UnitTests/MigrationStatusReportTests.cs new file mode 100644 index 0000000..d0ce431 --- /dev/null +++ b/test/SqlBound.Migrations.UnitTests/MigrationStatusReportTests.cs @@ -0,0 +1,47 @@ +namespace SqlBound.Migrations.UnitTests; + +public sealed class MigrationStatusReportTests +{ + [Fact] + public void Should_ClassifyAppliedAndPending_When_SomeMigrationsApplied() + { + var report = MigrationStatusReport.Build([Migration(1), Migration(2)], [Applied(1)]); + + Assert.Equal( + [ + new MigrationStatus(1, "m1", MigrationState.Applied, new DateTime(2026, 7, 12, 0, 0, 0)), + new MigrationStatus(2, "m2", MigrationState.Pending, null), + ], + report); + } + + [Fact] + public void Should_ClassifyDrifted_When_ChecksumDiffers() + { + var report = MigrationStatusReport.Build([Migration(1, "aaa")], [Applied(1, "bbb")]); + + Assert.Equal(MigrationState.Drifted, Assert.Single(report).State); + } + + [Fact] + public void Should_ClassifyMissing_When_AppliedMigrationHasNoFile() + { + var report = MigrationStatusReport.Build([], [Applied(1)]); + + Assert.Equal(MigrationState.Missing, Assert.Single(report).State); + } + + [Fact] + public void Should_OrderByVersion_When_MigrationsAndLedgerInterleave() + { + var report = MigrationStatusReport.Build([Migration(3), Migration(1)], [Applied(1), Applied(2)]); + + Assert.Equal([1, 2, 3], report.Select(status => status.Version)); + } + + private static Migration Migration(long version, string checksum = "checksum") => + new(version, $"m{version}", $"-- up {version}", $"-- down {version}", checksum); + + private static AppliedMigration Applied(long version, string checksum = "checksum") => + new(version, $"m{version}", checksum, new DateTime(2026, 7, 12, 0, 0, 0), 1); +} From 0c38950c114609e053292e8c102f379793fb0e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 13:00:08 +0100 Subject: [PATCH 4/7] Add migrate run engine and CLI command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MigrationRunner.RunAsync ensures the ledger, plans against the applied history, and applies each pending migration in its own transaction together with its ledger row — a failing script rolls back that one migration (raising MigrationExecutionException) and leaves every earlier one applied. The provider-neutral engine executes scripts as ordinary DbCommands so every provider will share it. The CLI 'migrate run' subcommand wires it for SQL Server through a shared MigrationCli helper that resolves the target, loads the directory, opens the connection, and maps expected failures to exit code 1. Co-Authored-By: Claude Opus 4.8 --- src/SqlBound.Cli/MigrateCommand.cs | 80 ++++++++++--- src/SqlBound.Cli/MigrationCli.cs | 82 ++++++++++++++ .../MigrationExecutionException.cs | 26 +++++ src/SqlBound.Migrations/MigrationRunner.cs | 85 ++++++++++++++ .../MigrationRunnerTests.cs | 105 ++++++++++++++++++ 5 files changed, 362 insertions(+), 16 deletions(-) create mode 100644 src/SqlBound.Cli/MigrationCli.cs create mode 100644 src/SqlBound.Migrations/MigrationExecutionException.cs create mode 100644 src/SqlBound.Migrations/MigrationRunner.cs create mode 100644 test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerTests.cs diff --git a/src/SqlBound.Cli/MigrateCommand.cs b/src/SqlBound.Cli/MigrateCommand.cs index 971b8c8..6f715b2 100644 --- a/src/SqlBound.Cli/MigrateCommand.cs +++ b/src/SqlBound.Cli/MigrateCommand.cs @@ -1,23 +1,28 @@ using System.CommandLine; +using SqlBound.Migrations; namespace SqlBound.Cli; /// -/// Builds the migrate command tree. M13 ships migrate add (scaffold a new migration); -/// run, revert, and status follow in M14. +/// Builds the migrate command tree: add scaffolds a migration, run applies the +/// pending ones. revert and status are added alongside run as M14 proceeds. /// internal static class MigrateCommand { - public static Command Build() + public static Command Build() => + new("migrate", "Author and apply SQL-file migrations.") + { + BuildAdd(), + BuildRun(), + }; + + private static Command BuildAdd() { var nameArgument = new Argument("name") { Description = "A short description of the migration, e.g. \"create items\".", }; - var migrationsOption = new Option("--migrations") - { - Description = "The migrations directory (default: ./migrations).", - }; + var migrationsOption = MigrationsOption(); var irreversibleOption = new Option("--irreversible") { Description = "Scaffold only the up script; the migration cannot be reverted.", @@ -33,16 +38,10 @@ public static Command Build() }; addCommand.SetAction(parseResult => { - var directory = parseResult.GetValue(migrationsOption); - if (string.IsNullOrWhiteSpace(directory)) - { - directory = Path.Combine(Directory.GetCurrentDirectory(), "migrations"); - } - try { var created = MigrationScaffolder.Create( - directory, + ResolveDirectory(parseResult.GetValue(migrationsOption)), parseResult.GetValue(nameArgument)!, reversible: !parseResult.GetValue(irreversibleOption), TimeProvider.System); @@ -60,9 +59,58 @@ public static Command Build() } }); - return new Command("migrate", "Author and apply SQL-file migrations.") + return addCommand; + } + + private static Command BuildRun() + { + var migrationsOption = MigrationsOption(); + var connectionOption = ConnectionOption(); + + var runCommand = new Command("run", "Apply all pending migrations, each in its own transaction.") { - addCommand, + migrationsOption, + connectionOption, }; + runCommand.SetAction((parseResult, cancellationToken) => MigrationCli.ExecuteAsync( + parseResult.GetValue(connectionOption), + ResolveDirectory(parseResult.GetValue(migrationsOption)), + async (connection, ledger, migrations) => + { + var applied = await MigrationRunner + .RunAsync(connection, ledger, migrations, TimeProvider.System, cancellationToken) + .ConfigureAwait(false); + if (applied.Count == 0) + { + Console.Out.WriteLine("already up to date; no migrations to apply."); + return 0; + } + + foreach (var migration in applied) + { + Console.Out.WriteLine($"applied {migration.Version}_{migration.Name} ({migration.ExecutionMs} ms)"); + } + + Console.Out.WriteLine($"applied {applied.Count} migration(s)."); + return 0; + }, + cancellationToken)); + + return runCommand; } + + private static Option MigrationsOption() => + new("--migrations") { Description = "The migrations directory (default: ./migrations)." }; + + private static Option ConnectionOption() => + new("--connection") + { + Description = + $"Connection string or provider URL (default: the {DatabaseUrl.EnvironmentVariable} environment variable).", + }; + + private static string ResolveDirectory(string? migrationsDirectory) => + string.IsNullOrWhiteSpace(migrationsDirectory) + ? Path.Combine(Directory.GetCurrentDirectory(), "migrations") + : migrationsDirectory; } diff --git a/src/SqlBound.Cli/MigrationCli.cs b/src/SqlBound.Cli/MigrationCli.cs new file mode 100644 index 0000000..87c636d --- /dev/null +++ b/src/SqlBound.Cli/MigrationCli.cs @@ -0,0 +1,82 @@ +using System.Data.Common; +using Microsoft.Data.SqlClient; +using SqlBound.Migrations; +using SqlBound.SqlServer; + +namespace SqlBound.Cli; + +/// +/// Shared plumbing for the connected migrate subcommands (run, revert, +/// status): resolve the target, load the migrations directory, open the connection, build the +/// SQL Server ledger, and invoke the command's action — translating every expected failure into an +/// error message and a non-zero exit code. SQL Server only in this release. +/// +internal static class MigrationCli +{ + public static async Task ExecuteAsync( + string? connectionValue, + string migrationsDirectory, + Func, Task> action, + CancellationToken cancellationToken) + { + var databaseValue = connectionValue ?? Environment.GetEnvironmentVariable(DatabaseUrl.EnvironmentVariable); + if (string.IsNullOrWhiteSpace(databaseValue)) + { + Console.Error.WriteLine($"error: set {DatabaseUrl.EnvironmentVariable} or pass --connection."); + return 1; + } + + DatabaseTarget target; + try + { + target = DatabaseUrl.Resolve(databaseValue); + } + catch (ArgumentException exception) + { + Console.Error.WriteLine($"error: {exception.Message}"); + return 1; + } + + if (target.Provider != DatabaseProviders.SqlServer) + { + Console.Error.WriteLine("error: 'migrate' currently supports SQL Server only."); + return 1; + } + + IReadOnlyList migrations; + try + { + migrations = MigrationDirectory.Load(migrationsDirectory); + } + catch (Exception exception) when (exception is MigrationFormatException or DirectoryNotFoundException) + { + Console.Error.WriteLine($"error: {exception.Message}"); + return 1; + } + + var connection = new SqlConnection(target.ConnectionString); + await using (connection.ConfigureAwait(false)) + { + try + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is SqlException or InvalidOperationException) + { + Console.Error.WriteLine($"error: cannot connect to the database: {exception.Message}"); + return 1; + } + + try + { + return await action(connection, new SqlServerMigrationLedger(), migrations).ConfigureAwait(false); + } + catch (Exception exception) + when (exception is MigrationInconsistencyException or MigrationExecutionException or DbException) + { + Console.Error.WriteLine($"error: {exception.Message}"); + return 1; + } + } + } +} diff --git a/src/SqlBound.Migrations/MigrationExecutionException.cs b/src/SqlBound.Migrations/MigrationExecutionException.cs new file mode 100644 index 0000000..dee8a40 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationExecutionException.cs @@ -0,0 +1,26 @@ +namespace SqlBound.Migrations; + +/// +/// Thrown when a migration's script fails to execute against the database. The offending +/// migration's transaction is rolled back before this is raised, so no partial change or ledger row +/// survives. The database's own exception is the . +/// +public sealed class MigrationExecutionException : Exception +{ + /// Initializes a new instance of the class. + /// The version of the migration that failed. + /// The name of the migration that failed. + /// The database exception that caused the failure. + public MigrationExecutionException(long version, string name, Exception innerException) + : base($"migration {version}_{name} failed: {innerException.Message}", innerException) + { + Version = version; + Name = name; + } + + /// The version of the migration that failed. + public long Version { get; } + + /// The name of the migration that failed. + public string Name { get; } +} diff --git a/src/SqlBound.Migrations/MigrationRunner.cs b/src/SqlBound.Migrations/MigrationRunner.cs new file mode 100644 index 0000000..2962c16 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationRunner.cs @@ -0,0 +1,85 @@ +using System.Data.Common; +using System.Diagnostics; + +namespace SqlBound.Migrations; + +/// +/// Applies and reverts migrations against a database. The orchestration is provider-neutral — a +/// migration script is executed as an ordinary and the ledger is written +/// through — so every provider shares this engine. Each migration is +/// applied in its own transaction together with its ledger row: a failure rolls back that one +/// migration and stops, leaving every earlier migration applied. +/// +public static class MigrationRunner +{ + /// Applies every pending migration, in version order. + /// An open connection to the target database. + /// The migration ledger for the target provider. + /// The migrations on disk, as loaded from the directory. + /// Supplies the applied_on_utc timestamp. + /// A token to cancel the operation. + /// The migrations applied by this run, in order; empty when already up to date. + /// The directory disagrees with the applied history. + /// A migration's up-script failed to execute. + public static async Task> RunAsync( + DbConnection connection, + IMigrationLedger ledger, + IReadOnlyList migrations, + TimeProvider timeProvider, + CancellationToken cancellationToken) + { + await ledger.EnsureCreatedAsync(connection, cancellationToken).ConfigureAwait(false); + var applied = await ledger.GetAppliedAsync(connection, cancellationToken).ConfigureAwait(false); + var plan = MigrationPlan.Create(migrations, applied); + + var appliedNow = new List(); + foreach (var migration in plan.Pending) + { + var record = await ApplyAsync(connection, ledger, migration, timeProvider, cancellationToken) + .ConfigureAwait(false); + appliedNow.Add(record); + } + + return appliedNow; + } + + private static async Task ApplyAsync( + DbConnection connection, + IMigrationLedger ledger, + Migration migration, + TimeProvider timeProvider, + CancellationToken cancellationToken) + { + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + try + { + var stopwatch = Stopwatch.StartNew(); + await ExecuteScriptAsync(connection, transaction, migration.UpScript, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + + var record = new AppliedMigration( + migration.Version, + migration.Name, + migration.Checksum, + timeProvider.GetUtcNow().UtcDateTime, + stopwatch.ElapsedMilliseconds); + await ledger.RecordAppliedAsync(connection, transaction, record, cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return record; + } + catch (DbException exception) + { + await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); + throw new MigrationExecutionException(migration.Version, migration.Name, exception); + } + } + + private static async Task ExecuteScriptAsync( + DbConnection connection, DbTransaction transaction, string script, CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = script; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerTests.cs b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerTests.cs new file mode 100644 index 0000000..91b1824 --- /dev/null +++ b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerTests.cs @@ -0,0 +1,105 @@ +using Microsoft.Data.SqlClient; +using SqlBound.Migrations; + +namespace SqlBound.SqlServer.IntegrationTests; + +/// +/// Exercises end to end against the shared container with the real +/// SQL Server ledger. Each test drops the ledger and the tables its migrations create, so the +/// sequential methods start from a clean slate. +/// +public sealed class MigrationRunnerTests(SqlServerFixture fixture) +{ + private readonly SqlServerMigrationLedger _ledger = new(); + private readonly TimeProvider _clock = TimeProvider.System; + + [Fact] + public async Task Should_ApplyAllPendingMigrations_When_Run() + { + await using var connection = await ResetAsync("mr_a", "mr_b"); + var migrations = new[] + { + Migration(20260712100000, "create_a", "CREATE TABLE dbo.mr_a (id int);", "aaa"), + Migration(20260712110000, "create_b", "CREATE TABLE dbo.mr_b (id int);", "bbb"), + }; + + var applied = await MigrationRunner.RunAsync(connection, _ledger, migrations, _clock, Token); + + Assert.Equal([20260712100000, 20260712110000], applied.Select(migration => migration.Version)); + Assert.True(await TableExistsAsync(connection, "mr_a")); + Assert.True(await TableExistsAsync(connection, "mr_b")); + Assert.Equal(2, (await _ledger.GetAppliedAsync(connection, Token)).Count); + } + + [Fact] + public async Task Should_ApplyNothing_When_AlreadyUpToDate() + { + await using var connection = await ResetAsync("mr_a"); + var migrations = new[] { Migration(20260712100000, "create_a", "CREATE TABLE dbo.mr_a (id int);", "aaa") }; + await MigrationRunner.RunAsync(connection, _ledger, migrations, _clock, Token); + + var second = await MigrationRunner.RunAsync(connection, _ledger, migrations, _clock, Token); + + Assert.Empty(second); + Assert.Single(await _ledger.GetAppliedAsync(connection, Token)); + } + + [Fact] + public async Task Should_RollBackFailedMigrationAndStop_When_AScriptErrors() + { + await using var connection = await ResetAsync("mr_a", "mr_bad"); + var migrations = new[] + { + Migration(20260712100000, "create_a", "CREATE TABLE dbo.mr_a (id int);", "aaa"), + Migration(20260712110000, "broken", "CREATE TABLE dbo.mr_bad (id int) THIS IS NOT SQL;", "bad"), + }; + + var exception = await Assert.ThrowsAsync( + () => MigrationRunner.RunAsync(connection, _ledger, migrations, _clock, Token)); + + Assert.Equal(20260712110000, exception.Version); + Assert.True(await TableExistsAsync(connection, "mr_a")); + Assert.False(await TableExistsAsync(connection, "mr_bad")); + Assert.Single(await _ledger.GetAppliedAsync(connection, Token)); + } + + [Fact] + public async Task Should_ThrowInconsistency_When_AnAppliedMigrationHasBeenEdited() + { + await using var connection = await ResetAsync("mr_a"); + await MigrationRunner.RunAsync( + connection, _ledger, + [Migration(20260712100000, "create_a", "CREATE TABLE dbo.mr_a (id int);", "aaa")], + _clock, Token); + + await Assert.ThrowsAsync( + () => MigrationRunner.RunAsync( + connection, _ledger, + [Migration(20260712100000, "create_a", "CREATE TABLE dbo.mr_a (id int, extra int);", "edited")], + _clock, Token)); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static Migration Migration(long version, string name, string upScript, string checksum) => + // Real checksums are 64-char SHA-256 hex; pad so the CHAR(64) ledger column round-trips exactly. + new(version, name, upScript, $"DROP TABLE dbo.{name};", checksum.PadRight(64, '0')); + + private async Task ResetAsync(params string[] tables) + { + var connection = await fixture.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + var drops = string.Join("\n", tables.Select(table => $"DROP TABLE IF EXISTS dbo.{table};")); + command.CommandText = $"DROP TABLE IF EXISTS dbo._sqlbound_migrations;\n{drops}"; + await command.ExecuteNonQueryAsync(Token); + return connection; + } + + private static async Task TableExistsAsync(SqlConnection connection, string table) + { + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT OBJECT_ID(@name, N'U');"; + command.Parameters.AddWithValue("@name", $"dbo.{table}"); + return await command.ExecuteScalarAsync(Token) is not (null or DBNull); + } +} From 50bd7230fd27242ee6227a01f38850664038463e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 13:01:32 +0100 Subject: [PATCH 5/7] Add migrate revert engine and CLI command MigrationRunner.RevertAsync rolls back the most recently applied migration: it runs the down-script and removes the ledger row in one transaction, refusing (via MigrationReverter) when that migration is irreversible or its files are gone, and no-opping when nothing is applied. The CLI 'migrate revert' subcommand wires it for SQL Server. Co-Authored-By: Claude Opus 4.8 --- src/SqlBound.Cli/MigrateCommand.cs | 29 +++++++ src/SqlBound.Migrations/MigrationRunner.cs | 38 +++++++++ .../MigrationRunnerRevertTests.cs | 80 +++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerRevertTests.cs diff --git a/src/SqlBound.Cli/MigrateCommand.cs b/src/SqlBound.Cli/MigrateCommand.cs index 6f715b2..850fb9a 100644 --- a/src/SqlBound.Cli/MigrateCommand.cs +++ b/src/SqlBound.Cli/MigrateCommand.cs @@ -14,6 +14,7 @@ public static Command Build() => { BuildAdd(), BuildRun(), + BuildRevert(), }; private static Command BuildAdd() @@ -99,6 +100,34 @@ private static Command BuildRun() return runCommand; } + private static Command BuildRevert() + { + var migrationsOption = MigrationsOption(); + var connectionOption = ConnectionOption(); + + var revertCommand = new Command("revert", "Revert the most recently applied migration.") + { + migrationsOption, + connectionOption, + }; + revertCommand.SetAction((parseResult, cancellationToken) => MigrationCli.ExecuteAsync( + parseResult.GetValue(connectionOption), + ResolveDirectory(parseResult.GetValue(migrationsOption)), + async (connection, ledger, migrations) => + { + var reverted = await MigrationRunner + .RevertAsync(connection, ledger, migrations, cancellationToken) + .ConfigureAwait(false); + Console.Out.WriteLine(reverted is null + ? "nothing to revert." + : $"reverted {reverted.Version}_{reverted.Name}."); + return 0; + }, + cancellationToken)); + + return revertCommand; + } + private static Option MigrationsOption() => new("--migrations") { Description = "The migrations directory (default: ./migrations)." }; diff --git a/src/SqlBound.Migrations/MigrationRunner.cs b/src/SqlBound.Migrations/MigrationRunner.cs index 2962c16..57234ea 100644 --- a/src/SqlBound.Migrations/MigrationRunner.cs +++ b/src/SqlBound.Migrations/MigrationRunner.cs @@ -43,6 +43,44 @@ public static async Task> RunAsync( return appliedNow; } + /// Reverts the most recently applied migration, running its down-script. + /// An open connection to the target database. + /// The migration ledger for the target provider. + /// The migrations on disk, as loaded from the directory. + /// A token to cancel the operation. + /// The reverted migration, or when nothing was applied. + /// The migration to revert is missing or irreversible. + /// The down-script failed to execute. + public static async Task RevertAsync( + DbConnection connection, + IMigrationLedger ledger, + IReadOnlyList migrations, + CancellationToken cancellationToken) + { + await ledger.EnsureCreatedAsync(connection, cancellationToken).ConfigureAwait(false); + var applied = await ledger.GetAppliedAsync(connection, cancellationToken).ConfigureAwait(false); + var target = MigrationReverter.Plan(migrations, applied); + if (target is null) + { + return null; + } + + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + try + { + // MigrationReverter.Plan guarantees a reversible target, so DownScript is non-null here. + await ExecuteScriptAsync(connection, transaction, target.DownScript!, cancellationToken).ConfigureAwait(false); + await ledger.RemoveAsync(connection, transaction, target.Version, cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return target; + } + catch (DbException exception) + { + await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); + throw new MigrationExecutionException(target.Version, target.Name, exception); + } + } + private static async Task ApplyAsync( DbConnection connection, IMigrationLedger ledger, diff --git a/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerRevertTests.cs b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerRevertTests.cs new file mode 100644 index 0000000..e94633e --- /dev/null +++ b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerRevertTests.cs @@ -0,0 +1,80 @@ +using Microsoft.Data.SqlClient; +using SqlBound.Migrations; + +namespace SqlBound.SqlServer.IntegrationTests; + +/// +/// Exercises end to end against the shared container. +/// Each test drops the ledger and its tables first so the sequential methods start clean. +/// +public sealed class MigrationRunnerRevertTests(SqlServerFixture fixture) +{ + private readonly SqlServerMigrationLedger _ledger = new(); + private readonly TimeProvider _clock = TimeProvider.System; + + [Fact] + public async Task Should_RunDownScriptAndRemoveLedgerRow_When_Reverting() + { + await using var connection = await ResetAsync("mrr_a", "mrr_b"); + var migrations = new[] + { + Migration(20260712100000, "create_a", "CREATE TABLE dbo.mrr_a (id int);"), + Migration(20260712110000, "create_b", "CREATE TABLE dbo.mrr_b (id int);"), + }; + await MigrationRunner.RunAsync(connection, _ledger, migrations, _clock, Token); + + var reverted = await MigrationRunner.RevertAsync(connection, _ledger, migrations, Token); + + Assert.Equal(20260712110000, reverted!.Version); + Assert.True(await TableExistsAsync(connection, "mrr_a")); + Assert.False(await TableExistsAsync(connection, "mrr_b")); + Assert.Equal([20260712100000], (await _ledger.GetAppliedAsync(connection, Token)).Select(row => row.Version)); + } + + [Fact] + public async Task Should_ReturnNull_When_NothingApplied() + { + await using var connection = await ResetAsync("mrr_a"); + var migrations = new[] { Migration(20260712100000, "create_a", "CREATE TABLE dbo.mrr_a (id int);") }; + await _ledger.EnsureCreatedAsync(connection, Token); + + Assert.Null(await MigrationRunner.RevertAsync(connection, _ledger, migrations, Token)); + } + + [Fact] + public async Task Should_ThrowInconsistency_When_TargetMigrationIsIrreversible() + { + await using var connection = await ResetAsync("mrr_a"); + var irreversible = new Migration( + 20260712100000, "create_a", "CREATE TABLE dbo.mrr_a (id int);", DownScript: null, Checksum("a")); + await MigrationRunner.RunAsync(connection, _ledger, [irreversible], _clock, Token); + + await Assert.ThrowsAsync( + () => MigrationRunner.RevertAsync(connection, _ledger, [irreversible], Token)); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static string Checksum(string seed) => seed.PadRight(64, '0'); + + private static Migration Migration(long version, string name, string upScript) => + new(version, name, upScript, $"DROP TABLE dbo.{name.Replace("create_", "mrr_")};", Checksum(name)); + + private async Task ResetAsync(params string[] tables) + { + var connection = await fixture.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + var drops = string.Join("\n", tables.Select(table => $"DROP TABLE IF EXISTS dbo.{table};")); + command.CommandText = $"DROP TABLE IF EXISTS dbo._sqlbound_migrations;\n{drops}"; + await command.ExecuteNonQueryAsync(Token); + return connection; + } + + private static async Task TableExistsAsync(SqlConnection connection, string table) + { + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT OBJECT_ID(@name, N'U');"; + command.Parameters.AddWithValue("@name", $"dbo.{table}"); + return await command.ExecuteScalarAsync(Token) is not (null or DBNull); + } +} From 2dee2bfe253bd982774441516c50a4fcfff131b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 13:03:37 +0100 Subject: [PATCH 6/7] Add migrate status engine and CLI command MigrationRunner.StatusAsync ensures the ledger, reads the applied rows, and returns MigrationStatusReport.Build's classification. The CLI 'migrate status' subcommand prints each migration's version, name, state (applied/pending/drifted/missing), and applied-on timestamp. Co-Authored-By: Claude Opus 4.8 --- src/SqlBound.Cli/MigrateCommand.cs | 41 +++++++++++++++ src/SqlBound.Migrations/MigrationRunner.cs | 17 +++++++ .../MigrationRunnerStatusTests.cs | 50 +++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerStatusTests.cs diff --git a/src/SqlBound.Cli/MigrateCommand.cs b/src/SqlBound.Cli/MigrateCommand.cs index 850fb9a..c800b2f 100644 --- a/src/SqlBound.Cli/MigrateCommand.cs +++ b/src/SqlBound.Cli/MigrateCommand.cs @@ -15,6 +15,7 @@ public static Command Build() => BuildAdd(), BuildRun(), BuildRevert(), + BuildStatus(), }; private static Command BuildAdd() @@ -128,6 +129,46 @@ private static Command BuildRevert() return revertCommand; } + private static Command BuildStatus() + { + var migrationsOption = MigrationsOption(); + var connectionOption = ConnectionOption(); + + var statusCommand = new Command("status", "Show each migration's state: applied, pending, drifted, or missing.") + { + migrationsOption, + connectionOption, + }; + statusCommand.SetAction((parseResult, cancellationToken) => MigrationCli.ExecuteAsync( + parseResult.GetValue(connectionOption), + ResolveDirectory(parseResult.GetValue(migrationsOption)), + async (connection, ledger, migrations) => + { + var report = await MigrationRunner + .StatusAsync(connection, ledger, migrations, cancellationToken) + .ConfigureAwait(false); + if (report.Count == 0) + { + Console.Out.WriteLine("no migrations."); + return 0; + } + + foreach (var status in report) + { + var appliedOn = status.AppliedOnUtc is { } when + ? $" {when:yyyy-MM-dd HH:mm:ss}Z" + : string.Empty; + Console.Out.WriteLine( + $"{status.Version}_{status.Name} {status.State.ToString().ToLowerInvariant()}{appliedOn}"); + } + + return 0; + }, + cancellationToken)); + + return statusCommand; + } + private static Option MigrationsOption() => new("--migrations") { Description = "The migrations directory (default: ./migrations)." }; diff --git a/src/SqlBound.Migrations/MigrationRunner.cs b/src/SqlBound.Migrations/MigrationRunner.cs index 57234ea..106eed7 100644 --- a/src/SqlBound.Migrations/MigrationRunner.cs +++ b/src/SqlBound.Migrations/MigrationRunner.cs @@ -81,6 +81,23 @@ public static async Task> RunAsync( } } + /// Reports each migration's state relative to the ledger, without changing anything. + /// An open connection to the target database. + /// The migration ledger for the target provider. + /// The migrations on disk, as loaded from the directory. + /// A token to cancel the operation. + /// One status per migration known to disk or the ledger, ascending by version. + public static async Task> StatusAsync( + DbConnection connection, + IMigrationLedger ledger, + IReadOnlyList migrations, + CancellationToken cancellationToken) + { + await ledger.EnsureCreatedAsync(connection, cancellationToken).ConfigureAwait(false); + var applied = await ledger.GetAppliedAsync(connection, cancellationToken).ConfigureAwait(false); + return MigrationStatusReport.Build(migrations, applied); + } + private static async Task ApplyAsync( DbConnection connection, IMigrationLedger ledger, diff --git a/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerStatusTests.cs b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerStatusTests.cs new file mode 100644 index 0000000..0e54475 --- /dev/null +++ b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerStatusTests.cs @@ -0,0 +1,50 @@ +using Microsoft.Data.SqlClient; +using SqlBound.Migrations; + +namespace SqlBound.SqlServer.IntegrationTests; + +/// +/// Exercises end to end against the shared container: +/// the pure classification is unit-tested, so this proves the read-only wiring over the real ledger. +/// +public sealed class MigrationRunnerStatusTests(SqlServerFixture fixture) +{ + private readonly SqlServerMigrationLedger _ledger = new(); + + [Fact] + public async Task Should_ReportAppliedAndPending_When_SomeMigrationsApplied() + { + await using var connection = await ResetAsync("ms_a"); + var first = new Migration(20260712100000, "create_a", "CREATE TABLE dbo.ms_a (id int);", "DROP TABLE dbo.ms_a;", Checksum("a")); + await MigrationRunner.RunAsync(connection, _ledger, [first], TimeProvider.System, Token); + var second = new Migration(20260712110000, "create_b", "SELECT 1;", "SELECT 1;", Checksum("b")); + + var report = await MigrationRunner.StatusAsync(connection, _ledger, [first, second], Token); + + Assert.Equal(MigrationState.Applied, report[0].State); + Assert.NotNull(report[0].AppliedOnUtc); + Assert.Equal(MigrationState.Pending, report[1].State); + } + + [Fact] + public async Task Should_ReportEmpty_When_NoMigrationsOnDiskOrInLedger() + { + await using var connection = await ResetAsync(); + + Assert.Empty(await MigrationRunner.StatusAsync(connection, _ledger, [], Token)); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static string Checksum(string seed) => seed.PadRight(64, '0'); + + private async Task ResetAsync(params string[] tables) + { + var connection = await fixture.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + var drops = string.Join("\n", tables.Select(table => $"DROP TABLE IF EXISTS dbo.{table};")); + command.CommandText = $"DROP TABLE IF EXISTS dbo._sqlbound_migrations;\n{drops}"; + await command.ExecuteNonQueryAsync(Token); + return connection; + } +} From 9fde08cbf55a2325973ee1cb1b9da24dd1f5cba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 13:07:28 +0100 Subject: [PATCH 7/7] Document run/revert/status and serialize ledger tests Update docs/migrations.md and the README for migrate run/revert/status, including the safety rules (checksum drift, out-of-order) and the documented no-GO-splitting limitation. Place the four SQL Server migration integration test classes in one xunit collection so they no longer run in parallel against the shared _sqlbound_migrations table. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 ++- docs/migrations.md | 55 ++++++++++++++++++- .../MigrationRunnerRevertTests.cs | 1 + .../MigrationRunnerStatusTests.cs | 1 + .../MigrationRunnerTests.cs | 1 + .../SqlServerMigrationLedgerTests.cs | 1 + 6 files changed, 61 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fabfa10..a571c8c 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,11 @@ and how SQL Server introspection works today. ## Migrations Schema changes are ordered SQL-file migrations — paired `{version}_{name}.up.sql` / `.down.sql` -files with a timestamp version — tracked in a `_sqlbound_migrations` ledger. `dotnet sqlbound -migrate add` scaffolds a migration and `dotnet sqlbound database create`/`drop` manage the target -database. Applying and reverting migrations land in M14. See [docs/migrations.md](docs/migrations.md). +files with a timestamp version — tracked in a `_sqlbound_migrations` ledger. `migrate add` scaffolds +a migration, `migrate run` applies pending ones (each in its own transaction, refusing on checksum +drift or out-of-order files), `migrate revert` rolls the last one back, and `migrate status` reports +what is applied vs pending. `database create`/`drop` manage the target database. SQL Server today; +other providers in M15. See [docs/migrations.md](docs/migrations.md). ## Packages diff --git a/docs/migrations.md b/docs/migrations.md index 15c04ad..e38d3e7 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -4,9 +4,9 @@ SqlBound applies schema changes as ordered SQL-file migrations, tracked in a dat This document covers the on-disk format, the ledger, and the CLI commands. The format decisions are recorded in [ADR 0006](adr/0006-migration-file-format.md). -> **Status.** M13 ships the migration model, the SQL Server ledger, `migrate add`, and -> `database create`/`database drop`. Applying and reverting migrations (`migrate run`, `migrate -> revert`, `migrate status`) arrives in M14, and the other providers in M15. +> **Status.** The full command set — `migrate add`, `migrate run`, `migrate revert`, `migrate +> status`, and `database create`/`database drop` — works against **SQL Server**. The other providers +> follow in M15. ## File format @@ -68,6 +68,55 @@ dotnet sqlbound migrate add "backfill emails" --irreversible - `--migrations ` sets the directory (default `./migrations`); it is created if missing. - `--irreversible` writes only the up script. +### `migrate run` + +Applies every pending migration, in version order: + +```bash +export SQLBOUND_DATABASE_URL="sqlserver://sa:password@localhost:1433/myapp?TrustServerCertificate=true" +dotnet sqlbound migrate run +# applied 20260712143000_create_items (18 ms) +# applied 1 migration(s). +``` + +- Each migration's up-script and its ledger row commit in **one transaction**. If a script fails, + that migration is rolled back and the run stops; every earlier migration stays applied. +- `run` refuses to proceed on two inconsistencies: an already-applied migration whose up-script has + been **edited** (checksum drift), and a **pending migration ordered before** one already applied + (a late-merged branch). Fix the directory rather than the database. +- `--migrations ` and `--connection` work as elsewhere. + +> **Batch separators.** Each script runs as a single command; SQL Server's `GO` separator is **not** +> supported, so a migration needing multiple batches (e.g. `CREATE PROCEDURE` followed by more SQL) +> must be split into separate migrations. This may be revisited in a later release. + +### `migrate revert` + +Rolls back the most recently applied migration by running its down-script: + +```bash +dotnet sqlbound migrate revert +# reverted 20260712143000_create_items. +``` + +- The down-script and the ledger removal commit in one transaction. +- `revert` refuses if the target migration is **irreversible** (no down script) or its files are + **missing**; it is a no-op ("nothing to revert") when the ledger is empty. +- Reverts one migration per invocation. + +### `migrate status` + +Reports each migration's state without changing anything: + +```bash +dotnet sqlbound migrate status +# 20260712143000_create_items applied 2026-07-12 14:30:07Z +# 20260712150000_backfill_emails pending +``` + +- States: **applied**, **pending**, **drifted** (up-script edited since it was applied), and + **missing** (in the ledger, but the file is gone). + ### `database create` / `database drop` Create or drop the database named by the connection string's `Initial Catalog`, connecting to diff --git a/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerRevertTests.cs b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerRevertTests.cs index e94633e..7aabb33 100644 --- a/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerRevertTests.cs +++ b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerRevertTests.cs @@ -7,6 +7,7 @@ namespace SqlBound.SqlServer.IntegrationTests; /// Exercises end to end against the shared container. /// Each test drops the ledger and its tables first so the sequential methods start clean. /// +[Collection("SqlServerMigrations")] public sealed class MigrationRunnerRevertTests(SqlServerFixture fixture) { private readonly SqlServerMigrationLedger _ledger = new(); diff --git a/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerStatusTests.cs b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerStatusTests.cs index 0e54475..a820663 100644 --- a/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerStatusTests.cs +++ b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerStatusTests.cs @@ -7,6 +7,7 @@ namespace SqlBound.SqlServer.IntegrationTests; /// Exercises end to end against the shared container: /// the pure classification is unit-tested, so this proves the read-only wiring over the real ledger. /// +[Collection("SqlServerMigrations")] public sealed class MigrationRunnerStatusTests(SqlServerFixture fixture) { private readonly SqlServerMigrationLedger _ledger = new(); diff --git a/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerTests.cs b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerTests.cs index 91b1824..994ce6a 100644 --- a/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerTests.cs +++ b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerTests.cs @@ -8,6 +8,7 @@ namespace SqlBound.SqlServer.IntegrationTests; /// SQL Server ledger. Each test drops the ledger and the tables its migrations create, so the /// sequential methods start from a clean slate. /// +[Collection("SqlServerMigrations")] public sealed class MigrationRunnerTests(SqlServerFixture fixture) { private readonly SqlServerMigrationLedger _ledger = new(); diff --git a/test/SqlBound.SqlServer.IntegrationTests/SqlServerMigrationLedgerTests.cs b/test/SqlBound.SqlServer.IntegrationTests/SqlServerMigrationLedgerTests.cs index 8b53d32..20af8f4 100644 --- a/test/SqlBound.SqlServer.IntegrationTests/SqlServerMigrationLedgerTests.cs +++ b/test/SqlBound.SqlServer.IntegrationTests/SqlServerMigrationLedgerTests.cs @@ -8,6 +8,7 @@ namespace SqlBound.SqlServer.IntegrationTests; /// sequentially (one xunit collection) and each drops the ledger table first, so they do not /// observe one another's rows; the describe suites run in parallel but only read other tables. /// +[Collection("SqlServerMigrations")] public sealed class SqlServerMigrationLedgerTests(SqlServerFixture fixture) { private const string SampleChecksum =