refactor(db): migrate tailored CV drafts

Move job-specific tailored documents into an additive provider-aware migration. Preserve edited content, indexes, and application cascade semantics.
This commit is contained in:
cesnimda
2026-08-30 17:03:48 +02:00
parent e28cc0ad47
commit 9922426aa5
9 changed files with 159 additions and 63 deletions
@@ -110,6 +110,7 @@ public sealed class MigrationChainTests
Assert.Contains("CREATE TABLE IF NOT EXISTS `ImapConnections`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CvUploadArtifacts`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CvExtractionRuns`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `TailoredCvDrafts`", script, StringComparison.Ordinal);
Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal);
Assert.All(
Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value),
@@ -451,6 +452,61 @@ public sealed class MigrationChainTests
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
[Fact]
public async Task Tailored_cv_adoption_preserves_document_and_cascade_relationship()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = Context(connection);
var migrator = db.GetService<IMigrator>();
await migrator.MigrateAsync("20260830125000_AdoptCvExtractionSchema");
await ExecuteAsync(connection, """
INSERT INTO Companies (Name) VALUES ('Fixture company');
INSERT INTO JobApplications
(CompanyId, JobTitle, DateApplied, SavedAt, Status, ResponseReceived, OwnerUserId,
HasResume, HasCoverLetter, HasPortfolio, HasOtherAttachment, IsDeleted)
VALUES
(1, 'Fixture role', '2026-08-01T09:00:00', '2026-07-20T09:00:00',
'Applied', 0, 'owner-fixture', 0, 0, 0, 0, 0);
CREATE TABLE "TailoredCvDrafts" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredCvDrafts" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL, "JobApplicationId" INTEGER NOT NULL,
"CanonicalProfileVersion" INTEGER NULL, "TemplateId" TEXT NOT NULL,
"Headline" TEXT NULL, "SummaryJson" TEXT NULL, "SelectedSkillsJson" TEXT NULL,
"ExperienceJson" TEXT NULL, "EducationJson" TEXT NULL, "CustomSectionsJson" TEXT NULL,
"RenderOptionsJson" TEXT NULL, "GenerationContextHash" TEXT NULL,
"LastGeneratedAtUtc" TEXT NULL, "LastEditedAtUtc" TEXT NULL, "Status" TEXT NOT NULL,
CONSTRAINT "FK_TailoredCvDrafts_JobApplications_JobApplicationId"
FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
);
INSERT INTO "TailoredCvDrafts"
("OwnerUserId", "JobApplicationId", "CanonicalProfileVersion", "TemplateId",
"SummaryJson", "CustomSectionsJson", "GenerationContextHash", "Status")
VALUES
('owner-fixture', 1, 7, 'code', '{"text":"preserve me"}', '[{"title":"Awards"}]',
'context-fixture', 'edited');
""");
await migrator.MigrateAsync();
Assert.Equal("{\"text\":\"preserve me\"}", await ScalarAsync<string>(connection,
"SELECT SummaryJson FROM TailoredCvDrafts WHERE JobApplicationId = 1;"));
await migrator.MigrateAsync("20260830125000_AdoptCvExtractionSchema");
Assert.Equal("context-fixture", await ScalarAsync<string>(connection,
"SELECT GenerationContextHash FROM TailoredCvDrafts WHERE JobApplicationId = 1;"));
await migrator.MigrateAsync();
Assert.Equal(2L, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM sqlite_master
WHERE type = 'index' AND name IN (
'IX_TailoredCvDrafts_OwnerUserId_JobApplicationId',
'IX_TailoredCvDrafts_JobApplicationId');
"""));
await ExecuteAsync(connection, "DELETE FROM JobApplications WHERE Id = 1;");
Assert.Equal(0L, await ScalarAsync<long>(connection, "SELECT COUNT(*) FROM TailoredCvDrafts;"));
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
private static JobTrackerContext Context(SqliteConnection connection)
{
var currentUser = new Mock<ICurrentUserService>();
@@ -0,0 +1,84 @@
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations;
/// <summary>
/// Moves job-specific tailored CV drafts into migration ownership without replacing existing
/// generated or manually edited document content.
/// </summary>
[DbContext(typeof(JobTrackerContext))]
[Migration("20260830126000_AdoptTailoredCvDraftSchema")]
public sealed class AdoptTailoredCvDraftSchema : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase))
{
migrationBuilder.Sql("""
CREATE TABLE IF NOT EXISTS `TailoredCvDrafts` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`JobApplicationId` int NOT NULL,
`CanonicalProfileVersion` int NULL,
`TemplateId` varchar(100) NOT NULL,
`Headline` longtext NULL,
`SummaryJson` longtext NULL,
`SelectedSkillsJson` longtext NULL,
`ExperienceJson` longtext NULL,
`EducationJson` longtext NULL,
`CustomSectionsJson` longtext NULL,
`RenderOptionsJson` longtext NULL,
`GenerationContextHash` longtext NULL,
`LastGeneratedAtUtc` datetime(6) NULL,
`LastEditedAtUtc` datetime(6) NULL,
`Status` varchar(100) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId`
FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
CREATE UNIQUE INDEX IF NOT EXISTS `IX_TailoredCvDrafts_OwnerUserId_JobApplicationId`
ON `TailoredCvDrafts` (`OwnerUserId`(191), `JobApplicationId`);
CREATE INDEX IF NOT EXISTS `IX_TailoredCvDrafts_JobApplicationId`
ON `TailoredCvDrafts` (`JobApplicationId`);
""");
return;
}
migrationBuilder.Sql("""
CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredCvDrafts" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"JobApplicationId" INTEGER NOT NULL,
"CanonicalProfileVersion" INTEGER NULL,
"TemplateId" TEXT NOT NULL,
"Headline" TEXT NULL,
"SummaryJson" TEXT NULL,
"SelectedSkillsJson" TEXT NULL,
"ExperienceJson" TEXT NULL,
"EducationJson" TEXT NULL,
"CustomSectionsJson" TEXT NULL,
"RenderOptionsJson" TEXT NULL,
"GenerationContextHash" TEXT NULL,
"LastGeneratedAtUtc" TEXT NULL,
"LastEditedAtUtc" TEXT NULL,
"Status" TEXT NOT NULL,
CONSTRAINT "FK_TailoredCvDrafts_JobApplications_JobApplicationId"
FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"
ON "TailoredCvDrafts" ("OwnerUserId", "JobApplicationId");
CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId"
ON "TailoredCvDrafts" ("JobApplicationId");
""");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Preserve tailored document content that may pre-date migration ownership.
}
}
@@ -546,34 +546,6 @@ public static class StartupInitializationExtensions
EnsureColumn(c, "GmailConnections", "LastSyncError", "ALTER TABLE GmailConnections ADD COLUMN LastSyncError TEXT NULL;");
}
static void EnsureCvTables(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredCvDrafts" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"JobApplicationId" INTEGER NOT NULL,
"CanonicalProfileVersion" INTEGER NULL,
"TemplateId" TEXT NOT NULL,
"Headline" TEXT NULL,
"SummaryJson" TEXT NULL,
"SelectedSkillsJson" TEXT NULL,
"ExperienceJson" TEXT NULL,
"EducationJson" TEXT NULL,
"CustomSectionsJson" TEXT NULL,
"RenderOptionsJson" TEXT NULL,
"GenerationContextHash" TEXT NULL,
"LastGeneratedAtUtc" TEXT NULL,
"LastEditedAtUtc" TEXT NULL,
"Status" TEXT NOT NULL,
CONSTRAINT "FK_TailoredCvDrafts_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId" ON "TailoredCvDrafts" ("OwnerUserId", "JobApplicationId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
}
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
// Phase F1). Additive tables: ApplicationUser.ProfileCvStructureJson remains the
// authoritative column every existing read path uses; these mirror it so future
@@ -912,7 +884,6 @@ public static class StartupInitializationExtensions
}
ReconcileGmailConnectionColumns(conn);
EnsureCvTables(conn);
EnsureCareerProfileTables(conn);
EnsureInterviewPrepNotesTable(conn);
EnsureAiWorkspaceNotesTable(conn);
@@ -1261,32 +1232,6 @@ public static class StartupInitializationExtensions
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncStatus", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncStatus` varchar(255) NULL;");
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncError", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncError` longtext NULL;");
if (!HasMySqlTable(conn, "TailoredCvDrafts") && HasMySqlTable(conn, "JobApplications"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredCvDrafts` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`JobApplicationId` int NOT NULL,
`CanonicalProfileVersion` int NULL,
`TemplateId` varchar(100) NOT NULL,
`Headline` longtext NULL,
`SummaryJson` longtext NULL,
`SelectedSkillsJson` longtext NULL,
`ExperienceJson` longtext NULL,
`EducationJson` longtext NULL,
`CustomSectionsJson` longtext NULL,
`RenderOptionsJson` longtext NULL,
`GenerationContextHash` longtext NULL,
`LastGeneratedAtUtc` datetime(6) NULL,
`LastEditedAtUtc` datetime(6) NULL,
`Status` varchar(100) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
);";
cmd.ExecuteNonQuery();
}
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
// Phase F1). Additive: AspNetUsers.ProfileCvStructureJson stays authoritative
// for every existing read path during the dual-write window.
@@ -28,6 +28,7 @@ internal static class StartupSchemaOwnership
"MicrosoftGraphConnections",
"RuleSettings",
"SystemEmailSettings",
"TailoredCvDrafts",
"TrustedDevices",
"TwoFactorRecoveryCodes",
"UserNotifications",
@@ -61,7 +62,6 @@ internal static class StartupSchemaOwnership
"CvVariantVersions",
"InterviewPrepItems",
"InterviewPrepNotes",
"TailoredCvDrafts",
};
// Historical migrations contain guarded compatibility bootstraps for these tables so direct
+1 -1
View File
@@ -558,7 +558,7 @@ SEC-008 implements the same durable state machine with `<final>.uploading` and `
### P3-1 — Reduce dual schema ownership incrementally
**Status (2026-08-30): in progress.** All 49 model tables now have an executable, disjoint creation-owner classification. Eleven formerly reconciler-created tables—including authentication support, email-provider connections, and CV extraction persistence—have moved through additive provider-aware migrations with legacy-row preservation, downgrade/retry safety, startup-DDL removal, fresh SQLite runtime proof and generated MariaDB SQL. The Gmail decision migration also closes its missing MariaDB creation path. Twenty-four startup-created tables remain; see `docs/verification/jt-019-schema-ownership.md` and V-194V-199.
**Status (2026-08-30): in progress.** All 49 model tables now have an executable, disjoint creation-owner classification. Twelve formerly reconciler-created tables—including authentication support, email-provider connections, CV extraction persistence, and tailored CV drafts—have moved through additive provider-aware migrations with legacy-row preservation, downgrade/retry safety, startup-DDL removal, fresh SQLite runtime proof and generated MariaDB SQL. The Gmail decision migration also closes its missing MariaDB creation path. Twenty-three startup-created tables remain; see `docs/verification/jt-019-schema-ownership.md` and V-194V-200.
- **Findings/scope:** JT-019; inventory reconciler operations and move stable schema ownership to EF migrations in small steps.
- **Dependencies:** provider upgrade fixtures and P2-2 restore safety.
+1
View File
@@ -231,3 +231,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-197 | Ownership/migration chain; populated adoption/downgrade/retry; index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move the authentication-support group to migration ownership without invalidating recovery codes, trusted devices, or active sessions | PASS — focused ownership/migration 9/9; representative rows survive adoption, downgrade and re-upgrade; expected indexes exist; generated MariaDB SQL contains all three provider-safe definitions; both startup-create paths are removed; full backend 725/725 | MariaDB SQL generated only; no provider account or production migration. Guarded MariaDB index/auto-increment repairs remain temporarily. Twenty-nine reconciler-owned tables remain | JT-019 authentication-support transfer complete; dependent feature groups continue incrementally |
| V-198 | Ownership/migration chain; populated adoption/downgrade/retry; encrypted-value and index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move Gmail, Microsoft Graph, and IMAP connection records to migration ownership without losing credentials or sync state | PASS — focused ownership/migration 10/10; representative encrypted values survive adoption, downgrade and re-upgrade; all five expected indexes exist; generated MariaDB SQL contains all three provider-safe definitions; both startup-create paths are removed; full backend 726/726 | MariaDB SQL generated only; no provider account or production migration. Gmail column repair and guarded MariaDB index/auto-increment repairs remain temporarily. Twenty-six reconciler-owned tables remain | JT-019 email-provider transfer complete; dependent CV/career groups continue incrementally |
| V-199 | Ownership/migration chain; populated adoption/downgrade/retry; FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move CV upload artifacts and extraction runs to migration ownership without losing import history | PASS — focused ownership/migration 11/11; artifact hash and structured result survive adoption, downgrade and re-upgrade; all three indexes exist; artifact deletion sets the run link null; generated MariaDB SQL contains provider-safe parent/child DDL; startup creates neither table; full backend 727/727 | MariaDB SQL generated only; no provider account or production migration. Guarded MariaDB index/auto-increment repairs remain temporarily. Twenty-four reconciler-owned tables remain | JT-019 CV extraction persistence transfer complete; tailored CV and career groups continue incrementally |
| V-200 | Ownership/migration chain; populated adoption/downgrade/retry; FK/index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move job-specific tailored CV drafts to migration ownership without losing generated or edited content | PASS — focused ownership/migration 12/12; summary, custom-section and context data survive adoption, downgrade and re-upgrade; both indexes exist; deleting the parent application cascades to the draft; generated MariaDB SQL is provider-safe; startup no longer creates the table; full backend 728/728 | MariaDB SQL generated only; no provider account or production migration. Guarded MariaDB index/auto-increment repairs remain temporarily. Twenty-three reconciler-owned tables remain | JT-019 tailored CV transfer complete; career-profile aggregate remains a dependency-aware future batch |
+8 -3
View File
@@ -69,8 +69,9 @@ Created by EF migrations, never by the reconciler:
`AccountDeletionFiles`, `AccountDeletionRequests`, `AiUsageRecords`, `Attachments`, `Companies`,
`Correspondences`, `CvExtractionRuns`, `CvUploadArtifacts`, `EmailDrafts`, `EmailSendAttempts`, `GmailConnections`,
`GmailReviewDecisions`, `ImapConnections`, `JobApplications`, `JobEvents`, `Jobs`,
`MicrosoftGraphConnections`, `RuleSettings`, `SystemEmailSettings`, `TrustedDevices`,
`TwoFactorRecoveryCodes`, `UserNotifications`, `UserOperations`, `UserRuleSettings`, and `UserSessions`.
`MicrosoftGraphConnections`, `RuleSettings`, `SystemEmailSettings`, `TailoredCvDrafts`,
`TrustedDevices`, `TwoFactorRecoveryCodes`, `UserNotifications`, `UserOperations`, `UserRuleSettings`,
and `UserSessions`.
`SystemEmailSettings` is the first completed ownership transfer: migration
`20260830120000_AdoptSystemEmailSettingsSchema` creates it for both supported providers and preserves
@@ -96,6 +97,10 @@ The CV import persistence group (`CvUploadArtifacts` and `CvExtractionRuns`) mov
`20260830125000_AdoptCvExtractionSchema`. The extraction run's optional artifact foreign key remains
`ON DELETE SET NULL`, so retention cleanup cannot erase the extraction/review record.
`TailoredCvDrafts` moved in `20260830126000_AdoptTailoredCvDraftSchema`; its generated and edited
content is retained during adoption while the one-to-one job-application relationship remains
`ON DELETE CASCADE`.
The reconciler may **repair** these (add a missing column, add an index, fix a non-`AUTO_INCREMENT`
primary key) and may seed the default `RuleSettings` row — but it must never `CREATE TABLE` them.
It used to create `RuleSettings`, which is precisely why a clean install failed with
@@ -105,7 +110,7 @@ It used to create `RuleSettings`, which is precisely why a clean install failed
Created by `StartupInitializationExtensions`, with a **no-op migration** holding the model snapshot:
`TailoredCvDrafts`, `CareerProfiles`, `CareerProfileVersions`, the six CareerProfile children (`CareerExperiences`,
`CareerProfiles`, `CareerProfileVersions`, the six CareerProfile children (`CareerExperiences`,
`CareerEducations`, `CareerSkills`, `CareerProjects`, `CareerCertifications`, `CareerLanguages`),
`InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `CvVariantVersions`, `AiInteractions`,
`ApplicationChecklistItems`, `CoverLetterVersions`, and `InterviewPrepItems`.
+6 -2
View File
@@ -27,6 +27,8 @@ cohesive table groups. It does not attempt to delete the legacy reconciler whole
MariaDB index/identity repair remain for historical installations.
- Added `20260830125000_AdoptCvExtractionSchema` for upload artifacts and extraction runs, retaining
parsed output and the nullable `ON DELETE SET NULL` artifact relationship.
- Added `20260830126000_AdoptTailoredCvDraftSchema` for the job-specific tailored document, retaining
generated and manually edited content plus its `ON DELETE CASCADE` application relationship.
- Corrected the ownership runbook: new tables now default to migrations; the reconciler is legacy
compatibility code to retire one dependency group at a time.
@@ -52,14 +54,16 @@ migration.
and re-upgrade, and all five expected provider indexes are present afterwards.
- Representative CV artifact metadata and structured extraction output survive adoption, downgrade,
and re-upgrade; deleting the artifact preserves the run and clears its nullable relationship.
- A representative tailored CV's summary, custom section, and generation context survive adoption,
downgrade, and re-upgrade; deleting the parent application still deletes its draft.
- Generated MariaDB SQL contains the provider-correct `SystemEmailSettings` DDL.
- Full backend: 727/727 passed after the CV extraction persistence transfer.
- Full backend: 728/728 passed after the tailored CV draft transfer.
- Fresh application startup over a new disposable SQLite database applied
`20260830120000_AdoptSystemEmailSettingsSchema` and reached the healthy listening state.
## Remaining JT-019 work
Twenty-four model tables remain startup-created, including the Identity group and several tables
Twenty-three model tables remain startup-created, including the Identity group and several tables
with parent dependencies. Transfer them in small dependency-aware migrations with blank, populated,
retry and MariaDB runtime proof. Column/index repairs must remain until historical upgrade fixtures
prove each one redundant.
+2 -1
View File
@@ -49,6 +49,7 @@ Updated: 2026-08-30
- Moved recovery codes, trusted devices, and revocable user sessions into one provider-aware authentication-support migration; populated legacy rows and indexes survive adoption, downgrade, and retry.
- Moved Gmail, Microsoft Graph, and IMAP connection records into one provider-aware migration; encrypted credentials, sync state, uniqueness constraints, and historical repair paths are preserved.
- Moved CV upload artifacts and extraction runs into a provider-aware migration; existing hashes, structured results, indexes, and nullable artifact retention semantics are preserved.
- Moved job-specific tailored CV drafts into a provider-aware migration; generated and edited content, indexes, and application cascade semantics are preserved.
### In progress
@@ -79,7 +80,7 @@ Updated: 2026-08-30
- Focused frontend: 2 suites, 6 tests passed.
- Full frontend: 64 suites, 272 tests passed.
- Next production build and TypeScript: passed.
- Full backend: 727/727 tests passed after the CV extraction JT-019 transfer.
- Full backend: 728/728 tests passed after the tailored CV JT-019 transfer.
- Portable Playwright launcher: resolved the user-local .NET 9 SDK; backend Release build passed with 0 warnings/errors.
- Playwright: initial full run 9/10 exposed the intentional mobile Settings control change; updated focused rerun passed 1/1. A final complete browser rerun remains in the end-of-batch gate.
- Focused backend match/intelligence verification: 34/34 passed.