fix(infrastructure): support clean MariaDB initialization
CI and Deploy / test (push) Failing after 1m1s
CI and Deploy / deploy (push) Has been skipped

A completely empty MariaDB database could not start: the reconciler assumed
migration-owned tables already existed, and migrations assumed reconciler-owned
tables already existed. Neither could go first. Existing databases worked, so
only fresh installs were affected.

Startup is now an explicit sequence: connect, reconcile, migrate, reconcile,
start. The reconciler runs twice because neither position alone works — pass 1
repairs legacy schemas and creates the reconciler-owned tables that migrations
reference, pass 2 picks up everything that could not exist yet on a fresh
database. Every statement is existence-guarded, so the second pass is a no-op
scan on a correct database.

Untangled the overlapping ownership:

- RuleSettings is migration-owned. The reconciler also created it, which made a
  clean install fail with "Table 'RuleSettings' already exists". It now only
  seeds the default row, and only once the table exists.
- The six CareerProfile child tables are reconciler-owned. Their migration was
  scaffolded against SQLite and indexed an unbounded longtext OwnerUserId, which
  exceeds MariaDB's 3072-byte key limit; it is now a no-op and the reconciler
  carries correct per-provider DDL. OwnerUserId and ItemKey are bounded to
  varchar(255) in the model so the index fits.
- Reconciler tables that reference another table are guarded on their parent, so
  pass 1 skips them on an empty database instead of failing on the foreign key.
- All index creation goes through one EnsureMySqlIndex helper, guarded on table
  existence as well as index existence. This removes ten copies of the raw block
  that crashed on a missing table.
- The DbContext-owned connection is no longer disposed by the reconciler, and
  Open() is guarded on connection state, so the second pass can reuse it.

Verified against MariaDB 11 and SQLite: empty MariaDB (40 tables, starts),
restart on the populated database (idempotent, rows preserved), empty MariaDB
via the Docker image, fresh SQLite (42 tables), and an existing partially
migrated SQLite dev database (34 tables upgraded to 44 with all 13 applications
and 8 companies intact). 329 backend tests pass in Release.

