Add migration validation summary gate
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user