diff --git a/docs/circuit-list-editor-migration.md b/docs/circuit-list-editor-migration.md index 868e3b2..bb94bc7 100644 --- a/docs/circuit-list-editor-migration.md +++ b/docs/circuit-list-editor-migration.md @@ -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`: diff --git a/package.json b/package.json index 82b6ea7..9f79571 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/db/repositories/circuit-device-row.repository.ts b/src/db/repositories/circuit-device-row.repository.ts index f66de15..601c260 100644 --- a/src/db/repositories/circuit-device-row.repository.ts +++ b/src/db/repositories/circuit-device-row.repository.ts @@ -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, diff --git a/src/db/repositories/legacy-consumer-migration.repository.ts b/src/db/repositories/legacy-consumer-migration.repository.ts new file mode 100644 index 0000000..a700276 --- /dev/null +++ b/src/db/repositories/legacy-consumer-migration.repository.ts @@ -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 & { 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(); + } + }); + } +} diff --git a/src/domain/services/legacy-consumer-migration.service.ts b/src/domain/services/legacy-consumer-migration.service.ts index 09ef99d..b6c8ccc 100644 --- a/src/domain/services/legacy-consumer-migration.service.ts +++ b/src/domain/services/legacy-consumer-migration.service.ts @@ -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 { // 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; } diff --git a/tests/legacy-consumer-migration.repository.test.ts b/tests/legacy-consumer-migration.repository.test.ts new file mode 100644 index 0000000..0dba8d3 --- /dev/null +++ b/tests/legacy-consumer-migration.repository.test.ts @@ -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; + } + }); +});