Add project transfer controls
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
createRoom,
|
createRoom,
|
||||||
deleteProjectDevice,
|
deleteProjectDevice,
|
||||||
disconnectProjectDeviceRows,
|
disconnectProjectDeviceRows,
|
||||||
|
exportProjectTransfer,
|
||||||
getProjectDeviceSyncPreview,
|
getProjectDeviceSyncPreview,
|
||||||
listCircuitLists,
|
listCircuitLists,
|
||||||
listDistributionBoards,
|
listDistributionBoards,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
listProjectDevices,
|
listProjectDevices,
|
||||||
listProjects,
|
listProjects,
|
||||||
listRooms,
|
listRooms,
|
||||||
|
importProjectTransfer,
|
||||||
synchronizeProjectDeviceRows,
|
synchronizeProjectDeviceRows,
|
||||||
updateProjectDevice,
|
updateProjectDevice,
|
||||||
updateProjectSettings,
|
updateProjectSettings,
|
||||||
@@ -349,6 +351,67 @@ export default function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleExportProject() {
|
||||||
|
if (!project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const transfer = await exportProjectTransfer(project.id);
|
||||||
|
const blob = new Blob([JSON.stringify(transfer, null, 2)], {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `${project.name.replace(/[^a-zA-Z0-9_-]+/g, "-") || "projekt"}.leistungsbilanz.json`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Projekt konnte nicht exportiert werden."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportProject(
|
||||||
|
transfer: unknown,
|
||||||
|
mode: "replace" | "duplicate"
|
||||||
|
) {
|
||||||
|
if (!project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await importProjectTransfer(
|
||||||
|
project.id,
|
||||||
|
mode,
|
||||||
|
project.currentRevision,
|
||||||
|
transfer
|
||||||
|
);
|
||||||
|
if (mode === "duplicate" && "projectId" in result) {
|
||||||
|
window.location.href = `/projects/${result.projectId}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Projektdatei konnte nicht importiert werden."
|
||||||
|
);
|
||||||
|
setIsProjectSettingsOpen(false);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openProjectDeviceSyncPreview(projectDeviceId: string) {
|
async function openProjectDeviceSyncPreview(projectDeviceId: string) {
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
return;
|
return;
|
||||||
@@ -1015,6 +1078,8 @@ export default function ProjectDetailPage() {
|
|||||||
<ProjectSettingsModal
|
<ProjectSettingsModal
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
onClose={() => setIsProjectSettingsOpen(false)}
|
onClose={() => setIsProjectSettingsOpen(false)}
|
||||||
|
onExport={handleExportProject}
|
||||||
|
onImport={handleImportProject}
|
||||||
onSave={handleSaveProjectSettings}
|
onSave={handleSaveProjectSettings}
|
||||||
project={project}
|
project={project}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
} from "../../domain/models/project-state-restore-command.model.js";
|
} from "../../domain/models/project-state-restore-command.model.js";
|
||||||
import {
|
import {
|
||||||
parseProjectStateSnapshot,
|
parseProjectStateSnapshot,
|
||||||
serializeProjectStateSnapshot,
|
|
||||||
type ProjectStateSnapshot,
|
type ProjectStateSnapshot,
|
||||||
} from "../../domain/models/project-state-snapshot.model.js";
|
} from "../../domain/models/project-state-snapshot.model.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -24,11 +23,11 @@ import { floors } from "../schema/floors.js";
|
|||||||
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
||||||
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
|
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import {
|
import {
|
||||||
hashProjectStatePayload,
|
|
||||||
readProjectStateSnapshot,
|
readProjectStateSnapshot,
|
||||||
} from "./project-state-snapshot.persistence.js";
|
} from "./project-state-snapshot.persistence.js";
|
||||||
|
|
||||||
@@ -63,7 +62,8 @@ export class ProjectStateRestoreCommandRepository
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
current.payloadSha256 !==
|
current.payloadSha256 !==
|
||||||
input.command.payload.expectedStateSha256
|
input.command.payload.expectedStateSha256 &&
|
||||||
|
!matchesHistoryRestoreTarget(tx, input, current.state)
|
||||||
) {
|
) {
|
||||||
throw new ProjectStateConflictError(
|
throw new ProjectStateConflictError(
|
||||||
input.projectId,
|
input.projectId,
|
||||||
@@ -75,18 +75,90 @@ export class ProjectStateRestoreCommandRepository
|
|||||||
const targetState = parseProjectStateSnapshot(
|
const targetState = parseProjectStateSnapshot(
|
||||||
input.command.payload.targetState
|
input.command.payload.targetState
|
||||||
);
|
);
|
||||||
const targetPayloadJson = serializeProjectStateSnapshot(targetState);
|
replaceProjectState(tx, targetState);
|
||||||
|
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
|
||||||
|
if (!persistedTarget) {
|
||||||
|
throw new Error("Restored project could not be loaded.");
|
||||||
|
}
|
||||||
const inverse = createProjectStateRestoreCommand(
|
const inverse = createProjectStateRestoreCommand(
|
||||||
hashProjectStatePayload(targetPayloadJson),
|
persistedTarget.payloadSha256,
|
||||||
current.state
|
current.state
|
||||||
);
|
);
|
||||||
|
|
||||||
replaceProjectState(tx, targetState);
|
|
||||||
|
|
||||||
return inverse;
|
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),
|
||||||
|
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 replaceProjectState(
|
function replaceProjectState(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
state: ProjectStateSnapshot
|
state: ProjectStateSnapshot
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export interface ProjectSettingsInput {
|
|||||||
interface ProjectSettingsModalProps {
|
interface ProjectSettingsModalProps {
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onExport: () => Promise<void>;
|
||||||
|
onImport: (
|
||||||
|
transfer: unknown,
|
||||||
|
mode: "replace" | "duplicate"
|
||||||
|
) => Promise<void>;
|
||||||
onSave: (input: ProjectSettingsInput) => Promise<void>;
|
onSave: (input: ProjectSettingsInput) => Promise<void>;
|
||||||
project: ProjectDto;
|
project: ProjectDto;
|
||||||
}
|
}
|
||||||
@@ -23,6 +28,8 @@ interface ProjectSettingsModalProps {
|
|||||||
export function ProjectSettingsModal({
|
export function ProjectSettingsModal({
|
||||||
isSaving,
|
isSaving,
|
||||||
onClose,
|
onClose,
|
||||||
|
onExport,
|
||||||
|
onImport,
|
||||||
onSave,
|
onSave,
|
||||||
project,
|
project,
|
||||||
}: ProjectSettingsModalProps) {
|
}: ProjectSettingsModalProps) {
|
||||||
@@ -45,6 +52,13 @@ export function ProjectSettingsModal({
|
|||||||
const [threePhaseVoltageV, setThreePhaseVoltageV] = useState(
|
const [threePhaseVoltageV, setThreePhaseVoltageV] = useState(
|
||||||
String(project.threePhaseVoltageV)
|
String(project.threePhaseVoltageV)
|
||||||
);
|
);
|
||||||
|
const [transfer, setTransfer] = useState<unknown>(null);
|
||||||
|
const [transferFilename, setTransferFilename] = useState("");
|
||||||
|
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
|
||||||
|
"duplicate"
|
||||||
|
);
|
||||||
|
const [replaceConfirmed, setReplaceConfirmed] = useState(false);
|
||||||
|
const [fileError, setFileError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -64,6 +78,21 @@ export function ProjectSettingsModal({
|
|||||||
Number(singlePhaseVoltageV) > 0 &&
|
Number(singlePhaseVoltageV) > 0 &&
|
||||||
Number(threePhaseVoltageV) > 0;
|
Number(threePhaseVoltageV) > 0;
|
||||||
|
|
||||||
|
async function handleTransferFile(file: File | undefined) {
|
||||||
|
setTransfer(null);
|
||||||
|
setTransferFilename("");
|
||||||
|
setFileError(null);
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setTransfer(JSON.parse(await file.text()) as unknown);
|
||||||
|
setTransferFilename(file.name);
|
||||||
|
} catch {
|
||||||
|
setFileError("Die ausgewählte Datei enthält kein gültiges JSON.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -193,6 +222,99 @@ export function ProjectSettingsModal({
|
|||||||
value={threePhaseVoltageV}
|
value={threePhaseVoltageV}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<hr className="my-2" />
|
||||||
|
<h3 className="h6">Projekt importieren oder exportieren</h3>
|
||||||
|
<p className="text-secondary small">
|
||||||
|
Der Export enthält den vollständigen unterstützten
|
||||||
|
Projektzustand. Beim Duplizieren bleibt dieses Projekt
|
||||||
|
unverändert.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary mb-3"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => void onExport()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Projektdatei herunterladen
|
||||||
|
</button>
|
||||||
|
<label className="form-label d-block" htmlFor="project-import">
|
||||||
|
Projektdatei auswählen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
accept=".json,application/json"
|
||||||
|
className="form-control"
|
||||||
|
id="project-import"
|
||||||
|
onChange={(event) =>
|
||||||
|
void handleTransferFile(event.target.files?.[0])
|
||||||
|
}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
{transferFilename ? (
|
||||||
|
<div className="form-text">{transferFilename} ist bereit.</div>
|
||||||
|
) : null}
|
||||||
|
{fileError ? (
|
||||||
|
<div className="text-danger small mt-1">{fileError}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="d-flex flex-wrap gap-3 mt-3">
|
||||||
|
<label className="form-check">
|
||||||
|
<input
|
||||||
|
checked={importMode === "duplicate"}
|
||||||
|
className="form-check-input"
|
||||||
|
name="import-mode"
|
||||||
|
onChange={() => setImportMode("duplicate")}
|
||||||
|
type="radio"
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">
|
||||||
|
Als neues Projekt duplizieren
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="form-check">
|
||||||
|
<input
|
||||||
|
checked={importMode === "replace"}
|
||||||
|
className="form-check-input"
|
||||||
|
name="import-mode"
|
||||||
|
onChange={() => setImportMode("replace")}
|
||||||
|
type="radio"
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">
|
||||||
|
Dieses Projekt ersetzen
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{importMode === "replace" ? (
|
||||||
|
<label className="form-check mt-2">
|
||||||
|
<input
|
||||||
|
checked={replaceConfirmed}
|
||||||
|
className="form-check-input"
|
||||||
|
onChange={(event) =>
|
||||||
|
setReplaceConfirmed(event.target.checked)
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span className="form-check-label text-danger">
|
||||||
|
Ich bestätige, dass der aktuelle Projektstand ersetzt
|
||||||
|
wird.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger mt-3"
|
||||||
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
transfer === null ||
|
||||||
|
(importMode === "replace" && !replaceConfirmed)
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
transfer === null
|
||||||
|
? undefined
|
||||||
|
: void onImport(transfer, importMode)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Projektdatei importieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
|
|||||||
@@ -229,6 +229,29 @@ export function updateProjectSettings(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exportProjectTransfer(projectId: string) {
|
||||||
|
return request<unknown>(`/api/projects/${projectId}/export`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importProjectTransfer(
|
||||||
|
projectId: string,
|
||||||
|
mode: "replace" | "duplicate",
|
||||||
|
expectedRevision: number,
|
||||||
|
transfer: unknown
|
||||||
|
) {
|
||||||
|
return request<
|
||||||
|
| { projectId: string; name: string }
|
||||||
|
| ProjectSettingsCommandResultDto
|
||||||
|
>(`/api/projects/${projectId}/import`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(
|
||||||
|
mode === "replace"
|
||||||
|
? { mode, expectedRevision, transfer }
|
||||||
|
: { mode, transfer }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listDistributionBoards(projectId: string) {
|
export function listDistributionBoards(projectId: string) {
|
||||||
return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`);
|
return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/reposi
|
|||||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
|
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
|
||||||
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
|
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
|
||||||
|
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
|
||||||
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
||||||
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
|
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
|
||||||
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
|
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
@@ -37,6 +37,12 @@ import { projectDevices } from "../src/db/schema/project-devices.js";
|
|||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
|
import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
|
||||||
|
import {
|
||||||
|
hashProjectStatePayload,
|
||||||
|
readProjectStateSnapshot,
|
||||||
|
} from "../src/db/repositories/project-state-snapshot.persistence.js";
|
||||||
|
import { serializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
|
||||||
import { rooms } from "../src/db/schema/rooms.js";
|
import { rooms } from "../src/db/schema/rooms.js";
|
||||||
import { ProjectStateConflictError } from "../src/domain/errors/project-state-conflict.error.js";
|
import { ProjectStateConflictError } from "../src/domain/errors/project-state-conflict.error.js";
|
||||||
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
|
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
|
||||||
@@ -253,6 +259,63 @@ function seedUpgradeOnlyData(context: DatabaseContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("project state restore command", () => {
|
describe("project state restore command", () => {
|
||||||
|
it("undoes a remapped cross-project import using the persisted target hash", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const transfers = new ProjectTransferRepository(context.db);
|
||||||
|
const transfer = transfers.exportProject("project-1");
|
||||||
|
assert.ok(transfer);
|
||||||
|
const duplicate = transfers.importDuplicate(transfer);
|
||||||
|
const before = readProjectStateSnapshot(
|
||||||
|
context.db,
|
||||||
|
duplicate.projectId
|
||||||
|
);
|
||||||
|
assert.ok(before);
|
||||||
|
const prepared = transfers.prepareReplace(
|
||||||
|
duplicate.projectId,
|
||||||
|
transfer
|
||||||
|
);
|
||||||
|
assert.ok(prepared);
|
||||||
|
const service = createService(context);
|
||||||
|
const restored = service.executeRestore({
|
||||||
|
projectId: duplicate.projectId,
|
||||||
|
expectedRevision: 0,
|
||||||
|
description: "Projektimport wiederherstellen",
|
||||||
|
command: prepared.command,
|
||||||
|
});
|
||||||
|
const legacyExpectedHash = hashProjectStatePayload(
|
||||||
|
serializeProjectStateSnapshot(
|
||||||
|
prepared.command.payload.targetState
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const storedChangeSet = context.db
|
||||||
|
.select()
|
||||||
|
.from(projectChangeSets)
|
||||||
|
.where(eq(projectChangeSets.id, restored.revision.changeSetId))
|
||||||
|
.get();
|
||||||
|
assert.ok(storedChangeSet);
|
||||||
|
const inverse = JSON.parse(storedChangeSet.inversePayloadJson);
|
||||||
|
inverse.payload.expectedStateSha256 = legacyExpectedHash;
|
||||||
|
context.db
|
||||||
|
.update(projectChangeSets)
|
||||||
|
.set({ inversePayloadJson: JSON.stringify(inverse) })
|
||||||
|
.where(eq(projectChangeSets.id, storedChangeSet.id))
|
||||||
|
.run();
|
||||||
|
const undone = service.undo({
|
||||||
|
projectId: duplicate.projectId,
|
||||||
|
expectedRevision: 1,
|
||||||
|
});
|
||||||
|
assert.equal(undone.revision.revisionNumber, 2);
|
||||||
|
assert.equal(
|
||||||
|
readProjectStateSnapshot(context.db, duplicate.projectId)
|
||||||
|
?.payloadSha256,
|
||||||
|
before.payloadSha256
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("restores a complete snapshot as a revision and supports undo and redo", () => {
|
it("restores a complete snapshot as a revision and supports undo and redo", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user