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
+2
View File
@@ -13,6 +13,8 @@ Legacy `Consumer` rows map into:
Multiple legacy consumers can map into one circuit when they share normalized circuit identity. Multiple legacy consumers can map into one circuit when they share normalized circuit identity.
For one circuit list, all new circuits, device rows, trace mappings and the migration report are committed in one SQLite transaction. A failed run therefore leaves none of those prepared migration writes behind.
## Grouping Strategy (`circuitNumber`) ## Grouping Strategy (`circuitNumber`)
Migration groups legacy rows by normalized `circuitNumber`: Migration groups legacy rows by normalized `circuitNumber`:
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"start": "node dist/server/index.js", "start": "node dist/server/index.js",
"test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts", "test": "tsx --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts",
"test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts", "test:watch": "tsx --watch --test tests/power-calculation.test.ts tests/consumer-linking.service.test.ts tests/consumer-schema-options.test.ts tests/project-device-schema.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "node scripts/db-backup.js", "db:backup": "node scripts/db-backup.js",
@@ -115,7 +115,7 @@ function toPatchValues(input: CircuitDeviceRowPatchInput) {
return values; return values;
} }
function toCreateValues(id: string, input: CircuitDeviceRowCreateInput) { export function toCircuitDeviceRowCreateValues(id: string, input: CircuitDeviceRowCreateInput) {
return { return {
id, id,
circuitId: input.circuitId, circuitId: input.circuitId,
@@ -208,7 +208,7 @@ export class CircuitDeviceRowRepository {
async create(input: CircuitDeviceRowCreateInput) { async create(input: CircuitDeviceRowCreateInput) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
await db.insert(circuitDeviceRows).values(toCreateValues(id, input)); await db.insert(circuitDeviceRows).values(toCircuitDeviceRowCreateValues(id, input));
return id; return id;
} }
@@ -240,7 +240,7 @@ export class CircuitDeviceRowRepository {
tx tx
.insert(circuitDeviceRows) .insert(circuitDeviceRows)
.values(toCreateValues(id, { ...input, sortOrder })) .values(toCircuitDeviceRowCreateValues(id, { ...input, sortOrder }))
.run(); .run();
tx.update(circuits).set({ isReserve: 0 }).where(eq(circuits.id, input.circuitId)).run(); tx.update(circuits).set({ isReserve: 0 }).where(eq(circuits.id, input.circuitId)).run();
}); });
@@ -270,7 +270,7 @@ export class CircuitDeviceRowRepository {
tx tx
.insert(circuitDeviceRows) .insert(circuitDeviceRows)
.values( .values(
toCreateValues(rowIds[index], { toCircuitDeviceRowCreateValues(rowIds[index], {
...row, ...row,
circuitId, circuitId,
sortOrder: row.sortOrder ?? (index + 1) * 10, sortOrder: row.sortOrder ?? (index + 1) * 10,
@@ -0,0 +1,138 @@
import crypto from "node:crypto";
import { eq, inArray } from "drizzle-orm";
import { db } from "../client.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuits } from "../schema/circuits.js";
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
import {
toCircuitDeviceRowCreateValues,
type CircuitDeviceRowCreateInput,
} from "./circuit-device-row.repository.js";
import {
toCircuitCreateValues,
type CircuitCreatePersistenceInput,
} from "./circuit.repository.js";
export interface LegacyMigrationCircuitPersistenceInput {
circuit: CircuitCreatePersistenceInput;
deviceRows: Array<
Omit<CircuitDeviceRowCreateInput, "circuitId"> & { legacyConsumerId: string }
>;
}
export interface LegacyMigrationReportPersistenceInput {
legacyConsumerCount: number;
createdCircuitCount: number;
createdDeviceRowCount: number;
duplicateGroupedCount: number;
generatedIdentifierCount: number;
unassignedRowCount: number;
warningsJson: string;
generatedIdentifiersJson: string;
duplicateGroupsJson: string;
}
export class LegacyConsumerMigrationRepository {
async listMigratedConsumerIds(circuitListId: string) {
const rows = await db
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
.from(legacyConsumerCircuitMigrations)
.where(eq(legacyConsumerCircuitMigrations.circuitListId, circuitListId));
return rows.map((row) => row.consumerId);
}
persistCircuitListMigration(input: {
circuitListId: string;
circuits: LegacyMigrationCircuitPersistenceInput[];
report: LegacyMigrationReportPersistenceInput;
}) {
if (input.circuits.some((entry) => entry.circuit.circuitListId !== input.circuitListId)) {
throw new Error("All migrated circuits must belong to the target circuit list.");
}
const consumerIds = input.circuits.flatMap((entry) =>
entry.deviceRows.map((row) => row.legacyConsumerId)
);
if (new Set(consumerIds).size !== consumerIds.length) {
throw new Error("A legacy consumer may only be migrated once per operation.");
}
const preparedCircuits = input.circuits.map((entry) => {
const circuitId = crypto.randomUUID();
return {
circuitId,
circuitValues: toCircuitCreateValues(circuitId, entry.circuit),
rows: entry.deviceRows.map((row) => {
const rowId = crypto.randomUUID();
return {
consumerId: row.legacyConsumerId,
rowId,
rowValues: toCircuitDeviceRowCreateValues(rowId, {
...row,
circuitId,
}),
};
}),
};
});
const createdAtIso = new Date().toISOString();
db.transaction((tx) => {
if (consumerIds.length > 0) {
const existingMappings = tx
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId })
.from(legacyConsumerCircuitMigrations)
.where(inArray(legacyConsumerCircuitMigrations.consumerId, consumerIds))
.all();
if (existingMappings.length > 0) {
throw new Error("A legacy consumer was migrated before this operation completed.");
}
}
for (const preparedCircuit of preparedCircuits) {
tx.insert(circuits).values(preparedCircuit.circuitValues).run();
for (const row of preparedCircuit.rows) {
tx.insert(circuitDeviceRows).values(row.rowValues).run();
tx
.insert(legacyConsumerCircuitMigrations)
.values({
consumerId: row.consumerId,
circuitId: preparedCircuit.circuitId,
circuitDeviceRowId: row.rowId,
circuitListId: input.circuitListId,
createdAtIso,
})
.run();
}
}
const reportValues = {
...input.report,
createdAtIso,
};
const existingReport = tx
.select({ id: legacyConsumerMigrationReports.id })
.from(legacyConsumerMigrationReports)
.where(eq(legacyConsumerMigrationReports.circuitListId, input.circuitListId))
.limit(1)
.all();
if (existingReport.length > 0) {
tx
.update(legacyConsumerMigrationReports)
.set(reportValues)
.where(eq(legacyConsumerMigrationReports.id, existingReport[0].id))
.run();
} else {
tx
.insert(legacyConsumerMigrationReports)
.values({
id: crypto.randomUUID(),
circuitListId: input.circuitListId,
...reportValues,
})
.run();
}
});
}
}
@@ -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 { 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 { CircuitListRepository } from "../../db/repositories/circuit-list.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import { ConsumerRepository } from "../../db/repositories/consumer.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 { 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 type { LegacyMigrationReport } from "../models/circuit-tree.model.js";
import { import {
inferSectionKeyFromEquipmentIdentifier, inferSectionKeyFromEquipmentIdentifier,
@@ -35,9 +32,9 @@ export class LegacyConsumerMigrationService {
private readonly circuitListRepository = new CircuitListRepository(); private readonly circuitListRepository = new CircuitListRepository();
private readonly sectionRepository = new CircuitSectionRepository(); private readonly sectionRepository = new CircuitSectionRepository();
private readonly circuitRepository = new CircuitRepository(); private readonly circuitRepository = new CircuitRepository();
private readonly circuitDeviceRowRepository = new CircuitDeviceRowRepository();
private readonly consumerRepository = new ConsumerRepository(); private readonly consumerRepository = new ConsumerRepository();
private readonly roomRepository = new RoomRepository(); private readonly roomRepository = new RoomRepository();
private readonly migrationRepository = new LegacyConsumerMigrationRepository();
async migrateCircuitList(projectId: string, circuitListId: string): Promise<LegacyMigrationReport> { async migrateCircuitList(projectId: string, circuitListId: string): Promise<LegacyMigrationReport> {
// Migration is additive: legacy consumers are preserved, and circuit-first entities are created // 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. // Idempotency guard: skip consumers already mapped in previous migration run.
const migratedRows = await db const migratedConsumerIds = new Set(
.select({ consumerId: legacyConsumerCircuitMigrations.consumerId }) await this.migrationRepository.listMigratedConsumerIds(circuitListId)
.from(legacyConsumerCircuitMigrations) );
.where(eq(legacyConsumerCircuitMigrations.circuitListId, circuitListId));
const migratedConsumerIds = new Set(migratedRows.map((row) => row.consumerId));
const consumersToMigrate = legacyConsumers.filter((consumer) => !migratedConsumerIds.has(consumer.id)); const consumersToMigrate = legacyConsumers.filter((consumer) => !migratedConsumerIds.has(consumer.id));
// Legacy rows are grouped by normalized circuit number so duplicates become // 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)); maxBySectionPrefix.set(section.prefix.toUpperCase(), Math.max(current, sequence));
} }
const migrationCircuits: LegacyMigrationCircuitPersistenceInput[] = [];
for (const group of groups) { for (const group of groups) {
const representative = group.consumers[0]; const representative = group.consumers[0];
let section = group.inferredSectionKey ? sectionByKey.get(group.inferredSectionKey) : null; let section = group.inferredSectionKey ? sectionByKey.get(group.inferredSectionKey) : null;
@@ -177,21 +173,8 @@ export class LegacyConsumerMigrationService {
usedEquipmentIdentifiers.add(equipmentIdentifier.toUpperCase()); usedEquipmentIdentifiers.add(equipmentIdentifier.toUpperCase());
const circuitId = await this.circuitRepository.create({ const deviceRows: LegacyMigrationCircuitPersistenceInput["deviceRows"] = [];
circuitListId, const circuitSortOrder = nextSortOrder;
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;
nextSortOrder += 10; nextSortOrder += 10;
let rowSortOrder = 10; let rowSortOrder = 10;
@@ -203,8 +186,7 @@ export class LegacyConsumerMigrationService {
noteRemark = noteRemark ? `${noteRemark} | ${legacyDeviceTypeNote}` : legacyDeviceTypeNote; noteRemark = noteRemark ? `${noteRemark} | ${legacyDeviceTypeNote}` : legacyDeviceTypeNote;
} }
await this.circuitDeviceRowRepository.create({ deviceRows.push({
circuitId,
linkedProjectDeviceId: consumer.projectDeviceId ?? undefined, linkedProjectDeviceId: consumer.projectDeviceId ?? undefined,
legacyConsumerId: consumer.id, legacyConsumerId: consumer.id,
sortOrder: rowSortOrder, sortOrder: rowSortOrder,
@@ -223,28 +205,32 @@ export class LegacyConsumerMigrationService {
cosPhi: consumer.powerFactor ?? undefined, cosPhi: consumer.powerFactor ?? undefined,
remark: noteRemark, remark: noteRemark,
}); });
report.createdDeviceRowCount += 1;
rowSortOrder += 10; 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({ migrationCircuits.push({
consumerId: consumer.id, circuit: {
circuitId,
circuitDeviceRowId: createdRow.id,
circuitListId, circuitListId,
createdAtIso: new Date().toISOString(), 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()))) { if (consumersToMigrate.some((consumer) => Boolean(consumer.comment?.trim()))) {
report.warnings.push( report.warnings.push(
@@ -256,31 +242,10 @@ export class LegacyConsumerMigrationService {
report.warnings.push(`Consumer ${row.consumerId} was migrated into unassigned section.`); report.warnings.push(`Consumer ${row.consumerId} was migrated into unassigned section.`);
} }
const existingReport = await db this.migrationRepository.persistCircuitListMigration({
.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, circuitListId,
circuits: migrationCircuits,
report: {
legacyConsumerCount: report.legacyConsumerCount, legacyConsumerCount: report.legacyConsumerCount,
createdCircuitCount: report.createdCircuitCount, createdCircuitCount: report.createdCircuitCount,
createdDeviceRowCount: report.createdDeviceRowCount, createdDeviceRowCount: report.createdDeviceRowCount,
@@ -290,9 +255,8 @@ export class LegacyConsumerMigrationService {
warningsJson: JSON.stringify(report.warnings), warningsJson: JSON.stringify(report.warnings),
generatedIdentifiersJson: JSON.stringify(report.generatedIdentifiers), generatedIdentifiersJson: JSON.stringify(report.generatedIdentifiers),
duplicateGroupsJson: JSON.stringify(report.groupedDuplicateCircuitNumbers), duplicateGroupsJson: JSON.stringify(report.groupedDuplicateCircuitNumbers),
createdAtIso: new Date().toISOString(), },
}); });
}
return report; return report;
} }
@@ -0,0 +1,141 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { db } from "../src/db/client.js";
import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuits } from "../src/db/schema/circuits.js";
import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js";
import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js";
describe("legacy consumer migration repository", () => {
it("writes circuits, rows, mappings and report in one synchronous transaction", () => {
const originalTransaction = db.transaction;
const inserts: Array<{ table: unknown; values: unknown }> = [];
let transactionCalls = 0;
(db as unknown as { transaction: (callback: (tx: unknown) => unknown) => unknown }).transaction =
(callback) => {
transactionCalls += 1;
const emptySelectChain = {
from() {
return emptySelectChain;
},
where() {
return emptySelectChain;
},
limit() {
return emptySelectChain;
},
all() {
return [];
},
};
return callback({
select() {
return emptySelectChain;
},
insert(table: unknown) {
return {
values(values: unknown) {
inserts.push({ table, values });
return {
run() {
return { changes: 1 };
},
};
},
};
},
});
};
try {
const repository = new LegacyConsumerMigrationRepository();
repository.persistCircuitListMigration({
circuitListId: "list-1",
circuits: [
{
circuit: {
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1F1",
displayName: "Legacy lighting",
sortOrder: 10,
},
deviceRows: [
{
legacyConsumerId: "consumer-1",
sortOrder: 10,
name: "Light 1",
displayName: "Light 1",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
},
{
legacyConsumerId: "consumer-2",
sortOrder: 20,
name: "Light 2",
displayName: "Light 2",
quantity: 2,
powerPerUnit: 0.2,
simultaneityFactor: 0.8,
},
],
},
],
report: {
legacyConsumerCount: 2,
createdCircuitCount: 1,
createdDeviceRowCount: 2,
duplicateGroupedCount: 1,
generatedIdentifierCount: 0,
unassignedRowCount: 0,
warningsJson: "[]",
generatedIdentifiersJson: "[]",
duplicateGroupsJson: "[{\"normalizedCircuitNumber\":\"-1F1\",\"count\":2}]",
},
});
assert.equal(transactionCalls, 1);
assert.deepEqual(
inserts.map((entry) => entry.table),
[
circuits,
circuitDeviceRows,
legacyConsumerCircuitMigrations,
circuitDeviceRows,
legacyConsumerCircuitMigrations,
legacyConsumerMigrationReports,
]
);
const circuit = inserts[0].values as { id: string };
const firstRow = inserts[1].values as { id: string; circuitId: string; legacyConsumerId: string };
const firstMapping = inserts[2].values as {
consumerId: string;
circuitId: string;
circuitDeviceRowId: string;
circuitListId: string;
};
assert.equal(firstRow.circuitId, circuit.id);
assert.equal(firstRow.legacyConsumerId, "consumer-1");
assert.deepEqual(
{
consumerId: firstMapping.consumerId,
circuitId: firstMapping.circuitId,
circuitDeviceRowId: firstMapping.circuitDeviceRowId,
circuitListId: firstMapping.circuitListId,
},
{
consumerId: "consumer-1",
circuitId: circuit.id,
circuitDeviceRowId: firstRow.id,
circuitListId: "list-1",
}
);
} finally {
(db as unknown as { transaction: unknown }).transaction = originalTransaction;
}
});
});