From f4a7be49296469a1fd51efa0730020945ff9106a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 20 May 2026 15:25:10 -0400 Subject: [PATCH] test(auditlog): cached call combined telemetry end-to-end (#23 M3) --- .../CachedCallCombinedTelemetryTests.cs | 270 ++++++++++++++++++ .../CombinedTelemetryDispatcher.cs | 125 ++++++++ .../CombinedTelemetryHarness.cs | 175 ++++++++++++ .../ScadaLink.AuditLog.Tests.csproj | 7 + 4 files changed, 577 insertions(+) create mode 100644 tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs create mode 100644 tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryDispatcher.cs create mode 100644 tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs b/tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs new file mode 100644 index 0000000..342b028 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Integration/CachedCallCombinedTelemetryTests.cs @@ -0,0 +1,270 @@ +using Akka.TestKit.Xunit2; +using Microsoft.EntityFrameworkCore; +using ScadaLink.AuditLog.Tests.Integration.Infrastructure; +using ScadaLink.Commons.Entities.Audit; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Messages.Integration; +using ScadaLink.Commons.Types; +using ScadaLink.Commons.Types.Enums; +using ScadaLink.ConfigurationDatabase.Tests.Migrations; + +namespace ScadaLink.AuditLog.Tests.Integration; + +/// +/// Bundle G G2 end-to-end suite for cached ExternalSystem.CachedCall +/// lifecycle telemetry (Audit Log #23 / M3). Wires the full M3 pipeline: +/// site-local SQLite audit writer + operation tracking store + the production +/// + the test-side +/// that ALSO pushes each combined +/// packet through the stub gRPC client into the central +/// AuditLogIngestActor's dual-write transaction against a per-test +/// MSSQL database. Asserts the audit rows + the SiteCalls row + the +/// site-local tracking row converge to the expected shape for each lifecycle. +/// +/// +/// +/// The bridge is driven directly via +/// — these tests do NOT spin up the actual S&F retry loop; that would +/// require a full SiteRuntime host and is out of scope for M3 (the S&F +/// observer hooks are exercised in ScadaLink.StoreAndForward.Tests at +/// unit level). The submit row is emitted via +/// because the +/// production submit emission happens at the script-call site, not inside the +/// S&F loop. +/// +/// +/// Each test uses a unique SourceSite id (Guid suffix) so concurrent +/// tests sharing the per-fixture MSSQL database don't interfere with each +/// other. +/// +/// +public class CachedCallCombinedTelemetryTests : TestKit, IClassFixture +{ + private readonly MsSqlMigrationFixture _fixture; + + public CachedCallCombinedTelemetryTests(MsSqlMigrationFixture fixture) + { + _fixture = fixture; + } + + private static string NewSiteId() => + "test-g2-cached-" + Guid.NewGuid().ToString("N").Substring(0, 8); + + private static CachedCallTelemetry SubmitPacket( + TrackedOperationId id, string siteId, DateTime nowUtc, string target = "ERP.GetOrder") => + new( + Audit: new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = nowUtc, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.CachedSubmit, + CorrelationId = id.Value, + SourceSiteId = siteId, + SourceInstanceId = "Plant.Pump42", + SourceScript = "ScriptActor:doStuff", + Target = target, + Status = AuditStatus.Submitted, + ForwardState = AuditForwardState.Pending, + }, + Operational: new SiteCallOperational( + TrackedOperationId: id, + Channel: "ApiOutbound", + Target: target, + SourceSite: siteId, + Status: "Submitted", + RetryCount: 0, + LastError: null, + HttpStatus: null, + CreatedAtUtc: nowUtc, + UpdatedAtUtc: nowUtc, + TerminalAtUtc: null)); + + private static CachedCallAttemptContext AttemptContext( + TrackedOperationId id, + string siteId, + CachedCallAttemptOutcome outcome, + int retryCount, + string? lastError, + int? httpStatus, + DateTime createdUtc, + DateTime occurredUtc, + string target = "ERP.GetOrder", + string channel = "ApiOutbound") => + new( + TrackedOperationId: id, + Channel: channel, + Target: target, + SourceSite: siteId, + Outcome: outcome, + RetryCount: retryCount, + LastError: lastError, + HttpStatus: httpStatus, + CreatedAtUtc: createdUtc, + OccurredAtUtc: occurredUtc, + DurationMs: 42, + SourceInstanceId: "Plant.Pump42"); + + [SkippableFact] + public async Task CachedCall_FailFailSuccess_Emits_5_AuditRows_AND_1_SiteCall_Delivered() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + var trackedId = TrackedOperationId.New(); + var t0 = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc); + + await using var harness = new CombinedTelemetryHarness(_fixture, this); + + // Submit + await harness.EmitSubmitAsync(SubmitPacket(trackedId, siteId, t0)); + + // Attempt 1: transient HTTP 500 + await harness.EmitAttemptAsync(AttemptContext( + trackedId, siteId, + CachedCallAttemptOutcome.TransientFailure, + retryCount: 1, lastError: "HTTP 500", httpStatus: 500, + createdUtc: t0, occurredUtc: t0.AddSeconds(5))); + + // Attempt 2: transient HTTP 500 + await harness.EmitAttemptAsync(AttemptContext( + trackedId, siteId, + CachedCallAttemptOutcome.TransientFailure, + retryCount: 2, lastError: "HTTP 500", httpStatus: 500, + createdUtc: t0, occurredUtc: t0.AddSeconds(15))); + + // Attempt 3: delivered (terminal — emits Attempted + CachedResolve) + await harness.EmitAttemptAsync(AttemptContext( + trackedId, siteId, + CachedCallAttemptOutcome.Delivered, + retryCount: 3, lastError: null, httpStatus: 200, + createdUtc: t0, occurredUtc: t0.AddSeconds(25))); + + // Central side: each forward through the dispatcher round-trips + // through the stub client + ingest actor, so by the time the awaits + // complete the rows are visible in MSSQL. + await using var read = harness.CreateReadContext(); + + // 1 Submit + 2 transient Attempted + 1 terminal Attempted + 1 + // CachedResolve = 5 audit rows. The plan allows 4-5; this is the + // happy path emitting exactly 5. + var auditRows = await read.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + Assert.InRange(auditRows.Count, 4, 5); + + // All audit rows must share the same CorrelationId (= TrackedOperationId). + Assert.All(auditRows, r => Assert.Equal(trackedId.Value, r.CorrelationId)); + + // Exactly one CachedSubmit row. + Assert.Single(auditRows, r => r.Kind == AuditKind.CachedSubmit); + // Exactly one terminal CachedResolve row, status Delivered. + var resolve = Assert.Single(auditRows, r => r.Kind == AuditKind.CachedResolve); + Assert.Equal(AuditStatus.Delivered, resolve.Status); + + // SiteCalls row: Delivered, RetryCount=3, TerminalAtUtc set. + var siteCall = await read.Set() + .SingleAsync(s => s.TrackedOperationId == trackedId); + Assert.Equal("Delivered", siteCall.Status); + Assert.Equal(3, siteCall.RetryCount); + Assert.NotNull(siteCall.TerminalAtUtc); + + // Site-local Tracking.Status mirrors the same outcome. + var snapshot = await harness.TrackingStore.GetStatusAsync(trackedId); + Assert.NotNull(snapshot); + Assert.Equal("Delivered", snapshot!.Status); + Assert.NotNull(snapshot.TerminalAtUtc); + } + + [SkippableFact] + public async Task CachedCall_AllAttemptsFailedAndParked_Emits_Terminal_Parked() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + var trackedId = TrackedOperationId.New(); + var t0 = new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc); + + await using var harness = new CombinedTelemetryHarness(_fixture, this); + + await harness.EmitSubmitAsync(SubmitPacket(trackedId, siteId, t0)); + + // Three transient failures... + for (int i = 1; i <= 3; i++) + { + await harness.EmitAttemptAsync(AttemptContext( + trackedId, siteId, + CachedCallAttemptOutcome.TransientFailure, + retryCount: i, lastError: "HTTP 500", httpStatus: 500, + createdUtc: t0, occurredUtc: t0.AddSeconds(i * 5))); + } + + // ...then S&F gives up — ParkedMaxRetries. + await harness.EmitAttemptAsync(AttemptContext( + trackedId, siteId, + CachedCallAttemptOutcome.ParkedMaxRetries, + retryCount: 4, lastError: "HTTP 500", httpStatus: 500, + createdUtc: t0, occurredUtc: t0.AddSeconds(30))); + + await using var read = harness.CreateReadContext(); + + var siteCall = await read.Set() + .SingleAsync(s => s.TrackedOperationId == trackedId); + Assert.Equal("Parked", siteCall.Status); + Assert.NotNull(siteCall.TerminalAtUtc); + + // Terminal audit row should also be Parked. + var resolve = await read.Set() + .Where(e => e.SourceSiteId == siteId && e.Kind == AuditKind.CachedResolve) + .SingleAsync(); + Assert.Equal(AuditStatus.Parked, resolve.Status); + + // Site-local tracking matches. + var snapshot = await harness.TrackingStore.GetStatusAsync(trackedId); + Assert.NotNull(snapshot); + Assert.Equal("Parked", snapshot!.Status); + Assert.NotNull(snapshot.TerminalAtUtc); + } + + [SkippableFact] + public async Task CachedCall_ImmediateSuccess_NoSF_Emits_Attempted_And_Resolve_Delivered() + { + Skip.IfNot(_fixture.Available, _fixture.SkipReason); + + var siteId = NewSiteId(); + var trackedId = TrackedOperationId.New(); + var t0 = new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc); + + await using var harness = new CombinedTelemetryHarness(_fixture, this); + + // Submit + immediate delivered attempt (RetryCount = 0). + await harness.EmitSubmitAsync(SubmitPacket(trackedId, siteId, t0)); + await harness.EmitAttemptAsync(AttemptContext( + trackedId, siteId, + CachedCallAttemptOutcome.Delivered, + retryCount: 0, lastError: null, httpStatus: 200, + createdUtc: t0, occurredUtc: t0.AddMilliseconds(50))); + + await using var read = harness.CreateReadContext(); + + var siteCall = await read.Set() + .SingleAsync(s => s.TrackedOperationId == trackedId); + Assert.Equal("Delivered", siteCall.Status); + Assert.Equal(0, siteCall.RetryCount); + Assert.NotNull(siteCall.TerminalAtUtc); + + // 1 Submit + 1 Attempted + 1 CachedResolve = 3 audit rows. + var auditRows = await read.Set() + .Where(e => e.SourceSiteId == siteId) + .ToListAsync(); + Assert.Equal(3, auditRows.Count); + Assert.Single(auditRows, r => r.Kind == AuditKind.CachedSubmit); + Assert.Single(auditRows, r => r.Kind == AuditKind.ApiCallCached); + var resolve = Assert.Single(auditRows, r => r.Kind == AuditKind.CachedResolve); + Assert.Equal(AuditStatus.Delivered, resolve.Status); + + var snapshot = await harness.TrackingStore.GetStatusAsync(trackedId); + Assert.NotNull(snapshot); + Assert.Equal("Delivered", snapshot!.Status); + } +} diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryDispatcher.cs b/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryDispatcher.cs new file mode 100644 index 0000000..83c8303 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryDispatcher.cs @@ -0,0 +1,125 @@ +using ScadaLink.AuditLog.Site.Telemetry; +using ScadaLink.AuditLog.Telemetry; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Messages.Integration; +using ScadaLink.Commons.Types; +using ScadaLink.Communication.Grpc; +using Google.Protobuf.WellKnownTypes; +using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp; + +namespace ScadaLink.AuditLog.Tests.Integration.Infrastructure; + +/// +/// Test-side combined-telemetry dispatcher: wraps a production +/// so the local audit + tracking +/// stores still get written, then projects the same packet onto the wire as a +/// and pushes it through the supplied +/// . The bridge can be composed into the +/// existing chain as the +/// implementation so a single +/// observer callback fans out to both halves. +/// +/// +/// +/// Production wiring keeps the wire push deferred to M6 — the site SQLite hot +/// path is the source of truth and a future M6 drain will push the rows +/// through the gRPC client. For end-to-end testing today we need a way to +/// exercise the central dual-write transaction immediately, so this +/// dispatcher synthesises the wire packet inline and round-trips it through +/// the stub client. The shape mirrors what the M6 drain will eventually emit. +/// +/// +/// Best-effort: both the inner forwarder call and the wire push are +/// wrapped in independent try/catch blocks. A thrown wire client doesn't +/// abort the local writes (the audit row is already in SQLite); a thrown +/// local forwarder doesn't abort the wire push (central still gets the +/// dual-write attempt). +/// +/// +public sealed class CombinedTelemetryDispatcher : ICachedCallTelemetryForwarder +{ + private readonly ICachedCallTelemetryForwarder _inner; + private readonly ISiteStreamAuditClient _wireClient; + + public CombinedTelemetryDispatcher( + ICachedCallTelemetryForwarder inner, + ISiteStreamAuditClient wireClient) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _wireClient = wireClient ?? throw new ArgumentNullException(nameof(wireClient)); + } + + /// + public async Task ForwardAsync(CachedCallTelemetry telemetry, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(telemetry); + + // Inner forwarder writes the audit row to SQLite + updates the + // tracking store. Best-effort — exceptions are already swallowed + // inside the production forwarder, but wrap defensively here too in + // case a test substitutes a stricter inner. + try + { + await _inner.ForwardAsync(telemetry, ct).ConfigureAwait(false); + } + catch + { + // Swallow — alog.md §7 best-effort contract. + } + + // Project the same packet onto the wire and push it through the stub + // client. This is the bit a future M6 drain will subsume — until + // then the test wraps the two halves into one observer-driven step. + try + { + var batch = new CachedTelemetryBatch(); + batch.Packets.Add(BuildPacket(telemetry)); + await _wireClient.IngestCachedTelemetryAsync(batch, ct).ConfigureAwait(false); + } + catch + { + // Swallow — the audit row is still in SQLite for a future drain; + // the central row will materialise the next time the wire path + // is exercised (or via the M6 reconciliation pull). + } + } + + private static CachedTelemetryPacket BuildPacket(CachedCallTelemetry telemetry) + { + return new CachedTelemetryPacket + { + AuditEvent = AuditEventMapper.ToDto(telemetry.Audit), + Operational = ToOperationalDto(telemetry.Operational), + }; + } + + private static SiteCallOperationalDto ToOperationalDto(SiteCallOperational op) + { + var dto = new SiteCallOperationalDto + { + TrackedOperationId = op.TrackedOperationId.Value.ToString("D"), + Channel = op.Channel, + Target = op.Target, + SourceSite = op.SourceSite, + Status = op.Status, + RetryCount = op.RetryCount, + LastError = op.LastError ?? string.Empty, + CreatedAtUtc = Timestamp.FromDateTime(EnsureUtc(op.CreatedAtUtc)), + UpdatedAtUtc = Timestamp.FromDateTime(EnsureUtc(op.UpdatedAtUtc)), + }; + if (op.HttpStatus.HasValue) + { + dto.HttpStatus = op.HttpStatus.Value; + } + if (op.TerminalAtUtc.HasValue) + { + dto.TerminalAtUtc = Timestamp.FromDateTime(EnsureUtc(op.TerminalAtUtc.Value)); + } + return dto; + } + + private static DateTime EnsureUtc(DateTime value) => + value.Kind == DateTimeKind.Utc + ? value + : DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc); +} diff --git a/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs b/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs new file mode 100644 index 0000000..538b269 --- /dev/null +++ b/tests/ScadaLink.AuditLog.Tests/Integration/Infrastructure/CombinedTelemetryHarness.cs @@ -0,0 +1,175 @@ +using Akka.Actor; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ScadaLink.AuditLog.Central; +using ScadaLink.AuditLog.Site; +using ScadaLink.AuditLog.Site.Telemetry; +using ScadaLink.Commons.Interfaces; +using ScadaLink.Commons.Interfaces.Repositories; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Messages.Integration; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.ConfigurationDatabase.Repositories; +using ScadaLink.ConfigurationDatabase.Tests.Migrations; +using ScadaLink.SiteRuntime.Tracking; + +namespace ScadaLink.AuditLog.Tests.Integration.Infrastructure; + +/// +/// Shared end-to-end harness for the M3 cached-call combined telemetry tests +/// (G2/G3/G4). Composes the full pipeline: +/// +/// Site-local SQLite (in-memory) + +/// + . +/// Site-local SQLite (in-memory). +/// Production wrapped by a +/// test-side that also pushes each +/// packet through the stub gRPC client. +/// wired to the +/// dispatcher so a single observer call fans out audit + tracking + wire. +/// connected +/// to an backed by the real +/// + +/// against the per-test database. +/// +/// +/// +/// +/// Disposal cleans up the in-memory SQLite stores. The Akka actor system is +/// owned by the calling ; the harness +/// only owns the ingest actor IActorRef and the underlying repositories' +/// DbContext lifecycle. +/// +/// +public sealed class CombinedTelemetryHarness : IAsyncDisposable +{ + public SqliteAuditWriter SqliteWriter { get; } + public RingBufferFallback Ring { get; } + public FallbackAuditWriter FallbackWriter { get; } + public OperationTrackingStore TrackingStore { get; } + public CachedCallTelemetryForwarder InnerForwarder { get; } + public CombinedTelemetryDispatcher Dispatcher { get; } + public CachedCallLifecycleBridge Bridge { get; } + public DirectActorSiteStreamAuditClient StubClient { get; } + public IActorRef IngestActor { get; } + public IServiceProvider ServiceProvider { get; } + + private readonly MsSqlMigrationFixture _fixture; + private bool _disposed; + + public CombinedTelemetryHarness( + MsSqlMigrationFixture fixture, + Akka.TestKit.Xunit2.TestKit testKit, + Func? siteCallRepoOverride = null) + { + _fixture = fixture ?? throw new ArgumentNullException(nameof(fixture)); + ArgumentNullException.ThrowIfNull(testKit); + + // Site SQLite — unique in-memory database per harness so tests don't share + // an audit queue. Mode=Memory + Cache=Shared keeps the file alive for the + // lifetime of the writer connection. + SqliteWriter = new SqliteAuditWriter( + Options.Create(new SqliteAuditWriterOptions + { + DatabasePath = "ignored", + BatchSize = 64, + ChannelCapacity = 1024, + }), + NullLogger.Instance, + connectionStringOverride: + $"Data Source=file:cachedcall-g-{Guid.NewGuid():N}?mode=memory&cache=shared"); + + Ring = new RingBufferFallback(); + FallbackWriter = new FallbackAuditWriter( + SqliteWriter, Ring, new NoOpAuditWriteFailureCounter(), + NullLogger.Instance); + + TrackingStore = new OperationTrackingStore( + Options.Create(new OperationTrackingOptions + { + // Same shared-in-memory pattern as the audit writer. + ConnectionString = + $"Data Source=file:tracking-g-{Guid.NewGuid():N}?mode=memory&cache=shared", + }), + NullLogger.Instance); + + // Central wiring: real repositories backed by the MSSQL fixture's DB. + ServiceProvider = BuildCentralServiceProvider(siteCallRepoOverride); + IngestActor = testKit.Sys.ActorOf(Props.Create(() => new AuditLogIngestActor( + ServiceProvider, + NullLogger.Instance))); + + StubClient = new DirectActorSiteStreamAuditClient(IngestActor); + + // Production forwarder writes the local stores; the dispatcher wraps + // it to ALSO push the same packet to central via the stub client. + InnerForwarder = new CachedCallTelemetryForwarder( + FallbackWriter, TrackingStore, NullLogger.Instance); + Dispatcher = new CombinedTelemetryDispatcher(InnerForwarder, StubClient); + + Bridge = new CachedCallLifecycleBridge(Dispatcher, NullLogger.Instance); + } + + /// + /// Convenience: emit the initial submit packet directly through the + /// dispatcher (the bridge's hooks fire only for S&F retry-loop + /// attempts; submit-row emission happens at the script call site). + /// + public Task EmitSubmitAsync(CachedCallTelemetry submit, CancellationToken ct = default) => + Dispatcher.ForwardAsync(submit, ct); + + /// + /// Convenience: route a per-attempt or terminal outcome through the bridge. + /// + public Task EmitAttemptAsync(CachedCallAttemptContext context, CancellationToken ct = default) => + Bridge.OnAttemptCompletedAsync(context, ct); + + public ScadaLinkDbContext CreateReadContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer(_fixture.ConnectionString) + .Options; + return new ScadaLinkDbContext(options); + } + + private IServiceProvider BuildCentralServiceProvider( + Func? siteCallRepoOverride) + { + var services = new ServiceCollection(); + services.AddDbContext(opts => + opts.UseSqlServer(_fixture.ConnectionString) + .ConfigureWarnings(w => w.Ignore( + Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))); + services.AddScoped(sp => + new AuditLogRepository(sp.GetRequiredService())); + if (siteCallRepoOverride is null) + { + services.AddScoped(sp => + new SiteCallAuditRepository(sp.GetRequiredService())); + } + else + { + services.AddScoped(sp => + siteCallRepoOverride(sp.GetRequiredService())); + } + return services.BuildServiceProvider(); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + await SqliteWriter.DisposeAsync().ConfigureAwait(false); + await TrackingStore.DisposeAsync().ConfigureAwait(false); + if (ServiceProvider is IAsyncDisposable asyncSp) + { + await asyncSp.DisposeAsync().ConfigureAwait(false); + } + else if (ServiceProvider is IDisposable sp) + { + sp.Dispose(); + } + } +} diff --git a/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj b/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj index 625f9c6..bd99f49 100644 --- a/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj +++ b/tests/ScadaLink.AuditLog.Tests/ScadaLink.AuditLog.Tests.csproj @@ -48,6 +48,13 @@ the fixture + EF migrations come along without duplicating them. --> + +