diff --git a/docs/circuit-list-editor-migration.md b/docs/circuit-list-editor-migration.md index bb94bc7..12fda5b 100644 --- a/docs/circuit-list-editor-migration.md +++ b/docs/circuit-list-editor-migration.md @@ -43,6 +43,11 @@ Run in this order for local database workflows: 5. Migrate legacy consumers: - `npm run db:migrate:legacy-consumers` +The migration command finishes with a cutover verification across the complete +database. It exits with an error while any legacy consumer lacks a migration +mapping, including consumers that cannot be migrated because they have no circuit +list assignment. Do not remove or disable the legacy path until this check passes. + ## Validation Checks After migration, verify: @@ -72,6 +77,6 @@ Actions: Do not delete legacy consumers yet. -- legacy endpoints/views may still depend on them +- legacy endpoints/views remain enabled only until the cutover verification passes - migration trace tables reference old/new mapping - keeping legacy rows allows comparison and rollback validation during transition diff --git a/scripts/db-migrate-legacy-consumers.ts b/scripts/db-migrate-legacy-consumers.ts index 44f0a24..cabe2a9 100644 --- a/scripts/db-migrate-legacy-consumers.ts +++ b/scripts/db-migrate-legacy-consumers.ts @@ -6,8 +6,9 @@ import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-co const projectRepository = new ProjectRepository(); const circuitListRepository = new CircuitListRepository(); +const migrationRepository = new LegacyConsumerMigrationRepository(db); const migrationService = new LegacyConsumerMigrationService( - new LegacyConsumerMigrationRepository(db) + migrationRepository ); async function run() { @@ -30,8 +31,21 @@ async function run() { } } + const unmigratedConsumers = + await migrationRepository.listUnmigratedConsumers(); + if (unmigratedConsumers.length > 0) { + const withoutCircuitList = unmigratedConsumers.filter( + (consumer) => !consumer.circuitListId + ).length; + throw new Error( + `Cutover verification failed: ${unmigratedConsumers.length} legacy consumer(s) remain unmigrated` + + ` (${withoutCircuitList} without a circuit list).` + ); + } + console.log("Legacy consumer migration summary:"); console.table(reports); + console.log("Cutover verification passed: all legacy consumers are mapped."); } run().catch((error) => { diff --git a/src/db/repositories/legacy-consumer-migration.repository.ts b/src/db/repositories/legacy-consumer-migration.repository.ts index 0be98dc..d557f3f 100644 --- a/src/db/repositories/legacy-consumer-migration.repository.ts +++ b/src/db/repositories/legacy-consumer-migration.repository.ts @@ -1,8 +1,9 @@ import crypto from "node:crypto"; -import { eq, inArray } from "drizzle-orm"; +import { eq, inArray, isNull } from "drizzle-orm"; import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuits } from "../schema/circuits.js"; +import { consumers } from "../schema/consumers.js"; import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js"; import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js"; import { @@ -36,6 +37,21 @@ export interface LegacyMigrationReportPersistenceInput { export class LegacyConsumerMigrationRepository { constructor(private readonly database: AppDatabase) {} + async listUnmigratedConsumers() { + return this.database + .select({ + id: consumers.id, + projectId: consumers.projectId, + circuitListId: consumers.circuitListId, + }) + .from(consumers) + .leftJoin( + legacyConsumerCircuitMigrations, + eq(legacyConsumerCircuitMigrations.consumerId, consumers.id) + ) + .where(isNull(legacyConsumerCircuitMigrations.consumerId)); + } + async listMigratedConsumerIds(circuitListId: string) { const rows = await this.database .select({ consumerId: legacyConsumerCircuitMigrations.consumerId }) diff --git a/tests/legacy-consumer-migration.repository.test.ts b/tests/legacy-consumer-migration.repository.test.ts index ca70217..29248e5 100644 --- a/tests/legacy-consumer-migration.repository.test.ts +++ b/tests/legacy-consumer-migration.repository.test.ts @@ -12,6 +12,7 @@ import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; +import { consumers } from "../src/db/schema/consumers.js"; import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js"; import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js"; import { projects } from "../src/db/schema/projects.js"; @@ -97,6 +98,61 @@ function assertNoMigrationWrites(context: DatabaseContext) { } describe("legacy consumer migration repository", () => { + it("reports every consumer without a completed migration mapping", async () => { + const { context, circuitListId, sectionId } = createTestDatabase(); + try { + context.db + .insert(consumers) + .values([ + { + id: "consumer-1", + projectId: "project-1", + circuitListId, + name: "Light 1", + quantity: 1, + installedPowerPerUnitKw: 0.1, + demandFactor: 1, + }, + { + id: "consumer-2", + projectId: "project-1", + circuitListId: null, + name: "Light 2", + quantity: 1, + installedPowerPerUnitKw: 0.2, + demandFactor: 1, + }, + ]) + .run(); + const repository = new LegacyConsumerMigrationRepository(context.db); + + assert.deepEqual( + (await repository.listUnmigratedConsumers()).sort((left, right) => + left.id.localeCompare(right.id) + ), + [ + { + id: "consumer-1", + projectId: "project-1", + circuitListId, + }, + { + id: "consumer-2", + projectId: "project-1", + circuitListId: null, + }, + ] + ); + + repository.persistCircuitListMigration( + migrationInput(circuitListId, sectionId) + ); + assert.deepEqual(await repository.listUnmigratedConsumers(), []); + } finally { + context.close(); + } + }); + it("commits circuits, rows, mappings and report together", () => { const { context, circuitListId, sectionId } = createTestDatabase(); try {