fix(db): provision CV builder + AI workspace tables via the MySQL-safe reconciler
Deploy failed on prod (MariaDB) while the test job was green: backend startup threw during Database.Migrate(), so deploy.sh's post-deploy health check exited. Root cause: AddCvVariants/AddAiInteractions were scaffolded against SQLite, so they bake SQLite type names into their DDL — DateTimeOffset emits `TEXT`, bool/int emit `INTEGER`, and the PK gets no AUTO_INCREMENT. Run against MariaDB that yields a structurally wrong table, and the composite index over a TEXT column then trips "ERROR 1071: Specified key was too long; max key length is 3072 bytes". SQLite accepts all of it, which is why local/container verification passed. Fix, following the pattern already used for CareerProfiles/AiWorkspaceNotes: - both migrations become no-ops; the three tables are reconciler-owned - reconciler provisions them idempotently per dialect (MySQL: varchar/int AUTO_INCREMENT/datetime(6); SQLite: CREATE TABLE IF NOT EXISTS) - DropMalformedMySqlTable rebuilds a half-built table left by the failed migration, guarded on row count so a table with ANY rows is never dropped - bound the indexed string columns with HasMaxLength so the model matches Verified against a real MariaDB 11 container: reproduced error 1071, then confirmed the corrected DDL yields auto_increment PKs, varchar/datetime columns and all previously-failing indexes. 306 backend tests green; SQLite container starts clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -298,6 +298,12 @@ namespace JobTrackerApi.Data
|
|||||||
// owns career data. PublicSlug is globally unique so /cv/{slug} can resolve it anonymously.
|
// owns career data. PublicSlug is globally unique so /cv/{slug} can resolve it anonymously.
|
||||||
modelBuilder.Entity<CvVariant>()
|
modelBuilder.Entity<CvVariant>()
|
||||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||||
|
// Bound the indexed string columns so Pomelo maps them to varchar, not longtext (MariaDB
|
||||||
|
// cannot index a TEXT/longtext column without a prefix length). Actual prod DDL is applied by
|
||||||
|
// the reconciler (StartupInitializationExtensions), MySQL-safe; see that file + DbContext note.
|
||||||
|
modelBuilder.Entity<CvVariant>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||||
|
modelBuilder.Entity<CvVariant>().Property(x => x.PublicSlug).HasMaxLength(64);
|
||||||
|
modelBuilder.Entity<CvVariantVersion>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||||
modelBuilder.Entity<CvVariant>()
|
modelBuilder.Entity<CvVariant>()
|
||||||
.HasIndex(x => x.PublicSlug)
|
.HasIndex(x => x.PublicSlug)
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
@@ -324,6 +330,9 @@ namespace JobTrackerApi.Data
|
|||||||
// docs/architecture/ai-career-assistant.md.
|
// docs/architecture/ai-career-assistant.md.
|
||||||
modelBuilder.Entity<AiInteraction>()
|
modelBuilder.Entity<AiInteraction>()
|
||||||
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||||
|
// varchar (not longtext) for the indexed columns — see the CvVariant note above.
|
||||||
|
modelBuilder.Entity<AiInteraction>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||||
|
modelBuilder.Entity<AiInteraction>().Property(x => x.Module).HasMaxLength(64);
|
||||||
modelBuilder.Entity<AiInteraction>()
|
modelBuilder.Entity<AiInteraction>()
|
||||||
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Module, x.CreatedAtUtc });
|
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Module, x.CreatedAtUtc });
|
||||||
modelBuilder.Entity<AiInteraction>()
|
modelBuilder.Entity<AiInteraction>()
|
||||||
|
|||||||
@@ -8,90 +8,20 @@ namespace JobTrackerApi.Migrations
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddCvVariants : Migration
|
public partial class AddCvVariants : Migration
|
||||||
{
|
{
|
||||||
|
// Intentionally a no-op. CvVariants/CvVariantVersions are provisioned by the reconciler in
|
||||||
|
// StartupInitializationExtensions (idempotent, MySQL-safe: varchar not longtext so MariaDB can
|
||||||
|
// index PublicSlug/OwnerUserId). The original CreateTable + CREATE INDEX here indexed unbounded
|
||||||
|
// string columns, which succeeds on SQLite but throws on MariaDB ("BLOB/TEXT column used in key
|
||||||
|
// specification without a key length"), crashing backend startup on prod. Reconciler-owned now,
|
||||||
|
// like CareerProfiles/AiWorkspaceNotes. This migration stays recorded so the chain is intact.
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "CvVariants",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
|
||||||
.Annotation("Sqlite:Autoincrement", true),
|
|
||||||
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
PublicSlug = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
JobApplicationId = table.Column<int>(type: "INTEGER", nullable: true),
|
|
||||||
SettingsJson = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
IsPublic = table.Column<bool>(type: "INTEGER", nullable: false),
|
|
||||||
Version = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
|
||||||
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_CvVariants", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_CvVariants_JobApplications_JobApplicationId",
|
|
||||||
column: x => x.JobApplicationId,
|
|
||||||
principalTable: "JobApplications",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.SetNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "CvVariantVersions",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
|
||||||
.Annotation("Sqlite:Autoincrement", true),
|
|
||||||
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
CvVariantId = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
Version = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
SettingsJson = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
Source = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_CvVariantVersions", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_CvVariantVersions_CvVariants_CvVariantId",
|
|
||||||
column: x => x.CvVariantId,
|
|
||||||
principalTable: "CvVariants",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_CvVariants_JobApplicationId",
|
|
||||||
table: "CvVariants",
|
|
||||||
column: "JobApplicationId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_CvVariants_OwnerUserId_UpdatedAtUtc",
|
|
||||||
table: "CvVariants",
|
|
||||||
columns: new[] { "OwnerUserId", "UpdatedAtUtc" });
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_CvVariants_PublicSlug",
|
|
||||||
table: "CvVariants",
|
|
||||||
column: "PublicSlug",
|
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_CvVariantVersions_CvVariantId_Version",
|
|
||||||
table: "CvVariantVersions",
|
|
||||||
columns: new[] { "CvVariantId", "Version" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "CvVariantVersions");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "CvVariants");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,51 +8,18 @@ namespace JobTrackerApi.Migrations
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddAiInteractions : Migration
|
public partial class AddAiInteractions : Migration
|
||||||
{
|
{
|
||||||
|
// Intentionally a no-op — AiInteractions is provisioned by the reconciler in
|
||||||
|
// StartupInitializationExtensions (idempotent, MySQL-safe: Module/OwnerUserId are varchar so
|
||||||
|
// MariaDB can index them). The original CREATE INDEX here indexed unbounded string columns,
|
||||||
|
// which crashes backend startup on MariaDB. See the AddCvVariants migration for the full note.
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AiInteractions",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
|
||||||
.Annotation("Sqlite:Autoincrement", true),
|
|
||||||
OwnerUserId = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
JobApplicationId = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
Module = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
Mode = table.Column<string>(type: "TEXT", nullable: true),
|
|
||||||
Title = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
Provider = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
ResultJson = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AiInteractions", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AiInteractions_JobApplications_JobApplicationId",
|
|
||||||
column: x => x.JobApplicationId,
|
|
||||||
principalTable: "JobApplications",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_AiInteractions_JobApplicationId",
|
|
||||||
table: "AiInteractions",
|
|
||||||
column: "JobApplicationId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_AiInteractions_OwnerUserId_JobApplicationId_Module_CreatedAtUtc",
|
|
||||||
table: "AiInteractions",
|
|
||||||
columns: new[] { "OwnerUserId", "JobApplicationId", "Module", "CreatedAtUtc" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AiInteractions");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,45 @@ public static class StartupInitializationExtensions
|
|||||||
return cmd.ExecuteScalar() is not null;
|
return cmd.ExecuteScalar() is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 3072-byte key limit, which is what crashed backend startup on prod. Detect that shape by
|
||||||
|
// probing one column's data type and rebuild the table from the correct MySQL DDL.
|
||||||
|
//
|
||||||
|
// Guarded on row count: an empty malformed table is dropped and recreated; a table with ANY rows
|
||||||
|
// is left untouched (never destroy user data -- surface it instead of silently deleting).
|
||||||
|
private static bool DropMalformedMySqlTable(DbConnection c, string table, string probeColumn, string expectedDataType)
|
||||||
|
{
|
||||||
|
if (!HasMySqlTable(c, table)) return false;
|
||||||
|
|
||||||
|
string? actual;
|
||||||
|
using (var probe = c.CreateCommand())
|
||||||
|
{
|
||||||
|
probe.CommandText = "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
|
||||||
|
var s = probe.CreateParameter(); s.ParameterName = "@schema"; s.Value = c.Database; probe.Parameters.Add(s);
|
||||||
|
var t = probe.CreateParameter(); t.ParameterName = "@table"; t.Value = table; probe.Parameters.Add(t);
|
||||||
|
var col = probe.CreateParameter(); col.ParameterName = "@column"; col.Value = probeColumn; probe.Parameters.Add(col);
|
||||||
|
actual = probe.ExecuteScalar() as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown column or already the right type: nothing to repair.
|
||||||
|
if (string.IsNullOrEmpty(actual) || string.Equals(actual, expectedDataType, StringComparison.OrdinalIgnoreCase)) return false;
|
||||||
|
|
||||||
|
long rows;
|
||||||
|
using (var count = c.CreateCommand())
|
||||||
|
{
|
||||||
|
count.CommandText = $"SELECT COUNT(*) FROM `{table}`;";
|
||||||
|
rows = Convert.ToInt64(count.ExecuteScalar() ?? 0L);
|
||||||
|
}
|
||||||
|
if (rows > 0) return false;
|
||||||
|
|
||||||
|
using var drop = c.CreateCommand();
|
||||||
|
drop.CommandText = $"DROP TABLE `{table}`;";
|
||||||
|
drop.ExecuteNonQuery();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool MySqlColumnExists(DbConnection c, string table, string column)
|
private static bool MySqlColumnExists(DbConnection c, string table, string column)
|
||||||
{
|
{
|
||||||
using var cmd = c.CreateCommand();
|
using var cmd = c.CreateCommand();
|
||||||
@@ -760,6 +799,65 @@ public static class StartupInitializationExtensions
|
|||||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType" ON "AiWorkspaceNotes" ("OwnerUserId", "JobApplicationId", "NoteType");""");
|
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType" ON "AiWorkspaceNotes" ("OwnerUserId", "JobApplicationId", "NoteType");""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 4 CV Builder: a variant is a lens over the master profile; versions are its
|
||||||
|
// autosave history. Reconciler-owned (not migration-owned) so the schema is MySQL-safe
|
||||||
|
// on prod -- see the AddCvVariants migration note. docs/architecture/cv-builder.md.
|
||||||
|
static void EnsureCvBuilderTables(DbConnection c)
|
||||||
|
{
|
||||||
|
Exec(c, """
|
||||||
|
CREATE TABLE IF NOT EXISTS "CvVariants" (
|
||||||
|
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvVariants" PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"OwnerUserId" TEXT NOT NULL,
|
||||||
|
"PublicSlug" TEXT NOT NULL,
|
||||||
|
"Name" TEXT NOT NULL,
|
||||||
|
"JobApplicationId" INTEGER NULL,
|
||||||
|
"SettingsJson" TEXT NOT NULL,
|
||||||
|
"IsPublic" INTEGER NOT NULL,
|
||||||
|
"Version" INTEGER NOT NULL,
|
||||||
|
"CreatedAtUtc" TEXT NOT NULL,
|
||||||
|
"UpdatedAtUtc" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_CvVariants_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
Exec(c, """
|
||||||
|
CREATE TABLE IF NOT EXISTS "CvVariantVersions" (
|
||||||
|
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvVariantVersions" PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"OwnerUserId" TEXT NOT NULL,
|
||||||
|
"CvVariantId" INTEGER NOT NULL,
|
||||||
|
"Version" INTEGER NOT NULL,
|
||||||
|
"SettingsJson" TEXT NOT NULL,
|
||||||
|
"Source" TEXT NOT NULL,
|
||||||
|
"CreatedAtUtc" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_CvVariantVersions_CvVariants_CvVariantId" FOREIGN KEY ("CvVariantId") REFERENCES "CvVariants" ("Id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvVariants_JobApplicationId" ON "CvVariants" ("JobApplicationId");""");
|
||||||
|
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvVariants_OwnerUserId_UpdatedAtUtc" ON "CvVariants" ("OwnerUserId", "UpdatedAtUtc");""");
|
||||||
|
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CvVariants_PublicSlug" ON "CvVariants" ("PublicSlug");""");
|
||||||
|
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvVariantVersions_CvVariantId_Version" ON "CvVariantVersions" ("CvVariantId", "Version");""");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5 AI Workspace: append-only AI interaction history per job application.
|
||||||
|
static void EnsureAiInteractionsTable(DbConnection c)
|
||||||
|
{
|
||||||
|
Exec(c, """
|
||||||
|
CREATE TABLE IF NOT EXISTS "AiInteractions" (
|
||||||
|
"Id" INTEGER NOT NULL CONSTRAINT "PK_AiInteractions" PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"OwnerUserId" TEXT NOT NULL,
|
||||||
|
"JobApplicationId" INTEGER NOT NULL,
|
||||||
|
"Module" TEXT NOT NULL,
|
||||||
|
"Mode" TEXT NULL,
|
||||||
|
"Title" TEXT NOT NULL,
|
||||||
|
"Provider" TEXT NOT NULL,
|
||||||
|
"ResultJson" TEXT NOT NULL,
|
||||||
|
"CreatedAtUtc" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_AiInteractions_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AiInteractions_JobApplicationId" ON "AiInteractions" ("JobApplicationId");""");
|
||||||
|
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AiInteractions_Owner_Job_Module_Created" ON "AiInteractions" ("OwnerUserId", "JobApplicationId", "Module", "CreatedAtUtc");""");
|
||||||
|
}
|
||||||
|
|
||||||
EnsureGmailConnectionsTable(conn);
|
EnsureGmailConnectionsTable(conn);
|
||||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||||
EnsureImapConnectionsTable(conn);
|
EnsureImapConnectionsTable(conn);
|
||||||
@@ -770,6 +868,8 @@ public static class StartupInitializationExtensions
|
|||||||
EnsureCareerProfileTables(conn);
|
EnsureCareerProfileTables(conn);
|
||||||
EnsureInterviewPrepNotesTable(conn);
|
EnsureInterviewPrepNotesTable(conn);
|
||||||
EnsureAiWorkspaceNotesTable(conn);
|
EnsureAiWorkspaceNotesTable(conn);
|
||||||
|
EnsureCvBuilderTables(conn);
|
||||||
|
EnsureAiInteractionsTable(conn);
|
||||||
|
|
||||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||||
// and at least one of the new columns already exists.
|
// and at least one of the new columns already exists.
|
||||||
@@ -1241,6 +1341,93 @@ public static class StartupInitializationExtensions
|
|||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 4 CV Builder + Phase 5 AI Workspace. Reconciler-owned rather than
|
||||||
|
// migration-owned: those migrations were scaffolded against SQLite, so on MariaDB
|
||||||
|
// they emit TEXT datetimes and a PK with no AUTO_INCREMENT, and the composite
|
||||||
|
// index over a TEXT column then exceeds MySQL's 3072-byte key limit -- which
|
||||||
|
// crashed backend startup on prod. Drop any such half-built table (only when it
|
||||||
|
// holds no rows) and rebuild from the correct MySQL DDL below.
|
||||||
|
// Children first: CvVariantVersions FKs into CvVariants.
|
||||||
|
DropMalformedMySqlTable(conn, "CvVariantVersions", "CreatedAtUtc", "datetime");
|
||||||
|
DropMalformedMySqlTable(conn, "CvVariants", "UpdatedAtUtc", "datetime");
|
||||||
|
DropMalformedMySqlTable(conn, "AiInteractions", "CreatedAtUtc", "datetime");
|
||||||
|
|
||||||
|
if (!HasMySqlTable(conn, "CvVariants"))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVariants` (
|
||||||
|
`Id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`OwnerUserId` varchar(255) NOT NULL,
|
||||||
|
`PublicSlug` varchar(64) NOT NULL,
|
||||||
|
`Name` varchar(255) NOT NULL,
|
||||||
|
`JobApplicationId` int NULL,
|
||||||
|
`SettingsJson` longtext NOT NULL,
|
||||||
|
`IsPublic` tinyint(1) NOT NULL,
|
||||||
|
`Version` int NOT NULL,
|
||||||
|
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||||
|
`UpdatedAtUtc` datetime(6) NOT NULL,
|
||||||
|
PRIMARY KEY (`Id`),
|
||||||
|
CONSTRAINT `FK_CvVariants_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE SET NULL
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasMySqlTable(conn, "CvVariantVersions"))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVariantVersions` (
|
||||||
|
`Id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`OwnerUserId` varchar(255) NOT NULL,
|
||||||
|
`CvVariantId` int NOT NULL,
|
||||||
|
`Version` int NOT NULL,
|
||||||
|
`SettingsJson` longtext NOT NULL,
|
||||||
|
`Source` varchar(100) NOT NULL,
|
||||||
|
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||||
|
PRIMARY KEY (`Id`),
|
||||||
|
CONSTRAINT `FK_CvVariantVersions_CvVariants_CvVariantId` FOREIGN KEY (`CvVariantId`) REFERENCES `CvVariants` (`Id`) ON DELETE CASCADE
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasMySqlTable(conn, "AiInteractions"))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `AiInteractions` (
|
||||||
|
`Id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`OwnerUserId` varchar(255) NOT NULL,
|
||||||
|
`JobApplicationId` int NOT NULL,
|
||||||
|
`Module` varchar(64) NOT NULL,
|
||||||
|
`Mode` varchar(64) NULL,
|
||||||
|
`Title` varchar(255) NOT NULL,
|
||||||
|
`Provider` varchar(100) NOT NULL,
|
||||||
|
`ResultJson` longtext NOT NULL,
|
||||||
|
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||||
|
PRIMARY KEY (`Id`),
|
||||||
|
CONSTRAINT `FK_AiInteractions_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariants", "Id");
|
||||||
|
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariantVersions", "Id");
|
||||||
|
EnsureMySqlAutoIncrementPrimaryKey(conn, "AiInteractions", "Id");
|
||||||
|
|
||||||
|
foreach (var (ixTable, ixName, ixColumns, ixUnique) in new[]
|
||||||
|
{
|
||||||
|
("CvVariants", "IX_CvVariants_JobApplicationId", "`JobApplicationId`", false),
|
||||||
|
("CvVariants", "IX_CvVariants_OwnerUserId_UpdatedAtUtc", "`OwnerUserId`, `UpdatedAtUtc`", false),
|
||||||
|
("CvVariants", "IX_CvVariants_PublicSlug", "`PublicSlug`", true),
|
||||||
|
("CvVariantVersions", "IX_CvVariantVersions_CvVariantId_Version", "`CvVariantId`, `Version`", false),
|
||||||
|
("AiInteractions", "IX_AiInteractions_JobApplicationId", "`JobApplicationId`", false),
|
||||||
|
("AiInteractions", "IX_AiInteractions_Owner_Job_Module_Created", "`OwnerUserId`, `JobApplicationId`, `Module`, `CreatedAtUtc`", 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();
|
||||||
|
}
|
||||||
|
|
||||||
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
|
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
using var cmd = conn.CreateCommand();
|
||||||
|
|||||||
Reference in New Issue
Block a user