forked from jappel/leistungsbilanz-ts
433 lines
14 KiB
TypeScript
433 lines
14 KiB
TypeScript
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 { externalModelConfigurations } from "../schema/external-model-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";
|
|
import { insertManyInBatches } from "./batched-insert.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),
|
|
externalModelConfiguration: state.externalModelConfiguration,
|
|
externalModel: {
|
|
source: state.externalModel.source,
|
|
importBatches: byId(state.externalModel.importBatches),
|
|
roomMappings: byId(state.externalModel.roomMappings),
|
|
objects: byId(state.externalModel.objects),
|
|
},
|
|
});
|
|
}
|
|
|
|
function byId<T extends { id: string }>(entries: readonly T[]) {
|
|
return [...entries].sort((left, right) =>
|
|
left.id.localeCompare(right.id)
|
|
);
|
|
}
|
|
|
|
function byKey<
|
|
TKey extends string,
|
|
TEntry extends Record<TKey, string>,
|
|
>(entries: readonly TEntry[], key: TKey) {
|
|
return [...entries].sort((left, right) =>
|
|
left[key].localeCompare(right[key])
|
|
);
|
|
}
|
|
|
|
function replaceProjectState(
|
|
database: AppDatabase,
|
|
state: ProjectStateSnapshot
|
|
) {
|
|
database
|
|
.update(distributionBoards)
|
|
.set({
|
|
feedingDistributionBoardId: null,
|
|
feedingCircuitId: null,
|
|
})
|
|
.where(eq(distributionBoards.projectId, state.project.id))
|
|
.run();
|
|
database
|
|
.delete(externalModelSources)
|
|
.where(eq(externalModelSources.projectId, state.project.id))
|
|
.run();
|
|
database
|
|
.delete(externalModelConfigurations)
|
|
.where(eq(externalModelConfigurations.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.externalModelConfiguration !== null) {
|
|
database
|
|
.insert(externalModelConfigurations)
|
|
.values(state.externalModelConfiguration)
|
|
.run();
|
|
}
|
|
insertMany(database, rooms, state.rooms);
|
|
insertMany(database, projectDevices, state.projectDevices);
|
|
insertMany(
|
|
database,
|
|
distributionBoards,
|
|
state.distributionBoards.map((board) => ({
|
|
...board,
|
|
feedingDistributionBoardId: null,
|
|
feedingCircuitId: null,
|
|
}))
|
|
);
|
|
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,
|
|
}))
|
|
);
|
|
restoreDistributionBoardFeedingReferences(database, state);
|
|
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:
|
|
originalContentBase64 === null
|
|
? null
|
|
: Buffer.from(originalContentBase64, "base64"),
|
|
})
|
|
)
|
|
);
|
|
insertMany(database, externalRoomMappings, state.externalModel.roomMappings);
|
|
insertMany(database, externalModelObjects, state.externalModel.objects);
|
|
}
|
|
|
|
function restoreDistributionBoardFeedingReferences(
|
|
database: AppDatabase,
|
|
state: ProjectStateSnapshot
|
|
) {
|
|
for (const board of state.distributionBoards) {
|
|
if (
|
|
board.feedingDistributionBoardId === null &&
|
|
board.feedingCircuitId === null
|
|
) {
|
|
continue;
|
|
}
|
|
const updated = database
|
|
.update(distributionBoards)
|
|
.set({
|
|
feedingDistributionBoardId: board.feedingDistributionBoardId,
|
|
feedingCircuitId: board.feedingCircuitId,
|
|
})
|
|
.where(eq(distributionBoards.id, board.id))
|
|
.run();
|
|
if (updated.changes !== 1) {
|
|
throw new Error("Distribution-board feeding reference could not be restored.");
|
|
}
|
|
}
|
|
}
|
|
|
|
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
|
database: AppDatabase,
|
|
table: TTable,
|
|
values: unknown[]
|
|
) {
|
|
insertManyInBatches(database, table, values);
|
|
}
|