Add migration validation summary gate

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 21:35:21 -04:00
1 parent 62ac397459
commit babbf07cb2
4 files changed
+131 -3

No files matched your search

@@ -6,6 +6,7 @@ import type { CreateMigrationReportInput } from "../../shared/commands";
import { SLO_SIGNAL_KEY } from "../../shared/domain";
import { createId } from "../../shared/ids";
import { AuditService } from "./audit-service";
import { buildMigrationValidationSummary, type MigrationValidationSummary } from "./migration-validation-summary";
import { OperationsService } from "./operations-service";
const LEGACY_TABLES = [
@@ -103,6 +104,7 @@ interface MigrationReportBody {
idMaps: Record<string, string[]>;
missingTables: string[];
failures: MigrationFailure[];
validationSummary: MigrationValidationSummary;
rollbackPack: {
sourceDatabasePath: string;
capturedAt: string;
@@ -296,11 +298,20 @@ export class MigrationService {
.filter((table) => availableTables.has(table))
.map((table) => [table, tableIds(source, table)]),
);
const idMappedTables = LEGACY_TABLES.filter((table) => availableTables.has(table) && hasColumn(source, table, "id"));
const missingTables = LEGACY_TABLES.filter((table) => !availableTables.has(table));
const failures = [
...missingTables.map((table) => ({ table, reason: "missing_table", detail: "Required table is absent." })),
...validateRelations(source, availableTables),
];
const validationSummary = buildMigrationValidationSummary({
requiredTables: LEGACY_TABLES,
tableCounts,
tableChecksums,
idMaps,
idMappedTables,
failures,
});
const sourceFingerprint = fingerprintSource({ tableCounts, tableChecksums, idMaps, failures });
const existingReportId = this.findExistingReport(input.orgId, sourceDatabasePath, sourceFingerprint);
if (existingReportId) return existingReportId;
@@ -313,6 +324,7 @@ export class MigrationService {
idMaps,
missingTables,
failures,
validationSummary,
rollbackPack: {
sourceDatabasePath,
capturedAt: now,
@@ -339,9 +351,9 @@ export class MigrationService {
orgId: input.orgId,
signalKey: SLO_SIGNAL_KEY.MIGRATION_VALIDATION_SUCCESS,
source: "migration_validation",
success: failures.length === 0,
success: validationSummary.passed,
durationMs: null,
detail: { reportId, sourceFingerprint, failureCount: failures.length },
detail: { reportId, sourceFingerprint, validationSummary },
});
return reportId;
} finally {
@@ -446,7 +458,7 @@ function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
function fingerprintSource(body: Omit<MigrationReportBody, "sourceFingerprint" | "missingTables" | "rollbackPack">): string {
function fingerprintSource(body: Omit<MigrationReportBody, "sourceFingerprint" | "missingTables" | "rollbackPack" | "validationSummary">): string {
return createHash("sha256").update(JSON.stringify(body)).digest("hex");
}
@@ -0,0 +1,86 @@
export interface MigrationFailureView {
table: string;
reason: string;
detail: string;
}
export interface MigrationValidationSummary {
requiredTableCount: number;
presentTableCount: number;
missingTableCount: number;
totalSourceRows: number;
rowsWithIntegrityFailures: number;
rowCoverageBasisPoints: number;
checksumCoverageBasisPoints: number;
idMapCoverageBasisPoints: number;
relationFailureCount: number;
passed: boolean;
thresholds: {
rowCoverageBasisPoints: number;
checksumCoverageBasisPoints: number;
idMapCoverageBasisPoints: number;
};
}
const BASIS_POINTS = 10000;
const ROW_COVERAGE_TARGET = 9900;
export function buildMigrationValidationSummary(input: {
requiredTables: string[];
tableCounts: Record<string, number>;
tableChecksums: Record<string, string>;
idMaps: Record<string, string[]>;
idMappedTables: string[];
failures: MigrationFailureView[];
}): MigrationValidationSummary {
const presentTableCount = Object.keys(input.tableCounts).length;
const missingTableCount = input.requiredTables.length - presentTableCount;
const totalSourceRows = sum(Object.values(input.tableCounts));
const rowsWithIntegrityFailures = countFailedRows(input.failures);
const rowCoverageBasisPoints = ratioBasisPoints(Math.max(0, totalSourceRows - rowsWithIntegrityFailures), totalSourceRows);
const checksumCoverageBasisPoints = ratioBasisPoints(Object.keys(input.tableChecksums).length, presentTableCount);
const idMapSourceRows = sum(input.idMappedTables.map((table) => input.tableCounts[table] ?? 0));
const idMappedRows = sum(input.idMappedTables.map((table) => input.idMaps[table]?.length ?? 0));
const idMapCoverageBasisPoints = ratioBasisPoints(idMappedRows, idMapSourceRows);
const relationFailureCount = input.failures.filter((failure) => failure.reason !== "missing_table").length;
const passed = missingTableCount === 0
&& rowCoverageBasisPoints >= ROW_COVERAGE_TARGET
&& checksumCoverageBasisPoints === BASIS_POINTS
&& idMapCoverageBasisPoints === BASIS_POINTS
&& relationFailureCount === 0;
return {
requiredTableCount: input.requiredTables.length,
presentTableCount,
missingTableCount,
totalSourceRows,
rowsWithIntegrityFailures,
rowCoverageBasisPoints,
checksumCoverageBasisPoints,
idMapCoverageBasisPoints,
relationFailureCount,
passed,
thresholds: {
rowCoverageBasisPoints: ROW_COVERAGE_TARGET,
checksumCoverageBasisPoints: BASIS_POINTS,
idMapCoverageBasisPoints: BASIS_POINTS,
},
};
}
function countFailedRows(failures: MigrationFailureView[]): number {
const keys = new Set<string>();
for (const failure of failures) {
if (failure.reason === "missing_table" || failure.reason === "missing_column") continue;
keys.add(`${failure.table}:${failure.detail.split(" -> ")[0]}`);
}
return keys.size;
}
function ratioBasisPoints(numerator: number, denominator: number): number {
if (denominator === 0) return BASIS_POINTS;
return Math.floor((numerator / denominator) * BASIS_POINTS);
}
function sum(values: number[]): number {
return values.reduce((total, value) => total + value, 0);
}
@@ -445,11 +445,14 @@ describe("domain services", () => {
tableChecksums: Record<string, string>;
idMaps: Record<string, string[]>;
failures: Array<{ table: string; reason: string }>;
validationSummary: { missingTableCount: number; passed: boolean };
rollbackPack: { sourceDatabasePath: string };
};
expect(report.tableChecksums.agents).toHaveLength(64);
expect(report.idMaps.agents).toEqual(["agent-1"]);
expect(report.failures.some((failure) => failure.reason === "missing_table")).toBe(true);
expect(report.validationSummary.missingTableCount).toBeGreaterThan(0);
expect(report.validationSummary.passed).toBe(false);
expect(report.rollbackPack.sourceDatabasePath).toBe(legacyPath);
connection.close();
});
@@ -35,6 +35,16 @@ interface MigrationReportBody {
tableChecksums: Record<string, string>;
idMaps: Record<string, string[]>;
failures: Array<{ table: string; reason: string; detail: string }>;
validationSummary: {
missingTableCount: number;
totalSourceRows: number;
rowsWithIntegrityFailures: number;
rowCoverageBasisPoints: number;
checksumCoverageBasisPoints: number;
idMapCoverageBasisPoints: number;
relationFailureCount: number;
passed: boolean;
};
rollbackPack: {
sourceDatabasePath: string;
sourceFingerprint: string;
@@ -82,6 +92,15 @@ describe("migration service", () => {
expect(body.rollbackPack.idMaps.tasks).toEqual(["task-1"]);
expect(body.failures).toContainEqual({ table: "agents", reason: "dangling_org_id", detail: "agent-1 -> org-missing" });
expect(body.failures).toContainEqual({ table: "tasks", reason: "dangling_assignee_agent_id", detail: "task-1 -> agent-missing" });
expect(body.validationSummary).toMatchObject({
totalSourceRows: 4,
rowsWithIntegrityFailures: 2,
rowCoverageBasisPoints: 5000,
checksumCoverageBasisPoints: 10000,
idMapCoverageBasisPoints: 10000,
relationFailureCount: 3,
passed: false,
});
connection.close();
});
@@ -243,6 +262,14 @@ describe("migration service", () => {
const body = JSON.parse(snapshot.load().migrationReports[0]!.reportJson) as MigrationReportBody;
expect(body.failures).toEqual([]);
expect(body.validationSummary).toMatchObject({
missingTableCount: 0,
rowCoverageBasisPoints: 10000,
checksumCoverageBasisPoints: 10000,
idMapCoverageBasisPoints: 10000,
relationFailureCount: 0,
passed: true,
});
expect(body.tableCounts.credentials).toBe(1);
expect(body.tableCounts.employee_identities).toBe(1);
expect(body.tableCounts.org_departments).toBe(1);