From 1430313a20bd69206b46437291ffc3f29333e994 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 18 Jul 2026 20:41:29 +0200 Subject: [PATCH] fix(db): provision CV builder + AI workspace tables via the MySQL-safe reconciler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Data/JobTrackerContext.cs | 9 + .../20260718074509_AddCvVariants.cs | 82 +------- .../20260718131138_AddAiInteractions.cs | 41 +--- .../StartupInitializationExtensions.cs | 187 ++++++++++++++++++ 4 files changed, 206 insertions(+), 113 deletions(-) diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 20f755b..9c1830b 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -298,6 +298,12 @@ namespace JobTrackerApi.Data // owns career data. PublicSlug is globally unique so /cv/{slug} can resolve it anonymously. modelBuilder.Entity() .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().Property(x => x.OwnerUserId).HasMaxLength(255); + modelBuilder.Entity().Property(x => x.PublicSlug).HasMaxLength(64); + modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); modelBuilder.Entity() .HasIndex(x => x.PublicSlug) .IsUnique(); @@ -324,6 +330,9 @@ namespace JobTrackerApi.Data // docs/architecture/ai-career-assistant.md. modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + // varchar (not longtext) for the indexed columns — see the CvVariant note above. + modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); + modelBuilder.Entity().Property(x => x.Module).HasMaxLength(64); modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Module, x.CreatedAtUtc }); modelBuilder.Entity() diff --git a/JobTrackerApi/Migrations/20260718074509_AddCvVariants.cs b/JobTrackerApi/Migrations/20260718074509_AddCvVariants.cs index fa8f832..b9940bc 100644 --- a/JobTrackerApi/Migrations/20260718074509_AddCvVariants.cs +++ b/JobTrackerApi/Migrations/20260718074509_AddCvVariants.cs @@ -8,90 +8,20 @@ namespace JobTrackerApi.Migrations /// 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. /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.CreateTable( - name: "CvVariants", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - OwnerUserId = table.Column(type: "TEXT", nullable: false), - PublicSlug = table.Column(type: "TEXT", nullable: false), - Name = table.Column(type: "TEXT", nullable: false), - JobApplicationId = table.Column(type: "INTEGER", nullable: true), - SettingsJson = table.Column(type: "TEXT", nullable: false), - IsPublic = table.Column(type: "INTEGER", nullable: false), - Version = table.Column(type: "INTEGER", nullable: false), - CreatedAtUtc = table.Column(type: "TEXT", nullable: false), - UpdatedAtUtc = table.Column(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(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - OwnerUserId = table.Column(type: "TEXT", nullable: false), - CvVariantId = table.Column(type: "INTEGER", nullable: false), - Version = table.Column(type: "INTEGER", nullable: false), - SettingsJson = table.Column(type: "TEXT", nullable: false), - Source = table.Column(type: "TEXT", nullable: false), - CreatedAtUtc = table.Column(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" }); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable( - name: "CvVariantVersions"); - - migrationBuilder.DropTable( - name: "CvVariants"); } } } diff --git a/JobTrackerApi/Migrations/20260718131138_AddAiInteractions.cs b/JobTrackerApi/Migrations/20260718131138_AddAiInteractions.cs index dced402..da12075 100644 --- a/JobTrackerApi/Migrations/20260718131138_AddAiInteractions.cs +++ b/JobTrackerApi/Migrations/20260718131138_AddAiInteractions.cs @@ -8,51 +8,18 @@ namespace JobTrackerApi.Migrations /// 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. /// protected override void Up(MigrationBuilder migrationBuilder) { - migrationBuilder.CreateTable( - name: "AiInteractions", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - OwnerUserId = table.Column(type: "TEXT", nullable: false), - JobApplicationId = table.Column(type: "INTEGER", nullable: false), - Module = table.Column(type: "TEXT", nullable: false), - Mode = table.Column(type: "TEXT", nullable: true), - Title = table.Column(type: "TEXT", nullable: false), - Provider = table.Column(type: "TEXT", nullable: false), - ResultJson = table.Column(type: "TEXT", nullable: false), - CreatedAtUtc = table.Column(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" }); } /// protected override void Down(MigrationBuilder migrationBuilder) { - migrationBuilder.DropTable( - name: "AiInteractions"); } } } diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 22b4bf3..7b7e609 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -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();