Adds the two issues found and fixed during the release-candidate audit to release-candidate-review.md: the follow-up reminder index that never created on MariaDB (fix in the preceding commit), and the nondeterministic timeline day-grouping test. Corrects database-ownership.md drift: the MariaDB startup scenarios now report 42 tables (measured in every scenario this audit), not the stale 40 from before the last Phase 5 tables were added, and adds the partially-migrated heal scenario (35 -> 42) that was verified. All claims reflect behaviour verified in this audit: 420 tests on Windows and Linux in both ICU modes, all three Docker images built, four database startup scenarios against live MariaDB 11 and SQLite, and a full backup -> restore -> app-start cycle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.5 KiB
Database ownership and startup order
2026-07-19. Which component creates which table, in what order, and why a clean MariaDB install used to fail. Read this before adding a table or touching
StartupInitializationExtensions.
The problem this document exists to prevent
EF Core bakes provider-specific type names into a migration at scaffold time. Every migration in this repo was scaffolded against SQLite, so run against MariaDB it emits:
TEXTforDateTimeOffsetand every unbounded stringINTEGERforboolandint- a
PRIMARY KEYwith noAUTO_INCREMENT
A composite index over one of those TEXT/longtext columns then exceeds MySQL's 3072-byte key
limit and startup dies with Specified key was too long. This is not theoretical — it crashed
production once (Phase 4 CvVariants) and made every clean MariaDB install fail until 2026-07-19.
Rule: a table whose migration was scaffolded against SQLite must not be created by that migration on MariaDB. Empty the migration and give the table to the reconciler, which carries correct DDL per provider.
Startup order
InitializeJobTrackerAsync runs exactly this sequence:
1. Connect
2. ReconcileSchema() ← pass 1: repair existing schema, create reconciler-owned tables
3. Database.Migrate() ← create every migration-owned table
4. ReconcileSchema() ← pass 2: everything pass 1 had to skip
5. Seed admin, start services
Why the reconciler runs twice
Neither position alone works:
- Pass 1 must come first. A legacy database has hand-added columns and Identity tables that
predate the migrations; without repairing them (and stamping the legacy migration id into
__EFMigrationsHistory)Migrate()collides with them.AddCareerProfileRelationalChildrenalso adds children that referenceCareerProfiles, a reconciler-owned table — so it must exist before migrations run. - Pass 2 must come after. On a brand-new database the migration-owned tables do not exist during
pass 1, so every reconciler table that references one (FK into
JobApplications) is skipped, as are the index andAUTO_INCREMENTrepairs.
Every statement in ReconcileSchema is existence-guarded, so the second pass is a no-op scan on an
already-correct database. Two consequences worth knowing:
- The
DbConnectionis not wrapped inusing— it belongs to theDbContext, and disposing it in pass 1 made pass 2 throwObjectDisposedException. conn.Open()is guarded onConnectionState, because pass 2 may inherit an open connection.
Ownership
Migration-owned
Created by EF migrations, never by the reconciler:
Companies, JobApplications, Jobs, Correspondences, Attachments, JobEvents,
RuleSettings, and the ASP.NET Identity tables.
The reconciler may repair these (add a missing column, add an index, fix a non-AUTO_INCREMENT
primary key) and may seed the default RuleSettings row — but it must never CREATE TABLE them.
It used to create RuleSettings, which is precisely why a clean install failed with
Table 'RuleSettings' already exists once Migrate() reached the initial migration.
Reconciler-owned
Created by StartupInitializationExtensions, with a no-op migration holding the model snapshot:
UserRuleSettings, SystemEmailSettings, CvUploadArtifacts, CvExtractionRuns,
GmailConnections, MicrosoftGraphConnections, ImapConnections, TailoredCvDrafts,
CareerProfiles, CareerProfileVersions, the six CareerProfile children (CareerExperiences,
CareerEducations, CareerSkills, CareerProjects, CareerCertifications, CareerLanguages),
InterviewPrepNotes, AiWorkspaceNotes, CvVariants, CvVariantVersions, AiInteractions,
ApplicationChecklistItems, CoverLetterVersions, InterviewPrepItems, TwoFactorRecoveryCodes,
TrustedDevices, UserSessions.
No-op migrations, each with a comment explaining why:
| Migration | Tables |
|---|---|
20260717222917_AddCareerProfileRelationalChildren |
the six CareerProfile children |
20260718074509_AddCvVariants |
CvVariants, CvVariantVersions |
20260718131138_AddAiInteractions |
AiInteractions |
20260719085904_AddApplicationChecklistItems |
ApplicationChecklistItems |
20260719094728_SyncCareerChildKeyLengths |
snapshot sync only |
20260719120954_AddCoverLetterVersions |
CoverLetterVersions |
20260719145044_AddInterviewPrepItems |
InterviewPrepItems |
Dependency guards
A reconciler table that references another table is guarded on its parent existing, so pass 1 skips it on a fresh database and pass 2 creates it:
| Table | Waits for |
|---|---|
TailoredCvDrafts, InterviewPrepNotes, AiWorkspaceNotes, CvVariants, AiInteractions, ApplicationChecklistItems, CoverLetterVersions, InterviewPrepItems |
JobApplications (migration-owned) |
CvVariantVersions |
CvVariants |
CareerProfileVersions, the six CareerProfile children |
CareerProfiles |
CvExtractionRuns |
CvUploadArtifacts |
Index creation goes through one helper, EnsureMySqlIndex, which is guarded on table existence
as well as index existence — repairing an absent table is not pass 1's job.
Adding a new table
- Add the entity and its
DbSet, and bound every indexed string withHasMaxLength— an unbounded string becomeslongtext, which MariaDB cannot index without a prefix length. This is what broke the CareerProfile children. dotnet ef migrations add …, then empty theUp/Downand say why in a comment.- Add SQLite DDL (
CREATE TABLE IF NOT EXISTS) and MySQL DDL (int AUTO_INCREMENT,varchar(n),datetime(6),tinyint(1)) to the reconciler. Guard the MySQL create on any parent table. - Create indexes via
EnsureMySqlIndex/CREATE INDEX IF NOT EXISTS. - Verify on a real MariaDB container — see below. EF InMemory will not catch any of this.
Fresh install
No manual database preparation. Point the app at an empty database and start it:
# MariaDB
Database__Provider=mysql \
ConnectionStrings__JobTracker="Server=…;Database=jobtracker;User=…;Password=…;" \
dotnet run --project JobTrackerApi/JobTrackerApi.csproj
# SQLite (default) — creates Data__Root/jobtracker.db
dotnet run --project JobTrackerApi/JobTrackerApi.csproj
Create the empty schema/database itself (CREATE DATABASE jobtracker;); the application builds
everything inside it.
Production upgrade
Deploy and restart. The reconciler is idempotent and additive:
- it never drops a table that holds rows (
DropMalformedMySqlTablechecks the row count first) - it only adds missing columns, tables and indexes
- migrations already recorded in
__EFMigrationsHistoryare not re-run, so emptying a migration'sUpchanges nothing for an existing database
No downtime step, no manual SQL, no data migration.
Verified
All four scenarios, 2026-07-19, against MariaDB 11 and SQLite:
| Scenario | Result |
|---|---|
| Empty MariaDB | 42 tables created, app starts |
| Populated MariaDB, restart | idempotent — still 42 tables, rows preserved |
| Partially-migrated MariaDB (Phase 4/5 tables dropped) | healed 35 → 42, surviving rows preserved |
| Empty MariaDB via the Docker image | 42 tables created, app starts |
| Fresh SQLite | 42 tables created, app starts |
| Existing partially-migrated SQLite dev DB (34 tables) | upgraded to 44 tables, 13 applications and 8 companies preserved |
Column types on MariaDB spot-checked: int AUTO_INCREMENT primary keys, varchar(255) owner keys,
datetime(6) timestamps, tinyint(1) booleans, and every composite index inside the key limit.