forked from jappel/leistungsbilanz-ts
268 lines
8 KiB
TypeScript
268 lines
8 KiB
TypeScript
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<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(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<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();
|
|
}
|