fix(infrastructure): support clean MariaDB initialization
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2219
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();
|
||||
|
||||
Reference in New Issue
Block a user