import { eq } from "drizzle-orm"; import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js"; import { assertProjectStateRestoreCommand, createProjectStateRestoreCommand, createProjectTransferRestoreCommand, projectStateRestoreCommandSchemaVersion, } 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 { canonicalEstimateBalance } from "../../estimate-balance/domain/estimate-balance-command.model.js"; import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.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 { buildings } from "../schema/buildings.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 { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; import { externalImportBatches } from "../schema/external-import-batches.js"; import { externalModelObjects } from "../schema/external-model-objects.js"; import { externalModelSources } from "../schema/external-model-sources.js"; import { externalRoomMappings } from "../schema/external-room-mappings.js"; import { estimateBalances } from "../schema/estimate-balances.js"; import { insertEstimateBalance, readEstimateBalances, } from "./estimate-balance.persistence.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."); } const currentEstimateBalances = readEstimateBalances(tx, input.projectId); const transferCommand = input.command.schemaVersion === projectStateRestoreCommandSchemaVersion ? input.command : null; if ( current.payloadSha256 !== input.command.payload.expectedStateSha256 && !matchesHistoryRestoreTarget( tx, input, current.state, currentEstimateBalances ) ) { throw new ProjectStateConflictError( input.projectId, input.command.payload.expectedStateSha256, current.payloadSha256 ); } if ( transferCommand && canonicalEstimateBalances(currentEstimateBalances) !== canonicalEstimateBalances( transferCommand.payload.expectedEstimateBalances ) && !matchesHistoryRestoreTarget( tx, input, current.state, currentEstimateBalances ) ) { throw new ProjectStateConflictError( input.projectId, input.command.payload.expectedStateSha256, current.payloadSha256 ); } const targetState = parseProjectStateSnapshot( input.command.payload.targetState ); replaceProjectState(tx, targetState); if (transferCommand) { replaceEstimateBalances( tx, input.projectId, transferCommand.payload.targetEstimateBalances ); } const persistedTarget = readProjectStateSnapshot(tx, input.projectId); if (!persistedTarget) { throw new Error("Restored project could not be loaded."); } const inverse = transferCommand ? createProjectTransferRestoreCommand( persistedTarget.payloadSha256, current.state, transferCommand.payload.targetEstimateBalances, currentEstimateBalances ) : createProjectStateRestoreCommand( persistedTarget.payloadSha256, current.state ); return inverse; } } function matchesHistoryRestoreTarget( database: AppDatabase, input: ExecuteProjectStateRestoreCommandInput, currentState: ProjectStateSnapshot, currentEstimateBalances: EstimateBalanceSnapshot[] ) { 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 unknown; assertProjectStateRestoreCommand(command); const expectedState = parseProjectStateSnapshot(command.payload.targetState); const stateMatches = canonicalProjectState(expectedState) === canonicalProjectState(currentState); if (!stateMatches) { return false; } return command.schemaVersion !== projectStateRestoreCommandSchemaVersion || canonicalEstimateBalances(command.payload.targetEstimateBalances) === canonicalEstimateBalances(currentEstimateBalances); } catch { return false; } } function canonicalEstimateBalances(balances: EstimateBalanceSnapshot[]) { return JSON.stringify( [...balances] .sort((left, right) => left.id.localeCompare(right.id)) .map((balance) => canonicalEstimateBalance(balance)) ); } function replaceEstimateBalances( database: AppDatabase, projectId: string, balances: EstimateBalanceSnapshot[] ) { database .delete(estimateBalances) .where(eq(estimateBalances.projectId, projectId)) .run(); for (const balance of balances) { insertEstimateBalance(database, balance); } } 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), buildings: byId(state.buildings), floors: byId(state.floors), rooms: byId(state.rooms), externalCsvConfiguration: state.externalCsvConfiguration, externalModel: { source: state.externalModel.source, importBatches: byId(state.externalModel.importBatches), roomMappings: byId(state.externalModel.roomMappings), objects: byId(state.externalModel.objects), }, }); } 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(externalModelSources) .where(eq(externalModelSources.projectId, state.project.id)) .run(); database .delete(externalCsvConfigurations) .where(eq(externalCsvConfigurations.projectId, state.project.id)) .run(); 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 .delete(buildings) .where(eq(buildings.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, siteAddress: state.project.siteAddress, workStage: state.project.workStage, projectLead: state.project.projectLead, projectEditor: state.project.projectEditor, }) .where(eq(projects.id, state.project.id)) .run(); insertMany(database, buildings, state.buildings); insertMany(database, floors, state.floors); if (state.externalCsvConfiguration !== null) { database .insert(externalCsvConfigurations) .values(state.externalCsvConfiguration) .run(); } 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) ); if (state.externalModel.source !== null) { database.insert(externalModelSources).values(state.externalModel.source).run(); } insertMany( database, externalImportBatches, state.externalModel.importBatches.map( ({ originalContentBase64, ...batch }) => ({ ...batch, originalBytes: Buffer.from(originalContentBase64, "base64"), }) ) ); insertMany(database, externalRoomMappings, state.externalModel.roomMappings); insertMany(database, externalModelObjects, state.externalModel.objects); } function insertMany[0]>( database: AppDatabase, table: TTable, values: unknown[] ) { if (values.length === 0) { return; } database .insert(table) .values(values as never) .run(); }