Architecture
Version 1 · Initial page: system-level architecture — component diagram, tech stack, request pipeline, DI service graph, background services, auth flow, deployment, and config/secrets notes.
Architecture
A system-level view of how Titan Command is put together. For the local database's own schema, see the Database Schema page; for every HTTP route, see the API Reference page; for how the recurring sync feature works, see the Auto Sync Feature page. This doc doesn't repeat those — it explains how the pieces connect.
System overview
┌───────────────────────────┐
│ Browser (operator UI) │
└──────────────┬─────────────┘
│ HTTP (cookie-based JWT)
┌──────────────▼─────────────┐
│ QDG.Migration.Web │ ASP.NET Core 8 MVC
│ Controllers, Views, DI │
└──┬──────────┬─────────────┬─┘
│ │ │
┌─────────────────▼──┐ ┌────▼──────────┐ │
│ QDG.Migration.Core │ │QDG.Migration. │ │
│ Services (business │ │Data (EF Core) │ │
│ logic, no ASP.NET/ │ │Repositories + │ │
│ EF dependency) │ │AppDbContext │ │
└──────────┬─────────┘ └───────┬─────────┘ │
│ MySqlConnector/ │ SQLite │ HttpClient
│ Dapper │ │
┌───────────▼──────────┐ ┌──────▼──────┐ ┌───▼────────────┐
│ Source MySQL (QDB) │ │titan_command│ │ QDBAuth │
│ Destination MySQL │ │.db (local │ │ (external │
│ (QDG) — config_tables│ │ app state) │ │ identity/2FA) │
│ / config_id_mappings │ └─────────────┘ └────────────────┘
└───────────────────────┘
- QDG.Migration.Web — the ASP.NET Core MVC host. Controllers, Razor views, DI wiring
(
Program.cs), auth cookie handling, background services. - QDG.Migration.Core — domain models and all business logic (
Services/). No dependency on ASP.NET or EF Core; talks to the remote MySQL source/destination directly viaMySqlConnector/Dapper. - QDG.Migration.Data — EF Core
AppDbContext(SQLite) and repositories that persist app state (sessions, mappings, sync jobs) — distinct from the business data being migrated. - Remote MySQL (×2) — the legacy QDB source and the redesigned QDG destination, reached over
the network per
DatabaseConfig, never touched by EF Core. - QDBAuth — an external identity service shared across the QDB ecosystem; this app has no
local accounts, it's a thin client (
ICentralAuthService) of QDBAuth's login/2FA/refresh API.
Tech stack
- Backend: ASP.NET Core 8, MVC + Razor views (a handful of unused/unreachable Blazor
.razorfiles also exist — see the API Reference page's "Dead code" note). - Frontend: Bootstrap 5, Bootstrap Icons, jQuery, DataTables (for the Table Selection checklist), Google "Inter" font. No SPA framework — server-rendered pages with targeted AJAX calls for live status/progress.
- App-state storage: SQLite via EF Core (
AppDbContext), created withEnsureCreated()and evolved mostly through hand-rolled schema patches (see the Database Schema page). - Migrated-data access:
MySqlConnector/Dapper directly against source and destination MySQL — no ORM, no EF Core, for the actual business data. - Auth: JWT, read from a cookie (
AuthToken), not anAuthorizationheader.
Request pipeline
Middleware order, from Program.cs:
UseExceptionHandler("/Home/Error")+UseHsts()— non-Development only.UseStaticFiles()— HTTPS redirection is explicitly disabled (UseHttpsRedirection()is commented out), presumably because TLS termination happens upstream (IIS/reverse proxy) rather than in-process.UseRouting()UseAuthentication()UseAuthorization()MapControllerRoute(conventional{controller=Home}/{action=Index}/{id?})MapRazorPages()
Two request-size limits are deliberately raised above ASP.NET's defaults, both because the wizard
renders one form field per source table column (not per table) and some legacy tables have up to
~190 columns: FormOptions.ValueCountLimit and MVC's MaxModelBindingCollectionSize are both
raised from 1,024 to 20,000, and Kestrel's MaxRequestHeadersTotalSize is raised to 128KB to leave
headroom for the JWT + antiforgery + TempData cookies that stack up across a multi-step wizard
session. The app requires authentication everywhere (no anonymous access), which is explicitly
why the usual DoS-protection rationale for keeping those limits low doesn't apply here.
Service graph
DI registrations from Program.cs, grouped by role (all Scoped unless noted):
| Group | Interface → Implementation | Lifetime |
|---|---|---|
| Migration pipeline | IConfigurationService → ConfigurationService |
Scoped |
ITableService → TableService |
Scoped | |
IRelationshipService → RelationshipService |
Scoped | |
IMigrationService → MigrationService |
Scoped | |
IDependencyResolver → DependencyResolver |
Scoped | |
IDatabaseAnalyzerService → DatabaseAnalyzerService |
Scoped | |
IExcelSchemaService → ExcelSchemaService |
Scoped | |
ISchemaValidationService → SchemaValidationService |
Scoped | |
| Repositories | IConfigRepository → ConfigRepository |
Scoped |
ISessionRepository → SessionRepository |
Scoped | |
IDestinationMappingRepository → DestinationMappingRepository |
Scoped | |
| Auth | IAuthTokenService → AuthTokenService |
Scoped |
ICentralAuthService → CentralAuthService |
Scoped, via typed AddHttpClient |
|
TokenRefreshCoordinator |
Singleton | |
| Progress/registries | IMigrationProgressStore → MigrationProgressStore |
Singleton |
IActiveMigrationRegistry → ActiveMigrationRegistry |
Singleton | |
ICopyJobRegistry → CopyJobRegistry |
Singleton | |
| Specialized copy/repair | IBarrierTrialsCopyService → BarrierTrialsCopyService |
Scoped |
ISectionalCopyService → SectionalCopyService |
Scoped | |
IDisciplineRepairService → DisciplineRepairService |
Scoped | |
| Sync | ISyncService → SyncService (Web) |
Scoped |
ICopySyncService → CopySyncService (Web) |
Scoped |
Plus AppDbContext (SQLite, Scoped by default), AddMemoryCache(), AddRazorPages().
The three singletons in the "Progress/registries" group exist specifically so that a background
Task.Run and the request that started it (or a later polling request) can share live state —
progress snapshots and cancellation tokens don't survive in a per-request scope.
Background services
Exactly two IHostedServices run in this app, both polling every 1 minute:
SyncBackgroundService— checksSyncJobrows for due runs.CopySyncBackgroundService— checksCopySyncConfigrows for due runs.
Both follow the same shape: query for IsEnabled && Status != Running && NextRunAt <= now, then
fire each due item on its own Task.Run with a fresh DI scope (never the poller's own scope).
See the Auto Sync Feature page for what happens inside a run.
Startup resilience
On every app start, alongside the schema-patching described in the Database Schema page, two idempotent recovery queries run:
UPDATE SyncJobs SET Status = 0, LastError = '...recovered...' WHERE Status = 1;
UPDATE CopySyncConfigs SET Status = 0 WHERE Status = 1;
This resets any job left stuck at Status = Running by a process that died mid-run (IIS recycle,
crash) — without it, such a job would be permanently invisible to the poller (which deliberately
skips anything already Running, to avoid double-triggering) and look like it's running forever
while doing nothing. This is a whole-system resilience property, not something specific to one
feature — it applies identically to Sync Jobs and Copy-Sync.
Auth architecture
QDBAuth is the identity source of truth for the whole QDB ecosystem; this app has no local accounts. The flow:
AccountControllercallsICentralAuthService.LoginAsync(email, password), thenVerifyTwoFactorAsync(pendingToken, code)for the 6-digit TOTP step, thenWhoAmIAsyncto resolve identity and permissions.- On success,
IAuthTokenService.CreateSessionTokenmints this app's own short-lived session JWT (not QDBAuth's token directly) into theAuthTokencookie, viaAuthCookieHelper. SilentTokenRefreshFilterruns as an authorization filter on every authenticated request (exceptAccountactions). If the embedded external access token is within 120s of its 900s (15 min) expiry, it silently callsICentralAuthService.RefreshAsyncand reissues the cookie — sliding the session forward as long as the user stays active, up to the refresh token's ~1-day ceiling.TokenRefreshCoordinator(the one non-registry singleton) serializes refresh calls per session, because QDBAuth's refresh tokens are single-use — two concurrent requests racing to refresh would otherwise leave one of them with an already-dead token.- A dead/rotated refresh token, or a deactivated account, forces a silent logout (cookie cleared,
redirect to
/Account/Login?reason=expired).
Roles (Admin/Operator/Viewer) are derived from QDBAuth's permission string via
AuthTokenService.MapPermissionToRole, using the CentralAuthApi:RoleMap config (external
ADMIN/DEVELOPER/VIEWER → internal Admin/Operator/Viewer) with DefaultRole: Viewer as the
fallback.
Deployment
.github/workflows/deploy.yml — triggered on push to master or manual workflow_dispatch, runs
on a self-hosted Windows runner (labels Windows, x64, qdg-aus, qdg-main):
- Checkout,
dotnet restore/build --configuration Release/publish --output ./publish. - Deploy (PowerShell):
Stop-WebAppPool+Stop-Websitefor theQDG-MigratorIIS site, wait 5 seconds,robocopy ./publish → C:\WebSites\QDG-Migratorwith/E /PURGE(mirrors the publish output, deletes anything in the target that isn't in the new output). - A
finallyblock always restarts the app pool and website, even if robocopy failed — avoiding a deploy failure leaving the site down. - Robocopy exit codes ≥ 8 are treated as failure and fail the step.
There is no staging environment and no blue/green — a push to master deploys straight to
the production IIS site. The self-hosted runner implies the runner machine either is the IIS
host or has direct WebAdministration access to it.
Configuration & secrets
Only appsettings.json and appsettings.Development.json exist — no
appsettings.Production.json. Top-level sections in appsettings.json:
ConnectionStrings.AppDatabase— SQLite connection string fortitan_command.db.CentralAuthApi—BaseUrl,ClientSecret,SiteName,RoleMap,DefaultRolefor the QDBAuth integration.Logging,AllowedHosts— standard ASP.NET Core defaults.
Worth knowing, not assuming: the checked-in CentralAuthApi.ClientSecret value and the JWT
signing key's in-code fallback both read as dev-only placeholders, and no
appsettings.Production.json or other override file is visible in this repo. Don't assume
production secrets are handled through a mechanism this repo doesn't show (environment variables,
IIS-level config, a secrets store) without checking the actual production host — the repo alone
doesn't prove it.