From 4d5eb22e66aef2f20c7f14c39802bd5870f1284e Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Thu, 23 Jul 2026 19:06:30 +0200 Subject: [PATCH] Isolate legacy migration transactions --- docs/spec/07-implementation-phases-todo.md | 2 + scripts/db-migrate-legacy-consumers.ts | 6 +- .../legacy-consumer-migration.repository.ts | 12 +- .../legacy-consumer-migration.service.ts | 5 +- ...gacy-consumer-migration.repository.test.ts | 282 ++++++++++-------- 5 files changed, 180 insertions(+), 127 deletions(-) diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 4083451..098c578 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -336,6 +336,8 @@ Implemented foundation: - circuit-device-row transaction tests cover both successful commits and forced SQLite rollbacks - project-device row synchronization has real multi-row SQLite commit and rollback coverage - BMK swaps and section reorder have real SQLite commit and rollback coverage +- the legacy consumer migration receives its database dependency at the script entry point +- legacy circuit, row, mapping and report writes have real late-failure rollback coverage - circuit and device-row persistence value mapping is separated from the general repositories ## Phase 12: Project Revisions and Persistent Undo / Redo diff --git a/scripts/db-migrate-legacy-consumers.ts b/scripts/db-migrate-legacy-consumers.ts index 10662e6..44f0a24 100644 --- a/scripts/db-migrate-legacy-consumers.ts +++ b/scripts/db-migrate-legacy-consumers.ts @@ -1,10 +1,14 @@ +import { db } from "../src/db/client.js"; import { CircuitListRepository } from "../src/db/repositories/circuit-list.repository.js"; +import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js"; import { ProjectRepository } from "../src/db/repositories/project.repository.js"; import { LegacyConsumerMigrationService } from "../src/domain/services/legacy-consumer-migration.service.js"; const projectRepository = new ProjectRepository(); const circuitListRepository = new CircuitListRepository(); -const migrationService = new LegacyConsumerMigrationService(); +const migrationService = new LegacyConsumerMigrationService( + new LegacyConsumerMigrationRepository(db) +); async function run() { const projects = await projectRepository.list(); diff --git a/src/db/repositories/legacy-consumer-migration.repository.ts b/src/db/repositories/legacy-consumer-migration.repository.ts index a700276..0be98dc 100644 --- a/src/db/repositories/legacy-consumer-migration.repository.ts +++ b/src/db/repositories/legacy-consumer-migration.repository.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; import { eq, inArray } from "drizzle-orm"; -import { db } from "../client.js"; +import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuits } from "../schema/circuits.js"; import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js"; @@ -8,11 +8,11 @@ import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migrat import { toCircuitDeviceRowCreateValues, type CircuitDeviceRowCreateInput, -} from "./circuit-device-row.repository.js"; +} from "./circuit-device-row.persistence.js"; import { toCircuitCreateValues, type CircuitCreatePersistenceInput, -} from "./circuit.repository.js"; +} from "./circuit.persistence.js"; export interface LegacyMigrationCircuitPersistenceInput { circuit: CircuitCreatePersistenceInput; @@ -34,8 +34,10 @@ export interface LegacyMigrationReportPersistenceInput { } export class LegacyConsumerMigrationRepository { + constructor(private readonly database: AppDatabase) {} + async listMigratedConsumerIds(circuitListId: string) { - const rows = await db + const rows = await this.database .select({ consumerId: legacyConsumerCircuitMigrations.consumerId }) .from(legacyConsumerCircuitMigrations) .where(eq(legacyConsumerCircuitMigrations.circuitListId, circuitListId)); @@ -78,7 +80,7 @@ export class LegacyConsumerMigrationRepository { }); const createdAtIso = new Date().toISOString(); - db.transaction((tx) => { + this.database.transaction((tx) => { if (consumerIds.length > 0) { const existingMappings = tx .select({ consumerId: legacyConsumerCircuitMigrations.consumerId }) diff --git a/src/domain/services/legacy-consumer-migration.service.ts b/src/domain/services/legacy-consumer-migration.service.ts index b6c8ccc..3c5bb84 100644 --- a/src/domain/services/legacy-consumer-migration.service.ts +++ b/src/domain/services/legacy-consumer-migration.service.ts @@ -34,7 +34,10 @@ export class LegacyConsumerMigrationService { private readonly circuitRepository = new CircuitRepository(); private readonly consumerRepository = new ConsumerRepository(); private readonly roomRepository = new RoomRepository(); - private readonly migrationRepository = new LegacyConsumerMigrationRepository(); + + constructor( + private readonly migrationRepository: LegacyConsumerMigrationRepository + ) {} async migrateCircuitList(projectId: string, circuitListId: string): Promise { // Migration is additive: legacy consumers are preserved, and circuit-first entities are created diff --git a/tests/legacy-consumer-migration.repository.test.ts b/tests/legacy-consumer-migration.repository.test.ts index 0dba8d3..ca70217 100644 --- a/tests/legacy-consumer-migration.repository.test.ts +++ b/tests/legacy-consumer-migration.repository.test.ts @@ -1,141 +1,183 @@ +import path from "node:path"; import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { db } from "../src/db/client.js"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; import { LegacyConsumerMigrationRepository } from "../src/db/repositories/legacy-consumer-migration.repository.js"; 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 { 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"; -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; +function createTestDatabase() { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db.insert(projects).values({ id: "project-1", name: "Test project" }).run(); + const board = new DistributionBoardRepository( + context.db + ).createWithCircuitListAndDefaultSections("project-1", "UV-01"); + const [section] = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, board.id)) + .limit(1) + .all(); + return { context, circuitListId: board.id, sectionId: section.id }; +} - (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: [ +function migrationInput(circuitListId: string, sectionId: string) { + return { + circuitListId, + circuits: [ + { + circuit: { + circuitListId, + sectionId, + equipmentIdentifier: "-1F1", + displayName: "Legacy lighting", + sortOrder: 10, + }, + deviceRows: [ { - 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, - }, - ], + 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}]", - }, - }); + }, + ], + report: { + legacyConsumerCount: 2, + createdCircuitCount: 1, + createdDeviceRowCount: 2, + duplicateGroupedCount: 1, + generatedIdentifierCount: 0, + unassignedRowCount: 0, + warningsJson: "[]", + generatedIdentifiersJson: "[]", + duplicateGroupsJson: + "[{\"normalizedCircuitNumber\":\"-1F1\",\"count\":2}]", + }, + }; +} - assert.equal(transactionCalls, 1); +function assertNoMigrationWrites(context: DatabaseContext) { + assert.equal(context.db.select().from(circuits).all().length, 0); + assert.equal(context.db.select().from(circuitDeviceRows).all().length, 0); + assert.equal( + context.db.select().from(legacyConsumerCircuitMigrations).all().length, + 0 + ); + assert.equal( + context.db.select().from(legacyConsumerMigrationReports).all().length, + 0 + ); +} + +describe("legacy consumer migration repository", () => { + it("commits circuits, rows, mappings and report together", () => { + const { context, circuitListId, sectionId } = createTestDatabase(); + try { + const repository = new LegacyConsumerMigrationRepository(context.db); + + repository.persistCircuitListMigration( + migrationInput(circuitListId, sectionId) + ); + + const persistedCircuits = context.db.select().from(circuits).all(); + const persistedRows = context.db + .select() + .from(circuitDeviceRows) + .all() + .sort((left, right) => left.sortOrder - right.sortOrder); + const persistedMappings = context.db + .select() + .from(legacyConsumerCircuitMigrations) + .all() + .sort((left, right) => left.consumerId.localeCompare(right.consumerId)); + const persistedReports = context.db + .select() + .from(legacyConsumerMigrationReports) + .all(); + + assert.equal(persistedCircuits.length, 1); + assert.equal(persistedRows.length, 2); + assert.equal(persistedMappings.length, 2); + assert.equal(persistedReports.length, 1); assert.deepEqual( - inserts.map((entry) => entry.table), + persistedMappings.map((mapping) => [ + mapping.consumerId, + mapping.circuitId, + mapping.circuitDeviceRowId, + mapping.circuitListId, + ]), [ - circuits, - circuitDeviceRows, - legacyConsumerCircuitMigrations, - circuitDeviceRows, - legacyConsumerCircuitMigrations, - legacyConsumerMigrationReports, + [ + "consumer-1", + persistedCircuits[0].id, + persistedRows[0].id, + circuitListId, + ], + [ + "consumer-2", + persistedCircuits[0].id, + persistedRows[1].id, + circuitListId, + ], ] ); - - 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", - } - ); + assert.equal(persistedReports[0].createdCircuitCount, 1); + assert.equal(persistedReports[0].createdDeviceRowCount, 2); } finally { - (db as unknown as { transaction: unknown }).transaction = originalTransaction; + context.close(); + } + }); + + it("rolls back all migrated entities when the final report write fails", () => { + const { context, circuitListId, sectionId } = createTestDatabase(); + try { + context.sqlite.exec(` + CREATE TRIGGER fail_migration_report + BEFORE INSERT ON legacy_consumer_migration_reports + BEGIN + SELECT RAISE(ABORT, 'forced migration report failure'); + END; + `); + const repository = new LegacyConsumerMigrationRepository(context.db); + + assert.throws( + () => + repository.persistCircuitListMigration( + migrationInput(circuitListId, sectionId) + ), + /forced migration report failure/ + ); + + assertNoMigrationWrites(context); + } finally { + context.close(); } }); });