Make legacy migration atomic

This commit is contained in:
2026-07-23 17:55:04 +02:00
parent ff84d85eb3
commit c7e5f7a80d
6 changed files with 328 additions and 83 deletions
@@ -1,15 +1,12 @@
import crypto from "node:crypto";
import { and, eq } from "drizzle-orm";
import { db } from "../../db/client.js";
import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import { ConsumerRepository } from "../../db/repositories/consumer.repository.js";
import {
LegacyConsumerMigrationRepository,
type LegacyMigrationCircuitPersistenceInput,
} from "../../db/repositories/legacy-consumer-migration.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js";
import { circuitDeviceRows } from "../../db/schema/circuit-device-rows.js";
import { legacyConsumerCircuitMigrations } from "../../db/schema/legacy-consumer-circuit-migrations.js";
import { legacyConsumerMigrationReports } from "../../db/schema/legacy-consumer-migration-report.js";
import type { LegacyMigrationReport } from "../models/circuit-tree.model.js";
import {
inferSectionKeyFromEquipmentIdentifier,
@@ -35,9 +32,9 @@ export class LegacyConsumerMigrationService {
private readonly circuitListRepository = new CircuitListRepository();
private readonly sectionRepository = new CircuitSectionRepository();
private readonly circuitRepository = new CircuitRepository();
private readonly circuitDeviceRowRepository = new CircuitDeviceRowRepository();
private readonly consumerRepository = new ConsumerRepository();
private readonly roomRepository = new RoomRepository();
private readonly migrationRepository = new LegacyConsumerMigrationRepository();
async migrateCircuitList(projectId: string, circuitListId: string): Promise<LegacyMigrationReport> {
// Migration is additive: legacy consumers are preserved, and circuit-first entities are created
@@ -76,11 +73,9 @@ export class LegacyConsumerMigrationService {
};
// Idempotency guard: skip consumers already mapped in previous migration run.
const migratedRows = await db
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
.from(legacyConsumerCircuitMigrations)
.where(eq(legacyConsumerCircuitMigrations.circuitListId, circuitListId));
const migratedConsumerIds = new Set(migratedRows.map((row) => row.consumerId));
const migratedConsumerIds = new Set(
await this.migrationRepository.listMigratedConsumerIds(circuitListId)
);
const consumersToMigrate = legacyConsumers.filter((consumer) => !migratedConsumerIds.has(consumer.id));
// Legacy rows are grouped by normalized circuit number so duplicates become
@@ -149,6 +144,7 @@ export class LegacyConsumerMigrationService {
maxBySectionPrefix.set(section.prefix.toUpperCase(), Math.max(current, sequence));
}
const migrationCircuits: LegacyMigrationCircuitPersistenceInput[] = [];
for (const group of groups) {
const representative = group.consumers[0];
let section = group.inferredSectionKey ? sectionByKey.get(group.inferredSectionKey) : null;
@@ -177,21 +173,8 @@ export class LegacyConsumerMigrationService {
usedEquipmentIdentifiers.add(equipmentIdentifier.toUpperCase());
const circuitId = await this.circuitRepository.create({
circuitListId,
sectionId: section.id,
equipmentIdentifier,
displayName: representative.description ?? representative.name,
sortOrder: nextSortOrder,
protectionType: representative.protectionType ?? undefined,
protectionRatedCurrent: representative.protectionRatedCurrent ?? undefined,
protectionCharacteristic: representative.protectionCharacteristic ?? undefined,
cableType: representative.cableType ?? undefined,
cableCrossSection: representative.cableCrossSection ?? undefined,
voltage: representative.voltageV ?? undefined,
remark: undefined,
});
report.createdCircuitCount += 1;
const deviceRows: LegacyMigrationCircuitPersistenceInput["deviceRows"] = [];
const circuitSortOrder = nextSortOrder;
nextSortOrder += 10;
let rowSortOrder = 10;
@@ -203,8 +186,7 @@ export class LegacyConsumerMigrationService {
noteRemark = noteRemark ? `${noteRemark} | ${legacyDeviceTypeNote}` : legacyDeviceTypeNote;
}
await this.circuitDeviceRowRepository.create({
circuitId,
deviceRows.push({
linkedProjectDeviceId: consumer.projectDeviceId ?? undefined,
legacyConsumerId: consumer.id,
sortOrder: rowSortOrder,
@@ -223,28 +205,32 @@ export class LegacyConsumerMigrationService {
cosPhi: consumer.powerFactor ?? undefined,
remark: noteRemark,
});
report.createdDeviceRowCount += 1;
rowSortOrder += 10;
const [createdRow] = await db
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(and(eq(circuitDeviceRows.circuitId, circuitId), eq(circuitDeviceRows.legacyConsumerId, consumer.id)))
.orderBy(circuitDeviceRows.sortOrder)
.limit(1);
if (!createdRow) {
throw new Error("Failed to resolve created circuit device row.");
}
await db.insert(legacyConsumerCircuitMigrations).values({
consumerId: consumer.id,
circuitId,
circuitDeviceRowId: createdRow.id,
circuitListId,
createdAtIso: new Date().toISOString(),
});
}
migrationCircuits.push({
circuit: {
circuitListId,
sectionId: section.id,
equipmentIdentifier,
displayName: representative.description ?? representative.name,
sortOrder: circuitSortOrder,
protectionType: representative.protectionType ?? undefined,
protectionRatedCurrent: representative.protectionRatedCurrent ?? undefined,
protectionCharacteristic: representative.protectionCharacteristic ?? undefined,
cableType: representative.cableType ?? undefined,
cableCrossSection: representative.cableCrossSection ?? undefined,
voltage: representative.voltageV ?? undefined,
remark: undefined,
},
deviceRows,
});
}
report.createdCircuitCount = migrationCircuits.length;
report.createdDeviceRowCount = migrationCircuits.reduce(
(count, entry) => count + entry.deviceRows.length,
0
);
if (consumersToMigrate.some((consumer) => Boolean(consumer.comment?.trim()))) {
report.warnings.push(
@@ -256,31 +242,10 @@ export class LegacyConsumerMigrationService {
report.warnings.push(`Consumer ${row.consumerId} was migrated into unassigned section.`);
}
const existingReport = await db
.select({ id: legacyConsumerMigrationReports.id })
.from(legacyConsumerMigrationReports)
.where(eq(legacyConsumerMigrationReports.circuitListId, circuitListId))
.limit(1);
if (existingReport.length) {
await db
.update(legacyConsumerMigrationReports)
.set({
legacyConsumerCount: report.legacyConsumerCount,
createdCircuitCount: report.createdCircuitCount,
createdDeviceRowCount: report.createdDeviceRowCount,
duplicateGroupedCount: report.groupedDuplicateCircuitNumbers.length,
generatedIdentifierCount: report.generatedIdentifiers.length,
unassignedRowCount: report.unassignedRows.length,
warningsJson: JSON.stringify(report.warnings),
generatedIdentifiersJson: JSON.stringify(report.generatedIdentifiers),
duplicateGroupsJson: JSON.stringify(report.groupedDuplicateCircuitNumbers),
createdAtIso: new Date().toISOString(),
})
.where(eq(legacyConsumerMigrationReports.id, existingReport[0].id));
} else {
await db.insert(legacyConsumerMigrationReports).values({
id: crypto.randomUUID(),
circuitListId,
this.migrationRepository.persistCircuitListMigration({
circuitListId,
circuits: migrationCircuits,
report: {
legacyConsumerCount: report.legacyConsumerCount,
createdCircuitCount: report.createdCircuitCount,
createdDeviceRowCount: report.createdDeviceRowCount,
@@ -290,9 +255,8 @@ export class LegacyConsumerMigrationService {
warningsJson: JSON.stringify(report.warnings),
generatedIdentifiersJson: JSON.stringify(report.generatedIdentifiers),
duplicateGroupsJson: JSON.stringify(report.groupedDuplicateCircuitNumbers),
createdAtIso: new Date().toISOString(),
});
}
},
});
return report;
}