import { eq } from "drizzle-orm"; import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js"; import { assertProjectStateRestoreCommand, createProjectStateRestoreCommand, } from "../../domain/models/project-state-restore-command.model.js"; import { parseProjectStateSnapshot, type ProjectStateSnapshot, } from "../../domain/models/project-state-snapshot.model.js"; import type { ExecuteProjectStateRestoreCommandInput, ProjectStateRestoreCommandStore, } from "../../domain/ports/project-state-restore-command.store.js"; import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js"; import { circuitSections } from "../schema/circuit-sections.js"; import { circuits } from "../schema/circuits.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoards } from "../schema/distribution-boards.js"; import { floors } from "../schema/floors.js"; import { projectDevices } from "../schema/project-devices.js"; import { projectChangeSets } from "../schema/project-change-sets.js"; import { projects } from "../schema/projects.js"; import { rooms } from "../schema/rooms.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { readProjectStateSnapshot, } from "./project-state-snapshot.persistence.js"; export class ProjectStateRestoreCommandRepository implements ProjectStateRestoreCommandStore { constructor(private readonly database: AppDatabase) {} execute(input: ExecuteProjectStateRestoreCommandInput) { assertProjectStateRestoreCommand(input.command); const targetState = parseProjectStateSnapshot( input.command.payload.targetState ); if (targetState.project.id !== input.projectId) { throw new Error("Restore state belongs to a different project."); } return executeProjectCommandTransaction( this.database, input, (tx) => this.applyCommand(tx, input) ); } private applyCommand( tx: AppDatabase, input: ExecuteProjectStateRestoreCommandInput ) { const current = readProjectStateSnapshot(tx, input.projectId); if (!current) { throw new Error("Project not found."); } if ( current.payloadSha256 !== input.command.payload.expectedStateSha256 && !matchesHistoryRestoreTarget(tx, input, current.state) ) { throw new ProjectStateConflictError( input.projectId, input.command.payload.expectedStateSha256, current.payloadSha256 ); } const targetState = parseProjectStateSnapshot( input.command.payload.targetState ); replaceProjectState(tx, targetState); const persistedTarget = readProjectStateSnapshot(tx, input.projectId); if (!persistedTarget) { throw new Error("Restored project could not be loaded."); } const inverse = createProjectStateRestoreCommand( persistedTarget.payloadSha256, current.state ); return inverse; } } function matchesHistoryRestoreTarget( database: AppDatabase, input: ExecuteProjectStateRestoreCommandInput, currentState: ProjectStateSnapshot ) { if ( (input.source !== "undo" && input.source !== "redo") || !input.historyTargetChangeSetId ) { return false; } const changeSet = database .select({ forwardPayloadJson: projectChangeSets.forwardPayloadJson, inversePayloadJson: projectChangeSets.inversePayloadJson, }) .from(projectChangeSets) .where(eq(projectChangeSets.id, input.historyTargetChangeSetId)) .get(); if (!changeSet) { return false; } try { const serialized = input.source === "undo" ? changeSet.forwardPayloadJson : changeSet.inversePayloadJson; const command = JSON.parse(serialized) as { type?: unknown; payload?: { targetState?: unknown }; }; if (command.type !== "project.restore-state") { return false; } const expectedState = parseProjectStateSnapshot( command.payload?.targetState ); return ( canonicalProjectState(expectedState) === canonicalProjectState(currentState) ); } catch { return false; } } function canonicalProjectState(state: ProjectStateSnapshot) { return JSON.stringify({ ...state, distributionBoards: byId(state.distributionBoards), circuitLists: byId(state.circuitLists), circuitSections: byId(state.circuitSections), distributionBoardComponents: byId( state.distributionBoardComponents ), circuitProtectionDevices: byKey( state.circuitProtectionDevices, "circuitId" ), distributionBoardComponentProtectionDevices: byKey( state.distributionBoardComponentProtectionDevices, "componentId" ), circuits: byId( state.circuits.map((circuit) => ({ ...circuit, deviceRows: byId(circuit.deviceRows), })) ), projectDevices: byId(state.projectDevices), floors: byId(state.floors), rooms: byId(state.rooms), }); } function byId(entries: readonly T[]) { return [...entries].sort((left, right) => left.id.localeCompare(right.id) ); } function byKey< TKey extends string, TEntry extends Record, >(entries: readonly TEntry[], key: TKey) { return [...entries].sort((left, right) => left[key].localeCompare(right[key]) ); } function replaceProjectState( database: AppDatabase, state: ProjectStateSnapshot ) { database .delete(distributionBoards) .where(eq(distributionBoards.projectId, state.project.id)) .run(); database .delete(projectDevices) .where(eq(projectDevices.projectId, state.project.id)) .run(); database .delete(rooms) .where(eq(rooms.projectId, state.project.id)) .run(); database .delete(floors) .where(eq(floors.projectId, state.project.id)) .run(); database .update(projects) .set({ name: state.project.name, internalProjectNumber: state.project.internalProjectNumber, externalProjectNumber: state.project.externalProjectNumber, buildingOwner: state.project.buildingOwner, description: state.project.description, isPublicBuilding: state.project.isPublicBuilding, singlePhaseVoltageV: state.project.singlePhaseVoltageV, threePhaseVoltageV: state.project.threePhaseVoltageV, enabledDistributionBoardSupplyTypes: state.project.enabledDistributionBoardSupplyTypes, }) .where(eq(projects.id, state.project.id)) .run(); insertMany(database, floors, state.floors); insertMany(database, rooms, state.rooms); insertMany(database, projectDevices, state.projectDevices); insertMany(database, distributionBoards, state.distributionBoards); insertMany(database, circuitLists, state.circuitLists); insertMany(database, circuitSections, state.circuitSections); insertMany( database, circuits, state.circuits.map(({ deviceRows: _deviceRows, ...circuit }) => ({ ...circuit, isReserve: circuit.isReserve ? 1 : 0, })) ); insertMany( database, distributionBoardComponents, state.distributionBoardComponents ); insertMany( database, circuitProtectionDevices, state.circuitProtectionDevices ); insertMany( database, distributionBoardComponentProtectionDevices, state.distributionBoardComponentProtectionDevices ); insertMany( database, circuitDeviceRows, state.circuits.flatMap((circuit) => circuit.deviceRows) ); } function insertMany[0]>( database: AppDatabase, table: TTable, values: unknown[] ) { if (values.length === 0) { return; } database .insert(table) .values(values as never) .run(); }