Ownership rules, startup order, fresh install and production upgrade are
documented in docs/infrastructure/database-ownership.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-19 11:51:55 +02:00
parent 1f1cbb92f3
commit 7f426e255c
7 changed files with 2787 additions and 387 deletions
+6
View File
@@ -376,6 +376,12 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<T>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
// Bounded so Pomelo maps them to varchar, not longtext: the composite index below is over
// OwnerUserId, and MariaDB cannot index a longtext column without a prefix length — an
// unbounded OwnerUserId is what blew the 3072-byte key limit on a clean MariaDB install.
modelBuilder.Entity<T>().Property(x => x.OwnerUserId).HasMaxLength(255);
modelBuilder.Entity<T>().Property(x => x.ItemKey).HasMaxLength(255);
modelBuilder.Entity<T>()
.HasIndex(x => new { x.OwnerUserId, x.CareerProfileId, x.SortOrder });
}
@@ -1,283 +1,28 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <summary>
/// Phase 3: relational children of CareerProfile (Experience/Education/Skill/Project/
/// Certification/Language) + the LongTailJson column on CareerProfiles. See
/// docs/architecture/career-profile-model.md.
///
/// HAND-EDITED after scaffolding. `dotnet ef migrations add` also re-emitted CreateTable for
/// AiWorkspaceNotes / CareerProfiles / InterviewPrepNotes / CareerProfileVersions — all of which
/// already exist in every real database (provisioned by the F1/Phase-0 reconciler in
/// StartupInitializationExtensions, which the prior ModelSnapshot did not know). Those creates
/// were removed so the migration applies cleanly on existing databases; the regenerated
/// snapshot now includes them, closing the drift. Only the six genuinely-new child tables and
/// the new LongTailJson column remain here.
/// </summary>
/// <inheritdoc />
public partial class AddCareerProfileRelationalChildren : Migration
{
// Deliberately a no-op. Scaffolded against SQLite, so on MariaDB it emitted an unbounded
// longtext OwnerUserId and then indexed (OwnerUserId, CareerProfileId, SortOrder) over it,
// which exceeds MySQL's 3072-byte key limit — a clean MariaDB install died here.
//
// The six CareerProfile child tables are provisioned by the idempotent reconciler in
// StartupInitializationExtensions, which carries correct DDL per provider. Existing databases
// already have this migration recorded as applied, so emptying it changes nothing for them.
// docs/infrastructure/database-ownership.md.
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// New editable long-tail JSON on the existing CareerProfiles table.
migrationBuilder.AddColumn<string>(
name: "LongTailJson",
table: "CareerProfiles",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "CareerCertifications",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
Issuer = table.Column<string>(type: "TEXT", nullable: true),
Location = table.Column<string>(type: "TEXT", nullable: true),
Date = table.Column<string>(type: "TEXT", nullable: true),
DateNormalized = table.Column<string>(type: "TEXT", nullable: true),
DetailsJson = table.Column<string>(type: "TEXT", nullable: false),
CareerProfileId = table.Column<int>(type: "INTEGER", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
ItemKey = table.Column<string>(type: "TEXT", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareerCertifications", x => x.Id);
table.ForeignKey(
name: "FK_CareerCertifications_CareerProfiles_CareerProfileId",
column: x => x.CareerProfileId,
principalTable: "CareerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CareerEducations",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Qualification = table.Column<string>(type: "TEXT", nullable: true),
QualificationLevel = table.Column<string>(type: "TEXT", nullable: true),
Institution = table.Column<string>(type: "TEXT", nullable: true),
Location = table.Column<string>(type: "TEXT", nullable: true),
Start = table.Column<string>(type: "TEXT", nullable: true),
End = table.Column<string>(type: "TEXT", nullable: true),
StartDate = table.Column<string>(type: "TEXT", nullable: true),
EndDate = table.Column<string>(type: "TEXT", nullable: true),
DetailsJson = table.Column<string>(type: "TEXT", nullable: false),
CareerProfileId = table.Column<int>(type: "INTEGER", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
ItemKey = table.Column<string>(type: "TEXT", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareerEducations", x => x.Id);
table.ForeignKey(
name: "FK_CareerEducations_CareerProfiles_CareerProfileId",
column: x => x.CareerProfileId,
principalTable: "CareerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CareerExperiences",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", nullable: true),
Company = table.Column<string>(type: "TEXT", nullable: true),
Location = table.Column<string>(type: "TEXT", nullable: true),
Start = table.Column<string>(type: "TEXT", nullable: true),
End = table.Column<string>(type: "TEXT", nullable: true),
StartDate = table.Column<string>(type: "TEXT", nullable: true),
EndDate = table.Column<string>(type: "TEXT", nullable: true),
IsCurrent = table.Column<bool>(type: "INTEGER", nullable: false),
BulletsJson = table.Column<string>(type: "TEXT", nullable: false),
SkillsJson = table.Column<string>(type: "TEXT", nullable: false),
CareerProfileId = table.Column<int>(type: "INTEGER", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
ItemKey = table.Column<string>(type: "TEXT", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareerExperiences", x => x.Id);
table.ForeignKey(
name: "FK_CareerExperiences_CareerProfiles_CareerProfileId",
column: x => x.CareerProfileId,
principalTable: "CareerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CareerLanguages",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
Level = table.Column<string>(type: "TEXT", nullable: true),
Notes = table.Column<string>(type: "TEXT", nullable: true),
CareerProfileId = table.Column<int>(type: "INTEGER", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
ItemKey = table.Column<string>(type: "TEXT", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareerLanguages", x => x.Id);
table.ForeignKey(
name: "FK_CareerLanguages_CareerProfiles_CareerProfileId",
column: x => x.CareerProfileId,
principalTable: "CareerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CareerProjects",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
Role = table.Column<string>(type: "TEXT", nullable: true),
Location = table.Column<string>(type: "TEXT", nullable: true),
Start = table.Column<string>(type: "TEXT", nullable: true),
End = table.Column<string>(type: "TEXT", nullable: true),
StartDate = table.Column<string>(type: "TEXT", nullable: true),
EndDate = table.Column<string>(type: "TEXT", nullable: true),
BulletsJson = table.Column<string>(type: "TEXT", nullable: false),
SkillsJson = table.Column<string>(type: "TEXT", nullable: false),
LinksJson = table.Column<string>(type: "TEXT", nullable: false),
CareerProfileId = table.Column<int>(type: "INTEGER", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
ItemKey = table.Column<string>(type: "TEXT", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareerProjects", x => x.Id);
table.ForeignKey(
name: "FK_CareerProjects_CareerProfiles_CareerProfileId",
column: x => x.CareerProfileId,
principalTable: "CareerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CareerSkills",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
Category = table.Column<string>(type: "TEXT", nullable: true),
Proficiency = table.Column<string>(type: "TEXT", nullable: true),
CareerProfileId = table.Column<int>(type: "INTEGER", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
ItemKey = table.Column<string>(type: "TEXT", nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CareerSkills", x => x.Id);
table.ForeignKey(
name: "FK_CareerSkills_CareerProfiles_CareerProfileId",
column: x => x.CareerProfileId,
principalTable: "CareerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CareerCertifications_CareerProfileId",
table: "CareerCertifications",
column: "CareerProfileId");
migrationBuilder.CreateIndex(
name: "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder",
table: "CareerCertifications",
columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_CareerEducations_CareerProfileId",
table: "CareerEducations",
column: "CareerProfileId");
migrationBuilder.CreateIndex(
name: "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder",
table: "CareerEducations",
columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_CareerExperiences_CareerProfileId",
table: "CareerExperiences",
column: "CareerProfileId");
migrationBuilder.CreateIndex(
name: "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder",
table: "CareerExperiences",
columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_CareerLanguages_CareerProfileId",
table: "CareerLanguages",
column: "CareerProfileId");
migrationBuilder.CreateIndex(
name: "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder",
table: "CareerLanguages",
columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_CareerProjects_CareerProfileId",
table: "CareerProjects",
column: "CareerProfileId");
migrationBuilder.CreateIndex(
name: "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder",
table: "CareerProjects",
columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_CareerSkills_CareerProfileId",
table: "CareerSkills",
column: "CareerProfileId");
migrationBuilder.CreateIndex(
name: "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder",
table: "CareerSkills",
columns: new[] { "OwnerUserId", "CareerProfileId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "CareerCertifications");
migrationBuilder.DropTable(name: "CareerEducations");
migrationBuilder.DropTable(name: "CareerExperiences");
migrationBuilder.DropTable(name: "CareerLanguages");
migrationBuilder.DropTable(name: "CareerProjects");
migrationBuilder.DropTable(name: "CareerSkills");
migrationBuilder.DropColumn(
name: "LongTailJson",
table: "CareerProfiles");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class SyncCareerChildKeyLengths : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -355,6 +355,7 @@ namespace JobTrackerApi.Migrations
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Location")
@@ -365,6 +366,7 @@ namespace JobTrackerApi.Migrations
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
@@ -403,6 +405,7 @@ namespace JobTrackerApi.Migrations
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Location")
@@ -410,6 +413,7 @@ namespace JobTrackerApi.Migrations
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Qualification")
@@ -463,6 +467,7 @@ namespace JobTrackerApi.Migrations
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Location")
@@ -470,6 +475,7 @@ namespace JobTrackerApi.Migrations
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("SkillsJson")
@@ -508,6 +514,7 @@ namespace JobTrackerApi.Migrations
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Level")
@@ -521,6 +528,7 @@ namespace JobTrackerApi.Migrations
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
@@ -627,6 +635,7 @@ namespace JobTrackerApi.Migrations
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("LinksJson")
@@ -641,6 +650,7 @@ namespace JobTrackerApi.Migrations
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Role")
@@ -682,6 +692,7 @@ namespace JobTrackerApi.Migrations
b.Property<string>("ItemKey")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Name")
@@ -689,6 +700,7 @@ namespace JobTrackerApi.Migrations
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Proficiency")
@@ -104,6 +104,31 @@ public static class StartupInitializationExtensions
return cmd.ExecuteScalar() is not null;
}
private static bool HasMySqlIndex(DbConnection c, string table, string indexName)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND INDEX_NAME = @index LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
var p3 = cmd.CreateParameter(); p3.ParameterName = "@index"; p3.Value = indexName; cmd.Parameters.Add(p3);
return cmd.ExecuteScalar() is not null;
}
// The one place an index is created on MySQL. Guarded on TABLE existence, not just index
// existence: the reconciler runs a pass BEFORE Migrate(), so on a brand-new database the
// migration-owned tables do not exist yet. Repairing an absent table is not this pass's job --
// it is skipped, Migrate() creates it, and the post-Migrate pass then finds it and reconciles.
// Without this guard a clean MariaDB install died on "Table 'CareerProfiles' doesn't exist".
// See docs/infrastructure/database-ownership.md.
private static void EnsureMySqlIndex(DbConnection c, string table, string indexName, string columnsSql, bool unique = false)
{
if (!HasMySqlTable(c, table)) return;
if (HasMySqlIndex(c, table, indexName)) return;
using var cmd = c.CreateCommand();
cmd.CommandText = $"CREATE {(unique ? "UNIQUE " : string.Empty)}INDEX `{indexName}` ON `{table}` ({columnsSql});";
cmd.ExecuteNonQuery();
}
// A migration scaffolded against SQLite bakes SQLite type names into its DDL ("TEXT" for
// DateTimeOffset, "INTEGER" for bool/int, no AUTO_INCREMENT). Run against MariaDB it produces a
// structurally wrong table -- and a composite index over a TEXT column then blows MySQL's
@@ -344,6 +369,19 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
// The schema reconciler. Runs TWICE: once before Migrate() and once after.
//
// Before: legacy databases need their Identity tables and hand-added columns present, and
// the legacy migration-history stamp written, or Migrate() collides with them.
// After: on a brand-new database the migration-owned tables did not exist during the first
// pass, so everything that depends on them (reconciler-owned tables that FK into
// JobApplications, index and AUTO_INCREMENT repairs) was skipped. The second pass finds them
// and finishes the job.
//
// Every statement in here is existence-guarded, so running it twice is a no-op scan on an
// already-correct database. docs/infrastructure/database-ownership.md.
void ReconcileSchema()
{
if (useSqliteBootstrap)
{
// Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied).
@@ -352,8 +390,11 @@ public static class StartupInitializationExtensions
const string legacyMigrationId = "20260310195000_AddJobFieldsAndSoftDelete";
const string legacyProductVersion = "7.0.17";
using DbConnection conn = db.Database.GetDbConnection();
conn.Open();
// Not a `using`: the connection belongs to the DbContext, and ReconcileSchema runs more
// than once — disposing it here made the second pass throw ObjectDisposedException.
DbConnection conn = db.Database.GetDbConnection();
// ReconcileSchema runs twice; the second pass may inherit an already-open connection.
if (conn.State != System.Data.ConnectionState.Open) conn.Open();
static void EnsureIdentityTables(DbConnection c)
{
@@ -753,6 +794,123 @@ public static class StartupInitializationExtensions
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CareerProfiles_OwnerUserId" ON "CareerProfiles" ("OwnerUserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version" ON "CareerProfileVersions" ("OwnerUserId", "CareerProfileId", "Version");""");
// The six relational children of CareerProfile. Reconciler-owned: their migration
// is a no-op because it was scaffolded against SQLite and produced an unindexable
// longtext OwnerUserId on MariaDB. docs/infrastructure/database-ownership.md.
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerExperiences" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerExperiences" PRIMARY KEY AUTOINCREMENT,
"Title" TEXT NULL,
"Company" TEXT NULL,
"Location" TEXT NULL,
"Start" TEXT NULL,
"End" TEXT NULL,
"StartDate" TEXT NULL,
"EndDate" TEXT NULL,
"IsCurrent" INTEGER NOT NULL DEFAULT 0,
"BulletsJson" TEXT NOT NULL DEFAULT '[]',
"SkillsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerExperiences_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerExperiences_CareerProfileId" ON "CareerExperiences" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder" ON "CareerExperiences" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerEducations" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerEducations" PRIMARY KEY AUTOINCREMENT,
"Qualification" TEXT NULL,
"QualificationLevel" TEXT NULL,
"Institution" TEXT NULL,
"Location" TEXT NULL,
"Start" TEXT NULL,
"End" TEXT NULL,
"StartDate" TEXT NULL,
"EndDate" TEXT NULL,
"DetailsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerEducations_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerEducations_CareerProfileId" ON "CareerEducations" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder" ON "CareerEducations" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerSkills" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerSkills" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL,
"Category" TEXT NULL,
"Proficiency" TEXT NULL,
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerSkills_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerSkills_CareerProfileId" ON "CareerSkills" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder" ON "CareerSkills" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerProjects" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProjects" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL,
"Role" TEXT NULL,
"Location" TEXT NULL,
"Start" TEXT NULL,
"End" TEXT NULL,
"StartDate" TEXT NULL,
"EndDate" TEXT NULL,
"BulletsJson" TEXT NOT NULL DEFAULT '[]',
"SkillsJson" TEXT NOT NULL DEFAULT '[]',
"LinksJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerProjects_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProjects_CareerProfileId" ON "CareerProjects" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder" ON "CareerProjects" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerCertifications" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerCertifications" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL,
"Issuer" TEXT NULL,
"Location" TEXT NULL,
"Date" TEXT NULL,
"DateNormalized" TEXT NULL,
"DetailsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerCertifications_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerCertifications_CareerProfileId" ON "CareerCertifications" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder" ON "CareerCertifications" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerLanguages" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerLanguages" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL,
"Level" TEXT NULL,
"Notes" TEXT NULL,
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerLanguages_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerLanguages_CareerProfileId" ON "CareerLanguages" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder" ON "CareerLanguages" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
}
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5):
@@ -981,22 +1139,13 @@ public static class StartupInitializationExtensions
var cs = app.Configuration.GetConnectionString("JobTracker");
if (!string.IsNullOrWhiteSpace(cs))
{
using DbConnection conn = db.Database.GetDbConnection();
conn.Open();
// Not a `using`: the connection belongs to the DbContext, and ReconcileSchema runs more
// than once — disposing it here made the second pass throw ObjectDisposedException.
DbConnection conn = db.Database.GetDbConnection();
// ReconcileSchema runs twice; the second pass may inherit an already-open connection.
if (conn.State != System.Data.ConnectionState.Open) conn.Open();
EnsureIdentityTablesMySql(conn);
static bool MySqlIndexExists(DbConnection c, string table, string indexName)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND INDEX_NAME = @index LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
var p4 = cmd.CreateParameter(); p4.ParameterName = "@index"; p4.Value = indexName; cmd.Parameters.Add(p4);
return cmd.ExecuteScalar() is not null;
}
static bool MySqlIntPrimaryKeyIsAutoIncrement(DbConnection c, string table, string column)
{
@@ -1038,37 +1187,169 @@ public static class StartupInitializationExtensions
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfiles", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfileVersions", "Id");
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
EnsureMySqlIndex(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId", "`OwnerUserId`", unique: true);
if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version"))
EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`");
// The six relational children of CareerProfile — reconciler-owned (their
// migration is a no-op). varchar OwnerUserId/ItemKey so the composite index fits
// inside MariaDB's key limit. docs/infrastructure/database-ownership.md.
if (!HasMySqlTable(conn, "CareerExperiences") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);";
cmd.ExecuteNonQuery();
using var cmdCareerExperiences = conn.CreateCommand();
cmdCareerExperiences.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerExperiences` (
`Id` int NOT NULL AUTO_INCREMENT,
`Title` longtext NULL,
`Company` longtext NULL,
`Location` longtext NULL,
`Start` longtext NULL,
`End` longtext NULL,
`StartDate` longtext NULL,
`EndDate` longtext NULL,
`IsCurrent` tinyint(1) NOT NULL DEFAULT 0,
`BulletsJson` longtext NOT NULL,
`SkillsJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerExperiences_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerExperiences.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerExperiences", "Id");
EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerEducations") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerEducations = conn.CreateCommand();
cmdCareerEducations.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerEducations` (
`Id` int NOT NULL AUTO_INCREMENT,
`Qualification` longtext NULL,
`QualificationLevel` longtext NULL,
`Institution` longtext NULL,
`Location` longtext NULL,
`Start` longtext NULL,
`End` longtext NULL,
`StartDate` longtext NULL,
`EndDate` longtext NULL,
`DetailsJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerEducations_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerEducations.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerEducations", "Id");
EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerSkills") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerSkills = conn.CreateCommand();
cmdCareerSkills.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerSkills` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NULL,
`Category` longtext NULL,
`Proficiency` longtext NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerSkills_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerSkills.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerSkills", "Id");
EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerProjects") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerProjects = conn.CreateCommand();
cmdCareerProjects.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProjects` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NULL,
`Role` longtext NULL,
`Location` longtext NULL,
`Start` longtext NULL,
`End` longtext NULL,
`StartDate` longtext NULL,
`EndDate` longtext NULL,
`BulletsJson` longtext NOT NULL,
`SkillsJson` longtext NOT NULL,
`LinksJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerProjects_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerProjects.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProjects", "Id");
EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerCertifications") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerCertifications = conn.CreateCommand();
cmdCareerCertifications.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerCertifications` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NULL,
`Issuer` longtext NULL,
`Location` longtext NULL,
`Date` longtext NULL,
`DateNormalized` longtext NULL,
`DetailsJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerCertifications_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerCertifications.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerCertifications", "Id");
EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerLanguages") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerLanguages = conn.CreateCommand();
cmdCareerLanguages.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerLanguages` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NULL,
`Level` longtext NULL,
`Notes` longtext NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerLanguages_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerLanguages.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerLanguages", "Id");
EnsureMySqlIndex(conn, "CareerLanguages", "IX_CareerLanguages_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerLanguages", "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepNotes", "Id");
if (!MySqlIndexExists(conn, "InterviewPrepNotes", "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_InterviewPrepNotes_OwnerUserId_JobApplicationId` ON `InterviewPrepNotes` (`OwnerUserId`, `JobApplicationId`);";
cmd.ExecuteNonQuery();
}
EnsureMySqlIndex(conn, "InterviewPrepNotes", "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId", "`OwnerUserId`, `JobApplicationId`", unique: true);
EnsureMySqlAutoIncrementPrimaryKey(conn, "AiWorkspaceNotes", "Id");
if (!MySqlIndexExists(conn, "AiWorkspaceNotes", "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType` ON `AiWorkspaceNotes` (`OwnerUserId`, `JobApplicationId`, `NoteType`);";
cmd.ExecuteNonQuery();
}
EnsureMySqlIndex(conn, "AiWorkspaceNotes", "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType", "`OwnerUserId`, `JobApplicationId`, `NoteType`", unique: true);
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
// Correspondences/Attachments) -- re-run once more after Migrate() below via
@@ -1090,24 +1371,13 @@ public static class StartupInitializationExtensions
EnsureMySqlColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpPendingSecretEncrypted` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpEnabledAtUtc` datetime NULL;");
if (!HasMySqlTable(conn, "RuleSettings"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `RuleSettings` (
`Id` int NOT NULL,
`AppliedFollowUpDays` int NOT NULL,
`AppliedGhostDays` int NOT NULL,
`OfferFollowUpDays` int NOT NULL,
`OfferGhostDays` int NOT NULL,
`FeedbackFollowUpDays` int NOT NULL,
`FeedbackGhostDays` int NOT NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
using (var seedRuleSettings = conn.CreateCommand())
// RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler
// used to create it too, which made a clean install fail with "Table 'RuleSettings'
// already exists" when Migrate() then tried. Only the default row is seeded here,
// and only once the table exists (pass 3 on a fresh database).
if (HasMySqlTable(conn, "RuleSettings"))
{
using var seedRuleSettings = conn.CreateCommand();
seedRuleSettings.CommandText = @"INSERT INTO `RuleSettings` (`Id`, `AppliedFollowUpDays`, `AppliedGhostDays`, `OfferFollowUpDays`, `OfferGhostDays`, `FeedbackFollowUpDays`, `FeedbackGhostDays`)
SELECT 1, 14, 30, 7, 14, 7, 14
WHERE NOT EXISTS (SELECT 1 FROM `RuleSettings` WHERE `Id` = 1);";
@@ -1167,7 +1437,7 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "CvExtractionRuns"))
if (!HasMySqlTable(conn, "CvExtractionRuns") && HasMySqlTable(conn, "CvUploadArtifacts"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvExtractionRuns` (
@@ -1271,7 +1541,7 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "TailoredCvDrafts"))
if (!HasMySqlTable(conn, "TailoredCvDrafts") && HasMySqlTable(conn, "JobApplications"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredCvDrafts` (
@@ -1315,7 +1585,7 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "CareerProfileVersions"))
if (!HasMySqlTable(conn, "CareerProfileVersions") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfileVersions` (
@@ -1333,7 +1603,7 @@ public static class StartupInitializationExtensions
}
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
if (!HasMySqlTable(conn, "InterviewPrepNotes"))
if (!HasMySqlTable(conn, "InterviewPrepNotes") && HasMySqlTable(conn, "JobApplications"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `InterviewPrepNotes` (
@@ -1353,7 +1623,7 @@ public static class StartupInitializationExtensions
}
// Generic AI workspace note persistence (candidate fit, focus plan).
if (!HasMySqlTable(conn, "AiWorkspaceNotes"))
if (!HasMySqlTable(conn, "AiWorkspaceNotes") && HasMySqlTable(conn, "JobApplications"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `AiWorkspaceNotes` (
@@ -1382,7 +1652,7 @@ public static class StartupInitializationExtensions
DropMalformedMySqlTable(conn, "AiInteractions", "CreatedAtUtc", "datetime");
DropMalformedMySqlTable(conn, "ApplicationChecklistItems", "CreatedAtUtc", "datetime");
if (!HasMySqlTable(conn, "CvVariants"))
if (!HasMySqlTable(conn, "CvVariants") && HasMySqlTable(conn, "JobApplications"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVariants` (
@@ -1402,7 +1672,7 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "CvVariantVersions"))
if (!HasMySqlTable(conn, "CvVariantVersions") && HasMySqlTable(conn, "CvVariants"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVariantVersions` (
@@ -1419,7 +1689,7 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "AiInteractions"))
if (!HasMySqlTable(conn, "AiInteractions") && HasMySqlTable(conn, "JobApplications"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `AiInteractions` (
@@ -1438,7 +1708,7 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "ApplicationChecklistItems"))
if (!HasMySqlTable(conn, "ApplicationChecklistItems") && HasMySqlTable(conn, "JobApplications"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `ApplicationChecklistItems` (
@@ -1481,25 +1751,12 @@ public static class StartupInitializationExtensions
("ApplicationChecklistItems", "IX_ApplicationChecklistItems_Owner_Job_Sort", "`OwnerUserId`, `JobApplicationId`, `SortOrder`", false),
})
{
if (MySqlIndexExists(conn, ixTable, ixName)) continue;
using var cmd = conn.CreateCommand();
cmd.CommandText = $"CREATE {(ixUnique ? "UNIQUE " : string.Empty)}INDEX `{ixName}` ON `{ixTable}` ({ixColumns});";
cmd.ExecuteNonQuery();
EnsureMySqlIndex(conn, ixTable, ixName, ixColumns, ixUnique);
}
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
EnsureMySqlIndex(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId", "`OwnerUserId`", unique: true);
if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);";
cmd.ExecuteNonQuery();
}
EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`");
if (!HasMySqlTable(conn, "TwoFactorRecoveryCodes"))
{
@@ -1517,12 +1774,7 @@ public static class StartupInitializationExtensions
EnsureMySqlAutoIncrementPrimaryKey(conn, "TwoFactorRecoveryCodes", "Id");
if (!MySqlIndexExists(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc` ON `TwoFactorRecoveryCodes` (`UserId`, `UsedAtUtc`);";
cmd.ExecuteNonQuery();
}
EnsureMySqlIndex(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc", "`UserId`, `UsedAtUtc`");
if (!HasMySqlTable(conn, "TrustedDevices"))
{
@@ -1542,19 +1794,9 @@ public static class StartupInitializationExtensions
EnsureMySqlAutoIncrementPrimaryKey(conn, "TrustedDevices", "Id");
if (!MySqlIndexExists(conn, "TrustedDevices", "IX_TrustedDevices_UserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_TrustedDevices_UserId` ON `TrustedDevices` (`UserId`);";
cmd.ExecuteNonQuery();
}
EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_UserId", "`UserId`");
if (!MySqlIndexExists(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_TrustedDevices_TokenHash` ON `TrustedDevices` (`TokenHash`);";
cmd.ExecuteNonQuery();
}
EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash", "`TokenHash`");
if (!HasMySqlTable(conn, "UserSessions"))
{
@@ -1572,12 +1814,7 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "UserSessions", "IX_UserSessions_UserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_UserSessions_UserId` ON `UserSessions` (`UserId`);";
cmd.ExecuteNonQuery();
}
EnsureMySqlIndex(conn, "UserSessions", "IX_UserSessions_UserId", "`UserId`");
// Schema reconciliation must never crash app startup: an index that fails
// (e.g. combined key exceeds MySQL's 3072-byte limit because an older
@@ -1587,14 +1824,13 @@ public static class StartupInitializationExtensions
// per-column key limit, and far longer than the GUID-like Identity ids
// actually stored there) so composite indexes stay well under the cap
// regardless of the column's declared width.
void TryCreateIndex(string table, string indexName, string columnsSql)
// Same guarded creation, but non-fatal: these are optimisation indexes on
// migration-owned tables, so a failure must degrade performance, never boot.
void TryCreateIndex(string table, string indexName, string columnsSql, bool unique = false)
{
if (MySqlIndexExists(conn, table, indexName)) return;
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $"CREATE INDEX `{indexName}` ON `{table}` ({columnsSql});";
cmd.ExecuteNonQuery();
EnsureMySqlIndex(conn, table, indexName, columnsSql, unique);
}
catch (Exception ex)
{
@@ -1602,20 +1838,8 @@ public static class StartupInitializationExtensions
}
}
void TryCreateUniqueIndex(string table, string indexName, string columnsSql)
{
if (MySqlIndexExists(conn, table, indexName)) return;
try
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $"CREATE UNIQUE INDEX `{indexName}` ON `{table}` ({columnsSql});";
cmd.ExecuteNonQuery();
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Skipping unique index {Index} on {Table} during startup reconciliation.", indexName, table);
}
}
void TryCreateUniqueIndex(string table, string indexName, string columnsSql) =>
TryCreateIndex(table, indexName, columnsSql, unique: true);
TryCreateIndex("Companies", "IX_Companies_OwnerUserId", "`OwnerUserId`(191)");
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId", "`OwnerUserId`(191)");
@@ -1641,7 +1865,17 @@ public static class StartupInitializationExtensions
TryCreateIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId", "`JobApplicationId`");
}
}
}
// 1. Reconcile what already exists, and create the reconciler-owned tables. This has to run
// BEFORE Migrate(): legacy schemas need repairing so migrations do not collide with
// them, and AddCareerProfileRelationalChildren adds children that reference
// CareerProfiles, a reconciler-owned table. Anything that references a migration-owned
// table is skipped here (the parent does not exist yet) and picked up in pass 3.
ReconcileSchema();
// 2. Migrations create every migration-owned table. On a brand-new database this is what
// actually builds the schema; the pass above found nothing to reconcile.
try
{
using var migrationScope = app.Services.CreateScope();
@@ -1654,6 +1888,11 @@ public static class StartupInitializationExtensions
throw;
}
// 3. Reconcile again, now that the migration-owned tables exist: creates the
// reconciler-owned tables that reference them and applies the index / AUTO_INCREMENT
// repairs that pass 1 had to skip. Idempotent, so on an existing database it is a scan.
ReconcileSchema();
// Optional: seed an initial admin user for local username/password login.
// Set Auth:AdminEmail and Auth:AdminPassword to enable.
var adminEmail = (app.Configuration["Auth:AdminEmail"] ?? "").Trim();
+157
View File
@@ -0,0 +1,157 @@
# 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:
- `TEXT` for `DateTimeOffset` and every unbounded string
- `INTEGER` for `bool` and `int`
- a `PRIMARY KEY` with **no** `AUTO_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. `AddCareerProfileRelationalChildren` also
adds children that reference `CareerProfiles`, 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 and `AUTO_INCREMENT` repairs.
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 `DbConnection` is **not** wrapped in `using` — it belongs to the `DbContext`, and disposing it
in pass 1 made pass 2 throw `ObjectDisposedException`.
- `conn.Open()` is guarded on `ConnectionState`, 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`, `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 |
### 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` | `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
1. Add the entity and its `DbSet`, and **bound every indexed string** with `HasMaxLength` — an
unbounded string becomes `longtext`, which MariaDB cannot index without a prefix length. This is
what broke the CareerProfile children.
2. `dotnet ef migrations add …`, then **empty the `Up`/`Down`** and say why in a comment.
3. 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.
4. Create indexes via `EnsureMySqlIndex` / `CREATE INDEX IF NOT EXISTS`.
5. 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:
```bash
# 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 (`DropMalformedMySqlTable` checks the row count first)
- it only adds missing columns, tables and indexes
- migrations already recorded in `__EFMigrationsHistory` are not re-run, so emptying a migration's
`Up` changes 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 | 40 tables created, app starts |
| Populated MariaDB, restart | idempotent — still 40 tables, rows preserved |
| Empty MariaDB via the Docker image | 40 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.