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.
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`)
Migration groups legacy rows by normalized `circuitNumber`:
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"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: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": "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/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:migrate": "drizzle-kit migrate",
"db:backup": "node scripts/db-backup.js",
@@ -115,7 +115,7 @@ function toPatchValues(input: CircuitDeviceRowPatchInput) {
return values;
}
function toCreateValues(id: string, input: CircuitDeviceRowCreateInput) {
export function toCircuitDeviceRowCreateValues(id: string, input: CircuitDeviceRowCreateInput) {
return {
id,
circuitId: input.circuitId,
@@ -208,7 +208,7 @@ export class CircuitDeviceRowRepository {
async create(input: CircuitDeviceRowCreateInput) {
const id = crypto.randomUUID();
await db.insert(circuitDeviceRows).values(toCreateValues(id, input));
await db.insert(circuitDeviceRows).values(toCircuitDeviceRowCreateValues(id, input));
return id;
}
@@ -240,7 +240,7 @@ export class CircuitDeviceRowRepository {
tx
.insert(circuitDeviceRows)
.values(toCreateValues(id, { ...input, sortOrder }))
.values(toCircuitDeviceRowCreateValues(id, { ...input, sortOrder }))
.run();
tx.update(circuits).set({ isReserve: 0 }).where(eq(circuits.id, input.circuitId)).run();
});
@@ -270,7 +270,7 @@ export class CircuitDeviceRowRepository {
tx
.insert(circuitDeviceRows)
.values(
toCreateValues(rowIds[index], {
toCircuitDeviceRowCreateValues(rowIds[index], {
...row,
circuitId,
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 { 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;
}
@@ -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;
}
});
});