leistungsbilanz-ts/src/db/repositories/project-state-restore-command.repository.ts

393 lines
12 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 { 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<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
.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<TTable extends Parameters<AppDatabase["insert"]>[0]>(
database: AppDatabase,
table: TTable,
values: unknown[]
) {
if (values.length === 0) {
return;
}
database
.insert(table)
.values(values as never)
.run();
}