fix(db): provision CV builder + AI workspace tables via the MySQL-safe reconciler
CI and Deploy / test (push) Successful in 2m19s
CI and Deploy / deploy (push) Failing after 7s

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:
cesnimda
2026-07-18 20:41:29 +02:00
parent 7a74311a71
commit 1430313a20
4 changed files with 206 additions and 113 deletions
+9
View File
@@ -298,6 +298,12 @@ namespace JobTrackerApi.Data
// owns career data. PublicSlug is globally unique so /cv/{slug} can resolve it anonymously.
modelBuilder.Entity<CvVariant>()
.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>()
.HasIndex(x => x.PublicSlug)
.IsUnique();
@@ -324,6 +330,9 @@ namespace JobTrackerApi.Data
// docs/architecture/ai-career-assistant.md.
modelBuilder.Entity<AiInteraction>()
.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>()
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Module, x.CreatedAtUtc });
modelBuilder.Entity<AiInteraction>()
@@ -8,90 +8,20 @@ namespace JobTrackerApi.Migrations
/// <inheritdoc />
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 />
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 />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CvVariantVersions");
migrationBuilder.DropTable(
name: "CvVariants");
}
}
}
@@ -8,51 +8,18 @@ namespace JobTrackerApi.Migrations
/// <inheritdoc />
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 />
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 />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AiInteractions");
}
}
}
@@ -104,6 +104,45 @@ public static class StartupInitializationExtensions
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)
{
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");""");
}
// 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);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn);
@@ -770,6 +868,8 @@ public static class StartupInitializationExtensions
EnsureCareerProfileTables(conn);
EnsureInterviewPrepNotesTable(conn);
EnsureAiWorkspaceNotesTable(conn);
EnsureCvBuilderTables(conn);
EnsureAiInteractionsTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
@@ -1241,6 +1341,93 @@ public static class StartupInitializationExtensions
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"))
{
using var cmd = conn.CreateCommand();