Appearance
Database Migration Manager
Overview
The Database Migration Manager is an admin-only feature that gives platform operators visibility into and control over EF Core migration state across all tenant databases. Each tenant in the platform has an isolated SQL Server database (database-per-tenant). Over time, as the ERP schema evolves, tenant databases drift — some may be fully up to date, others may be several migrations behind.
This feature exposes three core operations:
- Status — See which migrations are applied vs. pending for a given tenant database.
- Preview — Before touching a database, inspect the exact SQL that will run, a structured schema diff (tables added/dropped, columns added/dropped/altered, indexes), and a live sample of rows in affected tables.
- Apply / Rollback — Execute a migration forward or reverse it to a prior state, with an audit trail.
Bulk operations allow applying the latest migration to any number of tenant databases simultaneously (up to 5 in parallel).
Architecture Layers
┌────────────────────────────────────────────────────────────────────────┐
│ Blazor WASM (Admin Client) │
│ │
│ DatabaseManagerPage SubdomainMigrationsPage │
│ (list all subdomains) (per-subdomain timeline + preview) │
│ │ │ │
│ BulkMigrationDialog MigrationPreviewDialog │
│ ApplyConfirmDialog │
│ │ │ │
│ DatabaseManagerClientService (IDatabaseManagerClientService) │
│ HTTP via IAdminService → AdminPortal API │
└────────────────────────────────────┬───────────────────────────────────┘
│ HTTP (internal)
┌────────────────────────────────────▼───────────────────────────────────┐
│ BsuinessOwners.AdminPortal (Admin API) │
│ │
│ DatabaseMigrationController │
│ GET /DatabaseMigration/GetStatus/{subdomain} │
│ POST /DatabaseMigration/GetPreview/{subdomain} │
│ POST /DatabaseMigration/Apply/{subdomain} │
│ POST /DatabaseMigration/ApplyBulk │
└────────────────────────────────────┬───────────────────────────────────┘
│ DI
┌────────────────────────────────────▼───────────────────────────────────┐
│ BusinessOwners.Application — DatabaseMigrationService │
│ │
│ • Resolves subdomain → encrypted connection string (SubdomainService) │
│ • Delegates to IAppsPortalService (HTTP client to AppsPortal API) │
│ • Records audit via IDataTransferAuditStore (Apply only) │
│ • Runs bulk jobs with Parallel.ForEachAsync (max 5 concurrent) │
└────────────────────────────────────┬───────────────────────────────────┘
│ HTTP (internal, RestAPiType.Internal)
┌────────────────────────────────────▼───────────────────────────────────┐
│ AppsPortal.Apis — MigrationController │
│ │
│ [AllowAnonymous] (internal traffic only — no auth token required) │
│ GET /Migration/GetStatus?connectionString=... │
│ POST /Migration/GetPreview │
│ POST /Migration/Apply │
└────────────────────────────────────┬───────────────────────────────────┘
│ DI
┌────────────────────────────────────▼───────────────────────────────────┐
│ AppsPortal.Infrastructure — MigrationService │
│ │
│ • Decrypts connection string (IEncryptionService) │
│ • Creates a scoped IMicrotecAppsDbContext pointing at tenant DB │
│ • Uses EF Core IMigrator and IMigrationsAssembly │
│ • Dapper for live row sampling (AffectedTableSamples) │
└─────────────────────────────────────────────────────────────────────────┘
│ SQL
┌───────────▼──────────┐
│ Tenant SQL Database │
└──────────────────────┘Key design decision: The BusinessOwners layer never touches tenant databases directly. It only knows subdomain names and retrieves the encrypted connection string from its own admin database. All actual migration work is delegated to AppsPortal, which owns the IMicrotecAppsDbContext and the EF Core migration assembly.
Layer 1 — AppsPortal (Tenant Database Layer)
Interface
csharp
// Src/AppsPortal/Accounting/AppsPortal.Application/Core/Tenant/IDbMigrationService.cs
public interface IDbMigrationService
{
Task<MigrationStatusDto> GetStatusAsync(string connectionString, CancellationToken ct = default);
Task<MigrationPreviewDto> GetPreviewAsync(string connectionString, string targetMigration, CancellationToken ct = default);
Task<ApplyMigrationResultDto> ApplyAsync(string connectionString, string targetMigration, bool dryRun, CancellationToken ct = default);
}The interface takes raw connection strings, not subdomain names. The caller (BusinessOwners layer) is responsible for resolving the subdomain to a connection string before calling these methods.
Controller
csharp
// Src/AppsPortal/Accounting/AppsPortal.Apis/Controllers/MigrationController.cs
[AllowAnonymous]
[ApiController]
[Route("[controller]")]
public class MigrationController(IDbMigrationService migrationService) : BaseController
{
[HttpGet(nameof(GetStatus))]
public async Task<ActionResult<MigrationStatusDto>> GetStatus(
[FromQuery] string connectionString, CancellationToken ct)
[HttpPost(nameof(GetPreview))]
public async Task<ActionResult<MigrationPreviewDto>> GetPreview(
[FromBody] GetMigrationPreviewRequest request, CancellationToken ct)
[HttpPost(nameof(Apply))]
public async Task<ActionResult<ApplyMigrationResultDto>> Apply(
[FromBody] ApplyMigrationRequest request, CancellationToken ct)
}[AllowAnonymous] is used because this controller is only reachable via internal service-to-service calls (RestAPiType.Internal), not through the public gateway. The connection string passed in is still encrypted — MigrationService decrypts it internally.
GetStatus uses [FromQuery] rather than [FromBody] because it is a GET and the connection string is passed as a URL-encoded query parameter.
MigrationService Implementation
The concrete implementation lives in AppsPortal.Infrastructure and is registered as IScopedService.
Tenant DB Context Bootstrapping
Each operation creates a new DI scope and retrieves a fresh IMicrotecAppsDbContext, then overrides its connection string to point at the target tenant database:
csharp
using var scope = serviceProvider.CreateScope();
var appsContext = scope.ServiceProvider.GetRequiredService<IMicrotecAppsDbContext>();
appsContext.HasConnectionString = true;
appsContext.Context.Database.SetConnectionString(decrypted);HasConnectionString = true is a flag that tells the context factory not to use the default (ambient tenant) connection and instead accept the one being set explicitly. This allows the service to operate against any tenant database without changing the ambient tenant context.
GetStatusAsync
csharp
var allMigrations = appsContext.Context.Database.GetMigrations().ToList();
var appliedMigrations = (await appsContext.Context.Database.GetAppliedMigrationsAsync(ct)).ToHashSet();
string? currentMigration = appliedMigrations.Count > 0
? allMigrations.LastOrDefault(m => appliedMigrations.Contains(m))
: null;GetMigrations()reads the migration list from the compiled assembly — these are all migrations that could be applied.GetAppliedMigrationsAsync()queries the__EFMigrationsHistorytable in the tenant database — the subset actually applied.- "Current" is the last migration in assembly order that is also present in the applied set. This correctly handles gaps (e.g., if migrations were applied out of order or partially rolled back).
Each migration is projected into a MigrationInfoDto, and the friendly name is extracted by stripping the timestamp prefix:
csharp
private static string ExtractFriendlyName(string migrationId)
{
// "20250114093021_CreateFundTransfer" -> "CreateFundTransfer"
var underscoreIdx = migrationId.IndexOf('_');
return underscoreIdx >= 0 ? migrationId[(underscoreIdx + 1)..] : migrationId;
}GetPreviewAsync — How Preview Works
The preview is the most complex operation. It produces four pieces of data:
1. Direction detection (forward vs rollback)
csharp
private static bool DetermineIsRollback(List<string> allMigrations, string? currentMigration, string targetMigration)
{
if (currentMigration == null) return false;
int currentIdx = allMigrations.IndexOf(currentMigration);
int targetIdx = allMigrations.IndexOf(targetMigration);
return targetIdx < currentIdx;
}The migration list from EF Core is ordered chronologically by ID (the yyyyMMddHHmmss timestamp prefix). If the target's index is lower than the current's index, it is a rollback.
2. SQL Script generation
csharp
var migrator = appsContext.Context.GetService<IMigrator>();
// For forward: from=current→to=target; for rollback: from=target→to=current
string scriptFrom = isRollback ? targetMigration : (currentMigration ?? Migration.InitialDatabase);
string scriptTo = isRollback ? (currentMigration ?? Migration.InitialDatabase) : targetMigration;
string sqlScript = migrator.GenerateScript(scriptFrom, scriptTo);
// Reverse direction = the rollback (undo) script
string rollbackSqlScript = migrator.GenerateScript(scriptTo, scriptFrom);IMigrator.GenerateScript(from, to) asks EF Core to generate the T-SQL that would transition the schema from state from to state to. For a forward migration, from=current/to=target. For a rollback, from=target/to=current. The rollback script is always generated as the inverse of the forward script (swap from/to), so the UI always shows both: "what will happen" and "how to undo it."
3. Schema change extraction
csharp
var migrationsAssembly = context.GetService<IMigrationsAssembly>();
var migrationTypes = migrationsAssembly.Migrations; // Dictionary<string, Type>EF Core's IMigrationsAssembly.Migrations provides a dictionary from migration ID to the concrete migration class type. For each migration being traversed (the range between current and target), the service instantiates the class via reflection:
csharp
var migration = (Migration)Activator.CreateInstance(migType)!;
migration.ActiveProvider = context.Database.ProviderName!;
var operations = isRollback ? migration.DownOperations : migration.UpOperations;
changes.AddRange(ExtractSchemaChanges(operations));UpOperations and DownOperations are lists of MigrationOperation objects. The service pattern-matches on each operation type:
csharp
switch (op)
{
case CreateTableOperation cto: // → ChangeType = "TableAdded"
case DropTableOperation dto: // → ChangeType = "TableDropped"
case AddColumnOperation aco: // → ChangeType = "ColumnAdded"
case DropColumnOperation dco: // → ChangeType = "ColumnDropped"
case AlterColumnOperation alco: // → ChangeType = "ColumnAltered"
case CreateIndexOperation cio: // → ChangeType = "IndexAdded"
case DropIndexOperation dio: // → ChangeType = "IndexDropped"
case AddPrimaryKeyOperation apko:// → ChangeType = "IndexAdded"
}This runs entirely in-memory against the compiled migration C# classes — no database query needed at this stage.
4. Affected table data sampling
Once schema changes are extracted, the service identifies which table names appear in those changes, then opens a direct Dapper connection to the tenant database:
csharp
using var connection = new Microsoft.Data.SqlClient.SqlConnection(connectionString);
await connection.OpenAsync(ct);
foreach (var table in tables)
{
// Sanitize: only allow alphanumeric, underscore, dot to prevent SQL injection
if (!Regex.IsMatch(table, @"^[\w.]+$")) continue;
var count = await connection.QuerySingleAsync<long>($"SELECT COUNT(*) FROM [{table}]");
var rows = (await connection.QueryAsync<dynamic>($"SELECT TOP 5 * FROM [{table}]")).ToList();
}Table name input is sanitized by regex before interpolation into SQL. The table names come from EF Core's internal migration metadata — not from user input — but the check is present as a defense-in-depth measure. New tables that do not yet exist in the tenant DB will throw an exception on SELECT COUNT(*), which is silently swallowed (the table simply doesn't appear in the samples).
ApplyAsync
csharp
public async Task<ApplyMigrationResultDto> ApplyAsync(string connectionString, string targetMigration, bool dryRun, CancellationToken ct = default)
{
var decrypted = DecryptConnectionString(connectionString);
if (dryRun)
{
// Validate that the script can be generated, but do not execute
migrator.GenerateScript(Migration.InitialDatabase, targetMigration);
return new ApplyMigrationResultDto { Success = true, AppliedMigration = targetMigration };
}
// Actual apply
await migrator.MigrateAsync(targetMigration, ct);
return new ApplyMigrationResultDto { Success = true, AppliedMigration = targetMigration };
}IMigrator.MigrateAsync(targetMigration) migrates the database to exactly the named migration — forward if it is ahead of the current state, backward if it is behind. EF Core handles the direction automatically by inspecting __EFMigrationsHistory. Errors are caught and surfaced in ApplyMigrationResultDto.ErrorMessage without re-throwing, so the HTTP response is always 200 OK with Success = false on failure.
Layer 2 — BusinessOwners.Application (Orchestration Layer)
Interface
csharp
// Src/BusinessOwners/BusinessOwners.Application/AdminPortal/DatabaseManager/IDatabaseMigrationService.cs
public interface IDatabaseMigrationService
{
Task<MigrationStatusDto> GetStatusAsync(string subdomainName, CancellationToken ct = default);
Task<MigrationPreviewDto> GetPreviewAsync(string subdomainName, string targetMigration, CancellationToken ct = default);
Task<ApplyMigrationResultDto> ApplyAsync(string subdomainName, string targetMigration, bool dryRun,
string? ipAddress = null, string? userAgent = null, CancellationToken ct = default);
Task<BulkMigrationResultDto> ApplyBulkAsync(BulkMigrationRequestDto request,
string? ipAddress = null, string? userAgent = null, CancellationToken ct = default);
}This layer operates on subdomain names, not connection strings. It is responsible for translating subdomain → connection string, then proxying to AppsPortal.
Connection String Resolution
csharp
private async Task<string> GetEncryptedConnectionStringAsync(string subdomainName, CancellationToken ct)
{
var subdomain = await subdomainService.GetSubdomainConnectionString(subdomainName, ct);
return subdomain.ConnectionString; // Return encrypted — AppsPortal decrypts it
}ISubdomainService.GetSubdomainConnectionString queries the BusinessOwners admin database (which stores tenant metadata including their encrypted connection strings). The encrypted value is passed through — decryption happens in MigrationService on the AppsPortal side using IEncryptionService.
AppsPortal HTTP Client (IAppsPortalService)
The BusinessOwners application layer communicates with AppsPortal via IAppsPortalService, which is an HTTP client wrapper:
csharp
// Src/BusinessOwners/BusinessOwners.Application/AppsPortal/AppsPortalService.cs
public async Task<MigrationStatusDto> GetMigrationStatusAsync(string connectionString, CancellationToken cancellationToken = default)
{
var result = await client.GetAsync<MigrationStatusDto>(
$"{_apiUrlOptions.AppsPortalURL}Migration/GetStatus?connectionString={Uri.EscapeDataString(connectionString)}",
RestAPiType.Internal);
return result.Response!;
}
public async Task<MigrationPreviewDto> GetMigrationPreviewAsync(string connectionString, string targetMigration, CancellationToken cancellationToken = default)
{
var result = await client.PostAsync<MigrationPreviewDto, object>(
$"{_apiUrlOptions.AppsPortalURL}Migration/GetPreview",
new { connectionString, targetMigration },
RestAPiType.Internal, true);
return result.Response!;
}
public async Task<ApplyMigrationResultDto> ApplyMigrationAsync(string connectionString, string targetMigration, bool dryRun, CancellationToken cancellationToken = default)
{
var result = await client.PostAsync<ApplyMigrationResultDto, object>(
$"{_apiUrlOptions.AppsPortalURL}Migration/Apply",
new { connectionString, targetMigration, dryRun },
RestAPiType.Internal, true);
return result.Response!;
}RestAPiType.Internal bypasses user authentication — the call uses a service-level credential or is restricted to the internal network. The connection string is URL-encoded (for GET) or included in the JSON body (for POST).
Audit Recording (Apply only)
After a successful or failed apply, DatabaseMigrationService records an audit event:
csharp
var jobId = Guid.NewGuid().ToString();
var operation = await auditStore.CreateOperationAsync(
jobId, BulkOperationType.QuickMigrate, "system", "system", ct);
var metadata = AuditHelpers.CreateDatabaseMigrationMetadata(
subdomainName, fromMigration, targetMigration, isRollback, dryRun,
result.Success, result.ErrorMessage, ipAddress, userAgent);
await auditStore.AddMetadataAsync(operation.Id, metadata, ct);Audit failures are caught and logged as warnings — they do not fail the migration operation itself. The audit stores: subdomain, from/to migration IDs, whether it was a rollback, dry-run flag, success/failure outcome, error message, IP address, and user agent.
Bulk Migration
csharp
await Parallel.ForEachAsync(
request.SubdomainNames,
new ParallelOptions { MaxDegreeOfParallelism = 5, CancellationToken = ct },
async (subdomainName, token) =>
{
var connectionString = await GetEncryptedConnectionStringAsync(subdomainName, token);
var status = await appsPortalService.GetMigrationStatusAsync(connectionString, token);
// If TargetMigration is null, default to the last known migration
var target = request.TargetMigration
?? status.Migrations.LastOrDefault()?.MigrationId
?? string.Empty;
var applyResult = await appsPortalService.ApplyMigrationAsync(connectionString, target, false, token);
results.Add(new SubdomainMigrationResultDto { ... });
});Key characteristics:
- Uses
ConcurrentBag<SubdomainMigrationResultDto>as the accumulator (thread-safe). MaxDegreeOfParallelism = 5— at most 5 tenant databases are migrated simultaneously to avoid overwhelming the SQL Server host.- If
TargetMigrationisnull, it is resolved per-subdomain to the latest available migration (fetched from a status call). This means different subdomains could end up at different target migrations if they have different applied states — each goes to its own "latest." - Per-subdomain exceptions are caught and stored as failed results; they do not abort the entire batch.
Layer 3 — AdminPortal Controller
csharp
// Src/BusinessOwners/BsuinessOwners.AdminPortal/Controllers/DatabaseMigrationController.cs
[ApiController]
[Route("[controller]")]
public class DatabaseMigrationController(
IDatabaseMigrationService databaseMigrationService,
IHttpContextAccessor httpContextAccessor,
ILogger<DatabaseMigrationController> logger) : ControllerBaseEndpoints:
| Method | Route | Description |
|---|---|---|
| GET | /DatabaseMigration/GetStatus/{subdomain} | Migration status for one tenant |
| POST | /DatabaseMigration/GetPreview/{subdomain} | Full preview (SQL, schema diff, data samples) |
| POST | /DatabaseMigration/Apply/{subdomain} | Apply or rollback a specific migration |
| POST | /DatabaseMigration/ApplyBulk | Migrate multiple subdomains in parallel |
For Apply and ApplyBulk, the controller extracts the caller's IP address and user agent via AuditHelpers and forwards them to the service layer for audit recording:
csharp
var ip = AuditHelpers.GetIpAddress(httpContextAccessor.HttpContext);
var userAgent = AuditHelpers.GetUserAgent(httpContextAccessor.HttpContext);
var result = await databaseMigrationService.ApplyAsync(
subdomain, request.TargetMigration, request.DryRun, ip, userAgent, ct);All errors are caught at the controller level and returned as 500 with a JSON { error: "..." } body — no unhandled exceptions propagate.
Layer 4 — Blazor Client
HTTP Service
csharp
// Src/BusinessOwners/BusinessOwners.Admin/BusinessOwners.Admin.Client/Features/DatabaseManager/Services/DatabaseManagerClientService.cs
public class DatabaseManagerClientService(IAdminService httpService, ISnackbar snackbar)
: IDatabaseManagerClientService
{
private const string ControllerName = "DatabaseMigration";
public async Task<MigrationStatusDto?> GetStatusAsync(string subdomain, CancellationToken ct = default)
{
var result = await httpService
.GetAsync<MigrationStatusDto>($"{ControllerName}/GetStatus/{subdomain}", ct)
.WithErrorToast(snackbar);
return result.IsSuccess ? result.Value : null;
}
// ... PostAsync equivalents for GetPreview, Apply, ApplyBulk
}IAdminService is the standard Blazor HTTP client abstraction. All calls return Result<T>. The .WithErrorToast(snackbar) extension automatically shows a MudBlazor snackbar on failure. The service returns null on failure — callers check for null before using the data.
DatabaseManagerPage (List View)
Route: /database-manager
The list page loads all subdomains from ISubdomainClientService.GetAllSubdomainDropDown() and renders them in a MudDataGrid. It does not load migration status for every subdomain on load (that would be N HTTP calls). Instead it shows a lightweight grid with subdomain name only — migration status is loaded on demand when navigating to the detail page.
csharp
_subdomains = result.Value
.Select(s => new SubdomainMigrationRow { SubdomainName = s.Name })
.ToList();Multi-selection is enabled on the grid. When one or more subdomains are selected, a "Apply to Latest (N selected)" button appears in the toolbar that opens BulkMigrationDialog.
SubdomainMigrationsPage (Detail View)
Route: /database-manager/{SubdomainName}
On load, calls GetStatusAsync(SubdomainName) and populates the page with:
- A summary panel: applied count, pending count, total count, and the current migration ID.
- A
MigrationTimelinecomponent showing the full list.
When the user clicks a migration in the timeline, OpenPreviewAsync is called:
csharp
private async Task OpenPreviewAsync(MigrationInfoDto migration)
{
var parameters = new DialogParameters<MigrationPreviewDialog>
{
{ x => x.SubdomainName, SubdomainName },
{ x => x.MigrationInfo, migration }
};
var dialog = await DialogService.ShowAsync<MigrationPreviewDialog>(...);
var result = await dialog.Result;
// Dialog returns "apply" or "rollback" as the result data
if (result != null && !result.Canceled && result.Data is string action)
{
await ConfirmAndApplyAsync(migration.MigrationId, action == "rollback");
}
}After the preview dialog closes, if the user clicked Apply or Rollback, an ApplyConfirmDialog is shown for a second confirmation. Only after that confirmation does MigrationService.ApplyAsync execute. On success, the page reloads status.
MigrationTimeline Component
The timeline is a self-contained Blazor component that renders a virtualized list split into two sections: pending (top) and applied (bottom, newest first).
csharp
protected override void OnParametersSet()
{
_pending = Migrations.Where(m => !m.IsApplied).OrderBy(m => m.MigrationId).ToList();
_applied = Migrations.Where(m => m.IsApplied).OrderByDescending(m => m.MigrationId).ToList();
_pendingIndexed = _pending.Select((m, i) => (i + 1, m)).ToList();
_appliedIndexed = _applied.Select((m, i) => (_pending.Count + i + 1, m)).ToList();
}Applied migrations are sorted newest-first (descending by migration ID) so the most recent applied state is visible at the top of the applied section. Pending migrations are sorted oldest-first (ascending) so the next to apply is at the top of that section.
Virtualization is used via Blazor's <Virtualize> component for both sections, with ItemSize set to 46px (pending) and 56px (applied). This keeps the component performant even for schemas with hundreds of migrations.
Date parsing from migration IDs:
csharp
private static string? GetAppliedDate(string migrationId)
{
// Parses "20250114093021_CreateFundTransfer" → "Jan 14, 2025"
if (migrationId.Length >= 14 &&
DateTime.TryParseExact(migrationId[..14], "yyyyMMddHHmmss", ...))
return dt.ToString("MMM dd, yyyy");
return null;
}The current migration row is visually highlighted with a blue background (#eff6ff) and a CURRENT badge. Each row shows a hover-reveal action button (Apply for pending, Preview for applied).
MigrationPreviewDialog Component
Opens as a full-width large dialog. On OnInitializedAsync, it immediately calls GetPreviewAsync and shows skeleton loaders while waiting.
The dialog has four tabs:
| Tab | Content |
|---|---|
| Schema | MudSimpleTable listing every SchemaChangeDto with color-coded badges (green=added, red=dropped, yellow=altered) |
| Data | Per-table panels with row count and up to 5 sample rows rendered as MudSimpleTable |
| SQL | The forward migration SQL script in a dark code block with a clipboard copy button |
| Rollback Script | The reverse SQL script with a warning banner and clipboard copy button |
The dialog footer shows context-sensitive action buttons:
- If
IsRollback == true→ red "Rollback" button →MudDialog.Close(DialogResult.Ok("rollback")) - If
IsRollback == false→ green "Apply Migration" button →MudDialog.Close(DialogResult.Ok("apply"))
The string "apply" or "rollback" is returned as the dialog result data, which SubdomainMigrationsPage uses to determine intent.
Change type badge styling:
csharp
private static string GetChangeBadgeStyle(string changeType) => changeType switch
{
"TableAdded" or "ColumnAdded" or "IndexAdded" => "background:#dcfce7; color:#16a34a;", // green
"TableDropped" or "ColumnDropped" or "IndexDropped" => "background:#fee2e2; color:#dc2626;", // red
"ColumnAltered" => "background:#fef3c7; color:#d97706;", // yellow
_ => "background:#f1f5f9; color:#64748b;"
};ApplyConfirmDialog Component
A simple confirmation dialog that changes behavior based on IsRollback:
- Forward migration: Shows migration ID and subdomain name, "Apply" button immediately enabled.
- Rollback: Shows a destructive warning, requires the user to type the exact subdomain name into a text field before the "Rollback" button enables.
razor
<MudButton OnClick="Confirm"
Disabled="@(IsRollback && _confirmText != SubdomainName)">
@(IsRollback ? "Rollback" : "Apply")
</MudButton>This subdomain-name re-entry pattern is a guard against accidental rollbacks on production databases.
BulkMigrationDialog Component
Three-state dialog: preview → running → results.
csharp
private async Task RunAsync()
{
if (OnApplyBulk == null) return;
_isRunning = true;
StateHasChanged();
_result = await OnApplyBulk(new BulkMigrationRequestDto
{
SubdomainNames = SubdomainNames,
TargetMigration = TargetMigration // null = each subdomain goes to its own latest
});
_isRunning = false;
StateHasChanged();
}OnApplyBulk is passed in as a Func<BulkMigrationRequestDto, Task<BulkMigrationResultDto?>> delegate from the parent page — this keeps the dialog decoupled from the service layer. The results table shows per-subdomain outcome (OK/Failed chip, applied migration ID, error message).
DTO Reference
AppsPortal DTOs (AppsPortal.Application.Core.Tenant.Dtos.Migration)
These are the canonical DTO definitions — BusinessOwners and the Blazor client each mirror these locally.
csharp
public class MigrationStatusDto
{
public string? CurrentMigration { get; set; } // null if no migrations applied
public int TotalMigrations { get; set; }
public int AppliedCount { get; set; }
public int PendingCount { get; set; }
public List<MigrationInfoDto> Migrations { get; set; } = new();
}
public class MigrationInfoDto
{
public string MigrationId { get; set; } // e.g. "20250114093021_CreateFundTransfer"
public string FriendlyName { get; set; } // e.g. "CreateFundTransfer"
public bool IsApplied { get; set; }
public bool IsCurrent { get; set; }
public DateTime? AppliedAt { get; set; } // populated from __EFMigrationsHistory if available
}
public class MigrationPreviewDto
{
public string FromMigration { get; set; } // "(none)" if database has no migrations yet
public string ToMigration { get; set; }
public bool IsRollback { get; set; }
public string SqlScript { get; set; } // T-SQL to execute the forward/rollback
public string RollbackSqlScript { get; set; } // T-SQL to undo SqlScript
public List<SchemaChangeDto> SchemaChanges { get; set; }
public List<AffectedTableSampleDto> AffectedTableSamples { get; set; }
}
public class SchemaChangeDto
{
public string TableName { get; set; }
public string ChangeType { get; set; } // "TableAdded" | "TableDropped" | "ColumnAdded" |
// "ColumnDropped" | "ColumnAltered" |
// "IndexAdded" | "IndexDropped"
public string? ColumnName { get; set; } // null for table-level changes
public string? DataType { get; set; } // CLR type name (e.g. "String", "Int32")
public string Details { get; set; } // Human-readable summary sentence
}
public class AffectedTableSampleDto
{
public string TableName { get; set; }
public long TotalRowCount { get; set; }
public List<Dictionary<string, object?>> SampleRows { get; set; } // up to 5 rows, dynamic columns
}
public class ApplyMigrationResultDto
{
public bool Success { get; set; }
public string AppliedMigration { get; set; }
public string? ErrorMessage { get; set; } // null on success
}BusinessOwners Additional DTOs
csharp
public class BulkMigrationRequestDto
{
public List<string> SubdomainNames { get; set; }
public string? TargetMigration { get; set; } // null = latest per subdomain
}
public class BulkMigrationResultDto
{
public int TotalCount { get; set; }
public int SuccessCount { get; set; }
public int FailedCount { get; set; }
public List<SubdomainMigrationResultDto> Results { get; set; }
}
public class SubdomainMigrationResultDto
{
public string SubdomainName { get; set; }
public bool Success { get; set; }
public string? Error { get; set; }
public string? AppliedMigration { get; set; }
}Data Flow: Full Request Trace
Scenario: Admin clicks a migration row to preview it, then applies
1. User navigates to /database-manager/acme-corp
└─ SubdomainMigrationsPage.OnInitializedAsync()
└─ LoadDataAsync()
└─ DatabaseManagerClientService.GetStatusAsync("acme-corp")
GET /DatabaseMigration/GetStatus/acme-corp
└─ DatabaseMigrationController.GetStatus("acme-corp")
└─ DatabaseMigrationService.GetStatusAsync("acme-corp")
└─ SubdomainService.GetSubdomainConnectionString("acme-corp")
→ SELECT ConnectionString FROM Subdomains WHERE Name='acme-corp'
→ returns encrypted connection string
└─ AppsPortalService.GetMigrationStatusAsync(encryptedCs)
GET AppsPortalURL/Migration/GetStatus?connectionString=<url-encoded-encrypted-cs>
└─ MigrationController.GetStatus(encryptedCs)
└─ MigrationService.GetStatusAsync(encryptedCs)
decrypt(encryptedCs) → rawCs
CreateScope() → IMicrotecAppsDbContext
SetConnectionString(rawCs)
GetMigrations() → compiled assembly list
GetAppliedMigrationsAsync() → SELECT FROM __EFMigrationsHistory (acme-corp DB)
→ MigrationStatusDto { applied=47, pending=3, total=50, ... }
← MigrationStatusDto
← MigrationStatusDto
← MigrationStatusDto
← 200 OK { MigrationStatusDto }
← MigrationStatusDto?
StateHasChanged() → renders timeline
2. User clicks migration row "20250301120000_AddPaymentTable"
└─ MigrationTimeline raises OnPreviewClicked event
└─ SubdomainMigrationsPage.OpenPreviewAsync(migration)
└─ DialogService.ShowAsync<MigrationPreviewDialog>(SubdomainName, MigrationInfo)
└─ MigrationPreviewDialog.OnInitializedAsync()
└─ DatabaseManagerClientService.GetPreviewAsync("acme-corp", "20250301120000_AddPaymentTable")
POST /DatabaseMigration/GetPreview/acme-corp { targetMigration: "20250301..." }
└─ DatabaseMigrationController.GetPreview("acme-corp", req)
└─ DatabaseMigrationService.GetPreviewAsync("acme-corp", "20250301...")
└─ GetEncryptedConnectionStringAsync("acme-corp") → encryptedCs
└─ AppsPortalService.GetMigrationPreviewAsync(encryptedCs, "20250301...")
POST AppsPortalURL/Migration/GetPreview { connectionString, targetMigration }
└─ MigrationService.GetPreviewAsync(encryptedCs, "20250301...")
decrypt(encryptedCs) → rawCs
DetermineIsRollback() → false (target is ahead of current)
IMigrator.GenerateScript(current, target) → sqlScript (forward T-SQL)
IMigrator.GenerateScript(target, current) → rollbackSqlScript
BuildSchemaChanges() → reflect UpOperations of migrations in range
→ [SchemaChangeDto { TableName="Payments", ChangeType="TableAdded" }, ...]
BuildAffectedTableSamplesAsync(rawCs, schemaChanges)
→ SqlConnection(rawCs).Open()
→ SELECT COUNT(*) FROM [Payments] -- table doesn't exist yet → caught/skipped
→ (no samples for new tables)
→ MigrationPreviewDto { IsRollback=false, SqlScript="...", SchemaChanges=[...] }
← MigrationPreviewDto
← MigrationPreviewDto
← MigrationPreviewDto
← 200 OK { MigrationPreviewDto }
← MigrationPreviewDto?
renders 4 tabs: Schema | Data | SQL | Rollback Script
3. User clicks "Apply Migration"
└─ MigrationPreviewDialog.OnApply()
MudDialog.Close(DialogResult.Ok("apply"))
└─ SubdomainMigrationsPage.OpenPreviewAsync receives result.Data = "apply"
└─ ConfirmAndApplyAsync("20250301...", isRollback=false)
└─ DialogService.ShowAsync<ApplyConfirmDialog>(target, subdomain, isRollback=false)
User confirms → MudDialog.Close(DialogResult.Ok(true))
└─ DatabaseManagerClientService.ApplyAsync("acme-corp", "20250301...", dryRun=false)
POST /DatabaseMigration/Apply/acme-corp { targetMigration, dryRun=false }
└─ DatabaseMigrationController.Apply("acme-corp", req)
AuditHelpers.GetIpAddress() + GetUserAgent()
└─ DatabaseMigrationService.ApplyAsync("acme-corp", "20250301...", false, ip, ua)
GetEncryptedConnectionStringAsync() → encryptedCs
AppsPortalService.GetMigrationStatusAsync() → status (for audit "from" migration)
AppsPortalService.ApplyMigrationAsync(encryptedCs, "20250301...", dryRun=false)
POST AppsPortalURL/Migration/Apply { connectionString, targetMigration, dryRun }
└─ MigrationService.ApplyAsync(encryptedCs, "20250301...", false)
decrypt → rawCs
IMigrator.MigrateAsync("20250301...")
→ EF Core applies pending migrations up to target against acme-corp DB
→ updates __EFMigrationsHistory
→ ApplyMigrationResultDto { Success=true, AppliedMigration="20250301..." }
← ApplyMigrationResultDto
auditStore.CreateOperationAsync(BulkOperationType.QuickMigrate)
auditStore.AddMetadataAsync(operationId, metadata) ← fire-and-forget-safe
← ApplyMigrationResultDto
← 200 OK { success=true }
← ApplyMigrationResultDto?
Snackbar.Add("Migration applied successfully", Severity.Success)
await LoadDataAsync() ← refreshes timelineKey Implementation Notes
Connection String Encryption Flow
Connection strings stored in the BusinessOwners admin database are encrypted at rest. The flow is:
Admin DB (encrypted CS) → BusinessOwners.Application passes through as-is
→ AppsPortal.Apis receives encrypted CS
→ MigrationService.DecryptConnectionString() tries IEncryptionService.Decrypt()
→ Falls back to raw value if decryption fails (handles both encrypted and plain text)The GetStatus operation in MigrationService skips decryption (the commented-out line) and uses the connection string directly. This may be intentional for a development/testing path where the connection string is already plain text. All other operations (GetPreview, Apply) decrypt.
Why AppsPortal Owns the Migration Logic
EF Core migrations are compiled into the AppsPortal.Infrastructure assembly. The IMigrationsAssembly, IMigrator, and the IMicrotecAppsDbContext are all registered in the AppsPortal DI container. BusinessOwners has no reference to these assemblies and cannot call EF Core migration APIs directly — it can only communicate via HTTP. This is the correct separation of concerns: the service that owns the schema owns the migration tooling.
Rollback Behavior
EF Core's MigrateAsync(targetMigration) will call Down() on migrations in reverse order until the target state is reached. The rollback script shown in the preview (RollbackSqlScript) is generated by swapping from/to in GenerateScript, which asks EF Core to produce the SQL for the opposite direction. This script is informational — it shows what the Down() methods will execute, but the actual rollback is performed by MigrateAsync, not by executing the raw SQL.
Bulk Migration Parallelism
MaxDegreeOfParallelism = 5 means at most 5 simultaneous connections to 5 different tenant databases. Each parallel task makes 2 HTTP calls to AppsPortal (one GetStatus, one Apply) and one call to the admin database (connection string lookup). Under a bulk operation on 50 subdomains, peak concurrency is 5 × 2 = 10 in-flight HTTP calls to AppsPortal.