Restore named project snapshots

This commit is contained in:
2026-07-25 22:41:24 +02:00
parent 7c670fece3
commit 384a5769ab
25 changed files with 1315 additions and 206 deletions
@@ -14,7 +14,7 @@ export function applyProjectHistoryTransition(
database: AppDatabase,
input: ApplyProjectHistoryTransitionInput
) {
if (input.source === "user") {
if (input.source === "user" || input.source === "restore") {
database
.delete(projectHistoryStackEntries)
.where(
@@ -1,28 +1,25 @@
import crypto from "node:crypto";
import { and, asc, desc, eq } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import {
parseProjectStateSnapshot,
deserializeProjectStateSnapshot,
projectStateSnapshotSchemaVersion,
serializeProjectStateSnapshot,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
CreateNamedProjectSnapshotInput,
PreparedProjectSnapshotRestore,
ProjectSnapshotMetadata,
ProjectSnapshotStore,
} from "../../domain/ports/project-snapshot.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 { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projectSnapshots } from "../schema/project-snapshots.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import {
hashProjectStatePayload,
readProjectStateSnapshot,
} from "./project-state-snapshot.persistence.js";
export class ProjectSnapshotRepository implements ProjectSnapshotStore {
constructor(private readonly database: AppDatabase) {}
@@ -35,25 +32,15 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
const snapshotDescription =
input.description?.trim() || null;
return this.database.transaction((tx) => {
const project = tx
.select({
id: projects.id,
name: projects.name,
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
currentRevision: projects.currentRevision,
})
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
const current = readProjectStateSnapshot(tx, input.projectId);
if (!current) {
return null;
}
if (project.currentRevision !== input.expectedRevision) {
if (current.currentRevision !== input.expectedRevision) {
throw new ProjectRevisionConflictError(
input.projectId,
input.expectedRevision,
project.currentRevision
current.currentRevision
);
}
const existing = tx
@@ -73,182 +60,21 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
);
}
const boardRows = tx
.select()
.from(distributionBoards)
.where(eq(distributionBoards.projectId, input.projectId))
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
.all();
const listRows = tx
.select()
.from(circuitLists)
.where(eq(circuitLists.projectId, input.projectId))
.orderBy(asc(circuitLists.name), asc(circuitLists.id))
.all();
const sectionRows = tx
.select({
id: circuitSections.id,
circuitListId: circuitSections.circuitListId,
key: circuitSections.key,
displayName: circuitSections.displayName,
prefix: circuitSections.prefix,
sortOrder: circuitSections.sortOrder,
})
.from(circuitSections)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuitSections.circuitListId)
)
.where(eq(circuitLists.projectId, input.projectId))
.orderBy(
asc(circuitSections.circuitListId),
asc(circuitSections.sortOrder),
asc(circuitSections.id)
)
.all();
const circuitRows = tx
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
sectionId: circuits.sectionId,
equipmentIdentifier: circuits.equipmentIdentifier,
displayName: circuits.displayName,
sortOrder: circuits.sortOrder,
protectionType: circuits.protectionType,
protectionRatedCurrent: circuits.protectionRatedCurrent,
protectionCharacteristic: circuits.protectionCharacteristic,
cableType: circuits.cableType,
cableCrossSection: circuits.cableCrossSection,
cableLength: circuits.cableLength,
rcdAssignment: circuits.rcdAssignment,
terminalDesignation: circuits.terminalDesignation,
voltage: circuits.voltage,
controlRequirement: circuits.controlRequirement,
status: circuits.status,
isReserve: circuits.isReserve,
remark: circuits.remark,
})
.from(circuits)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(eq(circuitLists.projectId, input.projectId))
.orderBy(
asc(circuits.circuitListId),
asc(circuits.sortOrder),
asc(circuits.id)
)
.all();
const deviceRowRows = tx
.select({
id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId,
linkedProjectDeviceId:
circuitDeviceRows.linkedProjectDeviceId,
legacyConsumerId: circuitDeviceRows.legacyConsumerId,
sortOrder: circuitDeviceRows.sortOrder,
name: circuitDeviceRows.name,
displayName: circuitDeviceRows.displayName,
phaseType: circuitDeviceRows.phaseType,
connectionKind: circuitDeviceRows.connectionKind,
costGroup: circuitDeviceRows.costGroup,
category: circuitDeviceRows.category,
level: circuitDeviceRows.level,
roomId: circuitDeviceRows.roomId,
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
quantity: circuitDeviceRows.quantity,
powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor:
circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi,
remark: circuitDeviceRows.remark,
overriddenFields: circuitDeviceRows.overriddenFields,
})
.from(circuitDeviceRows)
.innerJoin(
circuits,
eq(circuits.id, circuitDeviceRows.circuitId)
)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(eq(circuitLists.projectId, input.projectId))
.orderBy(
asc(circuitDeviceRows.circuitId),
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all();
const projectDeviceRows = tx
.select()
.from(projectDevices)
.where(eq(projectDevices.projectId, input.projectId))
.orderBy(
asc(projectDevices.displayName),
asc(projectDevices.id)
)
.all();
const floorRows = tx
.select()
.from(floors)
.where(eq(floors.projectId, input.projectId))
.orderBy(asc(floors.sortOrder), asc(floors.id))
.all();
const roomRows = tx
.select()
.from(rooms)
.where(eq(rooms.projectId, input.projectId))
.orderBy(asc(rooms.roomNumber), asc(rooms.id))
.all();
const deviceRowsByCircuit = new Map<string, typeof deviceRowRows>();
for (const row of deviceRowRows) {
const entries = deviceRowsByCircuit.get(row.circuitId) ?? [];
entries.push(row);
deviceRowsByCircuit.set(row.circuitId, entries);
}
const snapshot = parseProjectStateSnapshot({
schemaVersion: projectStateSnapshotSchemaVersion,
project: {
id: project.id,
name: project.name,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
},
distributionBoards: boardRows,
circuitLists: listRows,
circuitSections: sectionRows,
circuits: circuitRows.map((circuit) => ({
...circuit,
isReserve: Boolean(circuit.isReserve),
deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [],
})),
projectDevices: projectDeviceRows,
floors: floorRows,
rooms: roomRows,
});
const payloadJson = serializeProjectStateSnapshot(snapshot);
const metadata: ProjectSnapshotMetadata = {
id: crypto.randomUUID(),
projectId: input.projectId,
sourceRevision: project.currentRevision,
sourceRevision: current.currentRevision,
schemaVersion: projectStateSnapshotSchemaVersion,
name: snapshotName,
description: snapshotDescription,
payloadSha256: crypto
.createHash("sha256")
.update(payloadJson)
.digest("hex"),
payloadSha256: current.payloadSha256,
createdAtIso: new Date().toISOString(),
createdByActorId: input.actorId ?? null,
};
tx.insert(projectSnapshots)
.values({
...metadata,
payloadJson,
payloadJson: current.payloadJson,
})
.run();
return metadata;
@@ -284,6 +110,54 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
)
.all();
}
prepareRestore(
projectId: string,
snapshotId: string
): PreparedProjectSnapshotRestore | null {
const current = readProjectStateSnapshot(this.database, projectId);
if (!current) {
return null;
}
const stored = this.database
.select()
.from(projectSnapshots)
.where(
and(
eq(projectSnapshots.id, snapshotId),
eq(projectSnapshots.projectId, projectId)
)
)
.get();
if (!stored) {
return null;
}
const actualChecksum = hashProjectStatePayload(stored.payloadJson);
if (actualChecksum !== stored.payloadSha256) {
throw new Error("Project snapshot checksum verification failed.");
}
if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) {
throw new Error("Project snapshot schema version is not supported.");
}
const targetState = deserializeProjectStateSnapshot(stored.payloadJson);
return {
snapshot: {
id: stored.id,
projectId: stored.projectId,
sourceRevision: stored.sourceRevision,
schemaVersion: stored.schemaVersion,
name: stored.name,
description: stored.description,
payloadSha256: stored.payloadSha256,
createdAtIso: stored.createdAtIso,
createdByActorId: stored.createdByActorId,
},
command: createProjectStateRestoreCommand(
current.payloadSha256,
targetState
),
};
}
}
function validateCreateInput(input: CreateNamedProjectSnapshotInput) {
@@ -0,0 +1,290 @@
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 {
serializeProjectStateSnapshot,
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 { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { consumers } from "../schema/consumers.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
import {
hashProjectStatePayload,
readProjectStateSnapshot,
} from "./project-state-snapshot.persistence.js";
export class ProjectStateRestoreCommandRepository
implements ProjectStateRestoreCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectStateRestoreCommandInput) {
assertProjectStateRestoreCommand(input.command);
if (input.command.payload.targetState.project.id !== input.projectId) {
throw new Error("Restore state belongs to a different project.");
}
return this.database.transaction((tx) => {
const current = readProjectStateSnapshot(tx, input.projectId);
if (!current) {
throw new Error("Project not found.");
}
if (
current.payloadSha256 !==
input.command.payload.expectedStateSha256
) {
throw new ProjectStateConflictError(
input.projectId,
input.command.payload.expectedStateSha256,
current.payloadSha256
);
}
const targetPayloadJson = serializeProjectStateSnapshot(
input.command.payload.targetState
);
const inverse = createProjectStateRestoreCommand(
hashProjectStatePayload(targetPayloadJson),
current.state
);
replaceProjectState(tx, input.command.payload.targetState);
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
}
function replaceProjectState(
database: AppDatabase,
state: ProjectStateSnapshot
) {
const upgradeOnlyData = captureUpgradeOnlyData(
database,
state.project.id
);
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,
singlePhaseVoltageV: state.project.singlePhaseVoltageV,
threePhaseVoltageV: state.project.threePhaseVoltageV,
})
.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,
circuitDeviceRows,
state.circuits.flatMap((circuit) => circuit.deviceRows)
);
restoreCompatibleUpgradeOnlyData(database, state, upgradeOnlyData);
}
function captureUpgradeOnlyData(
database: AppDatabase,
projectId: string
) {
const consumerLinks = database
.select({
id: consumers.id,
distributionBoardId: consumers.distributionBoardId,
circuitListId: consumers.circuitListId,
projectDeviceId: consumers.projectDeviceId,
roomId: consumers.roomId,
})
.from(consumers)
.where(eq(consumers.projectId, projectId))
.all();
const migrationMappings = database
.select({
consumerId: legacyConsumerCircuitMigrations.consumerId,
circuitId: legacyConsumerCircuitMigrations.circuitId,
circuitDeviceRowId:
legacyConsumerCircuitMigrations.circuitDeviceRowId,
circuitListId: legacyConsumerCircuitMigrations.circuitListId,
createdAtIso: legacyConsumerCircuitMigrations.createdAtIso,
})
.from(legacyConsumerCircuitMigrations)
.innerJoin(
circuitLists,
eq(
circuitLists.id,
legacyConsumerCircuitMigrations.circuitListId
)
)
.where(eq(circuitLists.projectId, projectId))
.all();
const migrationReports = database
.select({
id: legacyConsumerMigrationReports.id,
circuitListId: legacyConsumerMigrationReports.circuitListId,
legacyConsumerCount:
legacyConsumerMigrationReports.legacyConsumerCount,
createdCircuitCount:
legacyConsumerMigrationReports.createdCircuitCount,
createdDeviceRowCount:
legacyConsumerMigrationReports.createdDeviceRowCount,
duplicateGroupedCount:
legacyConsumerMigrationReports.duplicateGroupedCount,
generatedIdentifierCount:
legacyConsumerMigrationReports.generatedIdentifierCount,
unassignedRowCount:
legacyConsumerMigrationReports.unassignedRowCount,
warningsJson: legacyConsumerMigrationReports.warningsJson,
generatedIdentifiersJson:
legacyConsumerMigrationReports.generatedIdentifiersJson,
duplicateGroupsJson:
legacyConsumerMigrationReports.duplicateGroupsJson,
createdAtIso: legacyConsumerMigrationReports.createdAtIso,
})
.from(legacyConsumerMigrationReports)
.innerJoin(
circuitLists,
eq(circuitLists.id, legacyConsumerMigrationReports.circuitListId)
)
.where(eq(circuitLists.projectId, projectId))
.all();
return { consumerLinks, migrationMappings, migrationReports };
}
function restoreCompatibleUpgradeOnlyData(
database: AppDatabase,
state: ProjectStateSnapshot,
data: ReturnType<typeof captureUpgradeOnlyData>
) {
const boardIds = new Set(
state.distributionBoards.map((entry) => entry.id)
);
const listIds = new Set(state.circuitLists.map((entry) => entry.id));
const deviceIds = new Set(
state.projectDevices.map((entry) => entry.id)
);
const roomIds = new Set(state.rooms.map((entry) => entry.id));
const circuitIds = new Set(state.circuits.map((entry) => entry.id));
const rowIds = new Set(
state.circuits.flatMap((circuit) =>
circuit.deviceRows.map((entry) => entry.id)
)
);
for (const link of data.consumerLinks) {
database
.update(consumers)
.set({
distributionBoardId:
link.distributionBoardId &&
boardIds.has(link.distributionBoardId)
? link.distributionBoardId
: null,
circuitListId:
link.circuitListId && listIds.has(link.circuitListId)
? link.circuitListId
: null,
projectDeviceId:
link.projectDeviceId && deviceIds.has(link.projectDeviceId)
? link.projectDeviceId
: null,
roomId:
link.roomId && roomIds.has(link.roomId) ? link.roomId : null,
})
.where(eq(consumers.id, link.id))
.run();
}
insertMany(
database,
legacyConsumerCircuitMigrations,
data.migrationMappings.filter(
(entry) =>
listIds.has(entry.circuitListId) &&
circuitIds.has(entry.circuitId) &&
rowIds.has(entry.circuitDeviceRowId)
)
);
insertMany(
database,
legacyConsumerMigrationReports,
data.migrationReports.filter((entry) =>
listIds.has(entry.circuitListId)
)
);
}
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();
}
@@ -0,0 +1,201 @@
import crypto from "node:crypto";
import { asc, eq } from "drizzle-orm";
import {
parseProjectStateSnapshot,
projectStateSnapshotSchemaVersion,
serializeProjectStateSnapshot,
type ProjectStateSnapshot,
} from "../../domain/models/project-state-snapshot.model.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
export interface PersistedProjectStateSnapshot {
currentRevision: number;
state: ProjectStateSnapshot;
payloadJson: string;
payloadSha256: string;
}
export function readProjectStateSnapshot(
database: AppDatabase,
projectId: string
): PersistedProjectStateSnapshot | null {
const project = database
.select({
id: projects.id,
name: projects.name,
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
currentRevision: projects.currentRevision,
})
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
return null;
}
const boardRows = database
.select()
.from(distributionBoards)
.where(eq(distributionBoards.projectId, projectId))
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
.all();
const listRows = database
.select()
.from(circuitLists)
.where(eq(circuitLists.projectId, projectId))
.orderBy(asc(circuitLists.name), asc(circuitLists.id))
.all();
const sectionRows = database
.select({
id: circuitSections.id,
circuitListId: circuitSections.circuitListId,
key: circuitSections.key,
displayName: circuitSections.displayName,
prefix: circuitSections.prefix,
sortOrder: circuitSections.sortOrder,
})
.from(circuitSections)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuitSections.circuitListId)
)
.where(eq(circuitLists.projectId, projectId))
.orderBy(
asc(circuitSections.circuitListId),
asc(circuitSections.sortOrder),
asc(circuitSections.id)
)
.all();
const circuitRows = database
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
sectionId: circuits.sectionId,
equipmentIdentifier: circuits.equipmentIdentifier,
displayName: circuits.displayName,
sortOrder: circuits.sortOrder,
protectionType: circuits.protectionType,
protectionRatedCurrent: circuits.protectionRatedCurrent,
protectionCharacteristic: circuits.protectionCharacteristic,
cableType: circuits.cableType,
cableCrossSection: circuits.cableCrossSection,
cableLength: circuits.cableLength,
rcdAssignment: circuits.rcdAssignment,
terminalDesignation: circuits.terminalDesignation,
voltage: circuits.voltage,
controlRequirement: circuits.controlRequirement,
status: circuits.status,
isReserve: circuits.isReserve,
remark: circuits.remark,
})
.from(circuits)
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(eq(circuitLists.projectId, projectId))
.orderBy(
asc(circuits.circuitListId),
asc(circuits.sortOrder),
asc(circuits.id)
)
.all();
const deviceRowRows = database
.select({
id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId,
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
legacyConsumerId: circuitDeviceRows.legacyConsumerId,
sortOrder: circuitDeviceRows.sortOrder,
name: circuitDeviceRows.name,
displayName: circuitDeviceRows.displayName,
phaseType: circuitDeviceRows.phaseType,
connectionKind: circuitDeviceRows.connectionKind,
costGroup: circuitDeviceRows.costGroup,
category: circuitDeviceRows.category,
level: circuitDeviceRows.level,
roomId: circuitDeviceRows.roomId,
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
quantity: circuitDeviceRows.quantity,
powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi,
remark: circuitDeviceRows.remark,
overriddenFields: circuitDeviceRows.overriddenFields,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(eq(circuitLists.projectId, projectId))
.orderBy(
asc(circuitDeviceRows.circuitId),
asc(circuitDeviceRows.sortOrder),
asc(circuitDeviceRows.id)
)
.all();
const projectDeviceRows = database
.select()
.from(projectDevices)
.where(eq(projectDevices.projectId, projectId))
.orderBy(asc(projectDevices.displayName), asc(projectDevices.id))
.all();
const floorRows = database
.select()
.from(floors)
.where(eq(floors.projectId, projectId))
.orderBy(asc(floors.sortOrder), asc(floors.id))
.all();
const roomRows = database
.select()
.from(rooms)
.where(eq(rooms.projectId, projectId))
.orderBy(asc(rooms.roomNumber), asc(rooms.id))
.all();
const deviceRowsByCircuit = new Map<string, typeof deviceRowRows>();
for (const row of deviceRowRows) {
const entries = deviceRowsByCircuit.get(row.circuitId) ?? [];
entries.push(row);
deviceRowsByCircuit.set(row.circuitId, entries);
}
const state = parseProjectStateSnapshot({
schemaVersion: projectStateSnapshotSchemaVersion,
project: {
id: project.id,
name: project.name,
singlePhaseVoltageV: project.singlePhaseVoltageV,
threePhaseVoltageV: project.threePhaseVoltageV,
},
distributionBoards: boardRows,
circuitLists: listRows,
circuitSections: sectionRows,
circuits: circuitRows.map((circuit) => ({
...circuit,
isReserve: Boolean(circuit.isReserve),
deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [],
})),
projectDevices: projectDeviceRows,
floors: floorRows,
rooms: roomRows,
});
const payloadJson = serializeProjectStateSnapshot(state);
return {
currentRevision: project.currentRevision,
state,
payloadJson,
payloadSha256: hashProjectStatePayload(payloadJson),
};
}
export function hashProjectStatePayload(payloadJson: string) {
return crypto.createHash("sha256").update(payloadJson).digest("hex");
}
@@ -0,0 +1,10 @@
export class ProjectStateConflictError extends Error {
constructor(
public readonly projectId: string,
public readonly expectedSha256: string,
public readonly actualSha256: string
) {
super(`Project ${projectId} changed outside the prepared restore state.`);
this.name = "ProjectStateConflictError";
}
}
@@ -0,0 +1,62 @@
import {
parseProjectStateSnapshot,
type ProjectStateSnapshot,
} from "./project-state-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectStateRestoreCommandType = "project.restore-state";
export const projectStateRestoreCommandSchemaVersion = 1 as const;
export interface ProjectStateRestoreCommandPayload {
expectedStateSha256: string;
targetState: ProjectStateSnapshot;
}
export type ProjectStateRestoreCommand = SerializedProjectCommand<
ProjectStateRestoreCommandPayload
> & {
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
type: typeof projectStateRestoreCommandType;
};
export function createProjectStateRestoreCommand(
expectedStateSha256: string,
targetState: ProjectStateSnapshot
): ProjectStateRestoreCommand {
const command: ProjectStateRestoreCommand = {
schemaVersion: projectStateRestoreCommandSchemaVersion,
type: projectStateRestoreCommandType,
payload: {
expectedStateSha256,
targetState: parseProjectStateSnapshot(targetState),
},
};
assertProjectStateRestoreCommand(command);
return command;
}
export function assertProjectStateRestoreCommand(
value: unknown
): asserts value is ProjectStateRestoreCommand {
if (!value || typeof value !== "object") {
throw new Error("Project state restore command must be an object.");
}
const command = value as Partial<ProjectStateRestoreCommand>;
if (
command.type !== projectStateRestoreCommandType ||
command.schemaVersion !== projectStateRestoreCommandSchemaVersion
) {
throw new Error("Unsupported project state restore command.");
}
if (!command.payload || typeof command.payload !== "object") {
throw new Error("Project state restore payload is required.");
}
const payload = command.payload as Partial<ProjectStateRestoreCommandPayload>;
if (
typeof payload.expectedStateSha256 !== "string" ||
!/^[a-f0-9]{64}$/.test(payload.expectedStateSha256)
) {
throw new Error("Expected project state checksum is invalid.");
}
parseProjectStateSnapshot(payload.targetState);
}
@@ -24,6 +24,9 @@ export interface ProjectCommandExecutor {
executeUser(
input: ExecuteUserProjectCommandInput
): ExecutedProjectCommand;
executeRestore(
input: ExecuteUserProjectCommandInput
): ExecutedProjectCommand;
undo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand;
@@ -18,9 +18,19 @@ export interface CreateNamedProjectSnapshotInput {
actorId?: string;
}
export interface PreparedProjectSnapshotRestore {
snapshot: ProjectSnapshotMetadata;
command: ProjectStateRestoreCommand;
}
export interface ProjectSnapshotStore {
createNamed(
input: CreateNamedProjectSnapshotInput
): ProjectSnapshotMetadata | null;
listByProject(projectId: string): ProjectSnapshotMetadata[] | null;
prepareRestore(
projectId: string,
snapshotId: string
): PreparedProjectSnapshotRestore | null;
}
import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js";
@@ -0,0 +1,26 @@
import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteProjectStateRestoreCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ProjectStateRestoreCommand;
}
export interface ExecutedProjectStateRestoreCommand {
revision: AppendedProjectRevision;
inverse: ProjectStateRestoreCommand;
}
export interface ProjectStateRestoreCommandStore {
execute(
input: ExecuteProjectStateRestoreCommandInput
): ExecutedProjectStateRestoreCommand;
}
+31 -1
View File
@@ -41,6 +41,10 @@ import {
assertSerializedProjectCommand,
type SerializedProjectCommand,
} from "../models/project-command.model.js";
import {
assertProjectStateRestoreCommand,
projectStateRestoreCommandType,
} from "../models/project-state-restore-command.model.js";
import {
assertProjectDeviceRowSyncProjectCommand,
projectDeviceRowSyncCommandType,
@@ -76,6 +80,7 @@ import type { ProjectDeviceProjectCommandStore } from "../ports/project-device-p
import type { ProjectDeviceRowSyncProjectCommandStore } from "../ports/project-device-row-sync-project-command.store.js";
import type { ProjectDeviceStructureProjectCommandStore } from "../ports/project-device-structure-project-command.store.js";
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js";
interface DispatchProjectCommandInput {
projectId: string;
@@ -84,7 +89,7 @@ interface DispatchProjectCommandInput {
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: SerializedProjectCommand;
command: SerializedProjectCommand<unknown>;
}
export class ProjectCommandService implements ProjectCommandExecutor {
@@ -99,6 +104,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
@@ -107,6 +113,11 @@ export class ProjectCommandService implements ProjectCommandExecutor {
): ExecutedProjectCommand {
assertExpectedRevision(input.expectedRevision);
assertSerializedProjectCommand(input.command);
if (input.command.type === projectStateRestoreCommandType) {
throw new Error(
"Project state restore commands require a stored server snapshot."
);
}
return this.dispatch({
...input,
source: "user",
@@ -114,6 +125,18 @@ export class ProjectCommandService implements ProjectCommandExecutor {
});
}
executeRestore(
input: ExecuteUserProjectCommandInput
): ExecutedProjectCommand {
assertExpectedRevision(input.expectedRevision);
assertProjectStateRestoreCommand(input.command);
return this.dispatch({
...input,
source: "restore",
command: input.command,
});
}
undo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand {
@@ -271,6 +294,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case projectStateRestoreCommandType: {
assertProjectStateRestoreCommand(input.command);
return this.projectStateRestoreStore.execute({
...input,
command: input.command,
}).revision;
}
default:
throw new Error(
`Unsupported project command type: ${input.command.type}.`
@@ -10,6 +10,7 @@ import { ProjectHistoryRepository } from "../../db/repositories/project-history.
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-project-command.repository.js";
import { ProjectDeviceStructureProjectCommandRepository } from "../../db/repositories/project-device-structure-project-command.repository.js";
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
@@ -31,6 +32,8 @@ export const projectDeviceProjectCommandStore =
new ProjectDeviceProjectCommandRepository(db);
export const projectDeviceRowSyncProjectCommandStore =
new ProjectDeviceRowSyncProjectCommandRepository(db);
export const projectStateRestoreCommandStore =
new ProjectStateRestoreCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
@@ -43,5 +46,6 @@ export const projectCommandService = new ProjectCommandService(
projectDeviceStructureProjectCommandStore,
projectDeviceProjectCommandStore,
projectDeviceRowSyncProjectCommandStore,
projectStateRestoreCommandStore,
projectHistoryStore
);
@@ -1,7 +1,12 @@
import type { NextFunction, Request, Response } from "express";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
import { createNamedProjectSnapshotSchema } from "../../shared/validation/project-snapshot.schemas.js";
import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js";
import {
createNamedProjectSnapshotSchema,
restoreProjectSnapshotSchema,
} from "../../shared/validation/project-snapshot.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { projectSnapshotStore } from "../composition/project-snapshot-store.js";
export function listProjectSnapshots(req: Request, res: Response) {
@@ -59,6 +64,62 @@ export function createNamedProjectSnapshot(
}
}
export function restoreProjectSnapshot(
req: Request,
res: Response,
next: NextFunction
) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const { snapshotId } = req.params;
if (typeof snapshotId !== "string" || !snapshotId.trim()) {
return res.status(400).json({ error: "Invalid snapshotId" });
}
const parsed = restoreProjectSnapshotSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const prepared = projectSnapshotStore.prepareRestore(
projectId,
snapshotId
);
if (!prepared) {
return res.status(404).json({ error: "Project snapshot not found" });
}
const result = projectCommandService.executeRestore({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: `Snapshot "${prepared.snapshot.name}" wiederherstellen`,
command: prepared.command,
});
return res.json({
snapshot: prepared.snapshot,
revision: result.revision,
history: result.history,
});
} catch (error) {
if (error instanceof ProjectRevisionConflictError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_REVISION_CONFLICT",
expectedRevision: error.expectedRevision,
currentRevision: error.actualRevision,
});
}
if (error instanceof ProjectStateConflictError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_STATE_CONFLICT",
});
}
return next(error);
}
}
function getProjectId(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
+5
View File
@@ -25,6 +25,7 @@ import {
import {
createNamedProjectSnapshot,
listProjectSnapshots,
restoreProjectSnapshot,
} from "../controllers/project-snapshot.controller.js";
export const projectRouter = Router();
@@ -39,6 +40,10 @@ projectRouter.post("/:projectId/history/undo", undoProjectCommand);
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
projectRouter.get("/:projectId/snapshots", listProjectSnapshots);
projectRouter.post("/:projectId/snapshots", createNamedProjectSnapshot);
projectRouter.post(
"/:projectId/snapshots/:snapshotId/restore",
restoreProjectSnapshot
);
projectRouter.put("/:projectId", updateProjectSettings);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
@@ -8,3 +8,9 @@ export const createNamedProjectSnapshotSchema = z
description: z.string().trim().max(500).optional(),
})
.strict();
export const restoreProjectSnapshotSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
})
.strict();