Add portable project transfer API
This commit is contained in:
@@ -226,6 +226,16 @@ fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der global
|
|||||||
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
||||||
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
||||||
damit Änderungen weiterhin niemals still synchronisiert werden.
|
damit Änderungen weiterhin niemals still synchronisiert werden.
|
||||||
|
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
||||||
|
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
||||||
|
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
||||||
|
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
|
||||||
|
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
||||||
|
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
||||||
|
Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu
|
||||||
|
zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
|
||||||
|
Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen
|
||||||
|
innerhalb des unterstützten Laufzeitmodells bleiben erhalten.
|
||||||
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
||||||
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
||||||
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
createProjectTransferEnvelope,
|
||||||
|
parseProjectTransferEnvelope,
|
||||||
|
remapProjectState,
|
||||||
|
} from "../../domain/models/project-transfer.model.js";
|
||||||
|
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||||
|
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.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 { projects } from "../schema/projects.js";
|
||||||
|
import { rooms } from "../schema/rooms.js";
|
||||||
|
import {
|
||||||
|
hashProjectStatePayload,
|
||||||
|
readProjectStateSnapshot,
|
||||||
|
} from "./project-state-snapshot.persistence.js";
|
||||||
|
import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js";
|
||||||
|
|
||||||
|
export class ProjectTransferRepository implements ProjectTransferStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
exportProject(projectId: string) {
|
||||||
|
const snapshot = readProjectStateSnapshot(this.database, projectId);
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createProjectTransferEnvelope(
|
||||||
|
new Date().toISOString(),
|
||||||
|
snapshot.payloadSha256,
|
||||||
|
snapshot.state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareReplace(projectId: string, value: unknown) {
|
||||||
|
const transfer = this.parseVerified(value);
|
||||||
|
const current = readProjectStateSnapshot(this.database, projectId);
|
||||||
|
if (!current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const targetState =
|
||||||
|
transfer.projectState.project.id === projectId
|
||||||
|
? transfer.projectState
|
||||||
|
: remapProjectState(
|
||||||
|
transfer.projectState,
|
||||||
|
projectId,
|
||||||
|
() => crypto.randomUUID()
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
command: createProjectStateRestoreCommand(
|
||||||
|
current.payloadSha256,
|
||||||
|
targetState
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
importDuplicate(value: unknown) {
|
||||||
|
const transfer = this.parseVerified(value);
|
||||||
|
const projectId = crypto.randomUUID();
|
||||||
|
const state = remapProjectState(
|
||||||
|
transfer.projectState,
|
||||||
|
projectId,
|
||||||
|
() => crypto.randomUUID(),
|
||||||
|
`${transfer.projectState.project.name} (Kopie)`
|
||||||
|
);
|
||||||
|
this.database.transaction((tx) => insertProjectState(tx, state));
|
||||||
|
return { projectId, name: state.project.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseVerified(value: unknown) {
|
||||||
|
const transfer = parseProjectTransferEnvelope(value);
|
||||||
|
const payloadJson = serializeProjectStateSnapshot(
|
||||||
|
transfer.projectState
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256
|
||||||
|
) {
|
||||||
|
throw new Error("Project transfer checksum verification failed.");
|
||||||
|
}
|
||||||
|
return transfer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertProjectState(
|
||||||
|
database: AppDatabase,
|
||||||
|
state: ReturnType<typeof remapProjectState>
|
||||||
|
) {
|
||||||
|
const existing = database
|
||||||
|
.select({ id: projects.id })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, state.project.id))
|
||||||
|
.get();
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("Project transfer target already exists.");
|
||||||
|
}
|
||||||
|
database
|
||||||
|
.insert(projects)
|
||||||
|
.values({ ...state.project, currentRevision: 0 })
|
||||||
|
.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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||||
|
database: AppDatabase,
|
||||||
|
table: TTable,
|
||||||
|
values: unknown[]
|
||||||
|
) {
|
||||||
|
if (values.length > 0) {
|
||||||
|
database.insert(table).values(values as never).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import {
|
||||||
|
parseProjectStateSnapshot,
|
||||||
|
projectStateSnapshotSchemaVersion,
|
||||||
|
type ProjectStateSnapshot,
|
||||||
|
} from "./project-state-snapshot.model.js";
|
||||||
|
|
||||||
|
export const projectTransferFormat = "leistungsbilanz.project" as const;
|
||||||
|
export const projectTransferFormatVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface ProjectTransferEnvelope {
|
||||||
|
format: typeof projectTransferFormat;
|
||||||
|
formatVersion: typeof projectTransferFormatVersion;
|
||||||
|
exportedAtIso: string;
|
||||||
|
payloadSha256: string;
|
||||||
|
projectState: ProjectStateSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProjectTransferEnvelope(
|
||||||
|
exportedAtIso: string,
|
||||||
|
payloadSha256: string,
|
||||||
|
projectState: ProjectStateSnapshot
|
||||||
|
): ProjectTransferEnvelope {
|
||||||
|
return parseProjectTransferEnvelope({
|
||||||
|
format: projectTransferFormat,
|
||||||
|
formatVersion: projectTransferFormatVersion,
|
||||||
|
exportedAtIso,
|
||||||
|
payloadSha256,
|
||||||
|
projectState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseProjectTransferEnvelope(
|
||||||
|
value: unknown
|
||||||
|
): ProjectTransferEnvelope {
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
throw new Error("Project transfer file must be an object.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value.format !== projectTransferFormat ||
|
||||||
|
value.formatVersion !== projectTransferFormatVersion
|
||||||
|
) {
|
||||||
|
throw new Error("Project transfer format is not supported.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value.exportedAtIso !== "string" ||
|
||||||
|
!Number.isFinite(Date.parse(value.exportedAtIso))
|
||||||
|
) {
|
||||||
|
throw new Error("Project transfer timestamp is invalid.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value.payloadSha256 !== "string" ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(value.payloadSha256)
|
||||||
|
) {
|
||||||
|
throw new Error("Project transfer checksum is invalid.");
|
||||||
|
}
|
||||||
|
const projectState = parseProjectStateSnapshot(value.projectState);
|
||||||
|
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
||||||
|
throw new Error("Project transfer snapshot version is not supported.");
|
||||||
|
}
|
||||||
|
if (Object.keys(value).length !== 5) {
|
||||||
|
throw new Error("Project transfer file contains unknown fields.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
format: projectTransferFormat,
|
||||||
|
formatVersion: projectTransferFormatVersion,
|
||||||
|
exportedAtIso: value.exportedAtIso,
|
||||||
|
payloadSha256: value.payloadSha256,
|
||||||
|
projectState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remapProjectState(
|
||||||
|
source: ProjectStateSnapshot,
|
||||||
|
targetProjectId: string,
|
||||||
|
createId: () => string,
|
||||||
|
name: string = source.project.name
|
||||||
|
): ProjectStateSnapshot {
|
||||||
|
const boardIds = idMap(source.distributionBoards, createId);
|
||||||
|
const listIds = idMap(source.circuitLists, createId);
|
||||||
|
const sectionIds = idMap(source.circuitSections, createId);
|
||||||
|
const circuitIds = idMap(source.circuits, createId);
|
||||||
|
const deviceIds = idMap(source.projectDevices, createId);
|
||||||
|
const floorIds = idMap(source.floors, createId);
|
||||||
|
const roomIds = idMap(source.rooms, createId);
|
||||||
|
const rowIds = new Map(
|
||||||
|
source.circuits.flatMap((circuit) =>
|
||||||
|
circuit.deviceRows.map((row) => [row.id, createId()] as const)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return parseProjectStateSnapshot({
|
||||||
|
...source,
|
||||||
|
project: { ...source.project, id: targetProjectId, name },
|
||||||
|
distributionBoards: source.distributionBoards.map((board) => ({
|
||||||
|
...board,
|
||||||
|
id: requiredId(boardIds, board.id),
|
||||||
|
projectId: targetProjectId,
|
||||||
|
})),
|
||||||
|
circuitLists: source.circuitLists.map((list) => ({
|
||||||
|
...list,
|
||||||
|
id: requiredId(listIds, list.id),
|
||||||
|
projectId: targetProjectId,
|
||||||
|
distributionBoardId: requiredId(boardIds, list.distributionBoardId),
|
||||||
|
})),
|
||||||
|
circuitSections: source.circuitSections.map((section) => ({
|
||||||
|
...section,
|
||||||
|
id: requiredId(sectionIds, section.id),
|
||||||
|
circuitListId: requiredId(listIds, section.circuitListId),
|
||||||
|
})),
|
||||||
|
circuits: source.circuits.map((circuit) => ({
|
||||||
|
...circuit,
|
||||||
|
id: requiredId(circuitIds, circuit.id),
|
||||||
|
circuitListId: requiredId(listIds, circuit.circuitListId),
|
||||||
|
sectionId: requiredId(sectionIds, circuit.sectionId),
|
||||||
|
deviceRows: circuit.deviceRows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
id: requiredId(rowIds, row.id),
|
||||||
|
circuitId: requiredId(circuitIds, circuit.id),
|
||||||
|
linkedProjectDeviceId:
|
||||||
|
row.linkedProjectDeviceId === null
|
||||||
|
? null
|
||||||
|
: requiredId(deviceIds, row.linkedProjectDeviceId),
|
||||||
|
legacyConsumerId: null,
|
||||||
|
roomId:
|
||||||
|
row.roomId === null ? null : requiredId(roomIds, row.roomId),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
projectDevices: source.projectDevices.map((device) => ({
|
||||||
|
...device,
|
||||||
|
id: requiredId(deviceIds, device.id),
|
||||||
|
projectId: targetProjectId,
|
||||||
|
})),
|
||||||
|
floors: source.floors.map((floor) => ({
|
||||||
|
...floor,
|
||||||
|
id: requiredId(floorIds, floor.id),
|
||||||
|
projectId: targetProjectId,
|
||||||
|
})),
|
||||||
|
rooms: source.rooms.map((room) => ({
|
||||||
|
...room,
|
||||||
|
id: requiredId(roomIds, room.id),
|
||||||
|
projectId: targetProjectId,
|
||||||
|
floorId:
|
||||||
|
room.floorId === null ? null : requiredId(floorIds, room.floorId),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function idMap(
|
||||||
|
entries: ReadonlyArray<{ id: string }>,
|
||||||
|
createId: () => string
|
||||||
|
) {
|
||||||
|
return new Map(entries.map((entry) => [entry.id, createId()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredId(ids: ReadonlyMap<string, string>, sourceId: string) {
|
||||||
|
const targetId = ids.get(sourceId);
|
||||||
|
if (!targetId) {
|
||||||
|
throw new Error(`Project transfer reference "${sourceId}" is invalid.`);
|
||||||
|
}
|
||||||
|
return targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js";
|
||||||
|
import type { ProjectTransferEnvelope } from "../models/project-transfer.model.js";
|
||||||
|
|
||||||
|
export interface PreparedProjectTransferReplace {
|
||||||
|
command: ProjectStateRestoreCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTransferStore {
|
||||||
|
exportProject(projectId: string): ProjectTransferEnvelope | null;
|
||||||
|
prepareReplace(
|
||||||
|
projectId: string,
|
||||||
|
transfer: unknown
|
||||||
|
): PreparedProjectTransferReplace | null;
|
||||||
|
importDuplicate(transfer: unknown): { projectId: string; name: string };
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { db } from "../../db/client.js";
|
||||||
|
import { ProjectTransferRepository } from "../../db/repositories/project-transfer.repository.js";
|
||||||
|
|
||||||
|
export const projectTransferStore = new ProjectTransferRepository(db);
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||||
|
import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js";
|
||||||
|
import { importProjectTransferSchema } from "../../shared/validation/project-transfer.schemas.js";
|
||||||
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
|
import { projectTransferStore } from "../composition/project-transfer-store.js";
|
||||||
|
|
||||||
|
export function exportProjectTransfer(req: Request, res: Response) {
|
||||||
|
const projectId = getProjectId(req, res);
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const transfer = projectTransferStore.exportProject(projectId);
|
||||||
|
if (!transfer) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
const filename = safeFilename(transfer.projectState.project.name);
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${filename}.leistungsbilanz.json"`
|
||||||
|
);
|
||||||
|
return res.json(transfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importProjectTransfer(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const projectId = getProjectId(req, res);
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = importProjectTransferSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (parsed.data.mode === "duplicate") {
|
||||||
|
return res
|
||||||
|
.status(201)
|
||||||
|
.json(projectTransferStore.importDuplicate(parsed.data.transfer));
|
||||||
|
}
|
||||||
|
const prepared = projectTransferStore.prepareReplace(
|
||||||
|
projectId,
|
||||||
|
parsed.data.transfer
|
||||||
|
);
|
||||||
|
if (!prepared) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
const result = projectCommandService.executeRestore({
|
||||||
|
projectId,
|
||||||
|
expectedRevision: parsed.data.expectedRevision,
|
||||||
|
description: "Projektimport wiederherstellen",
|
||||||
|
command: prepared.command,
|
||||||
|
});
|
||||||
|
return res.json(result);
|
||||||
|
} 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" });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message.startsWith("Project transfer") ||
|
||||||
|
error.message.startsWith("Snapshot"))
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectId(req: Request, res: Response) {
|
||||||
|
const { projectId } = req.params;
|
||||||
|
if (typeof projectId !== "string" || !projectId.trim()) {
|
||||||
|
res.status(400).json({ error: "Invalid projectId" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFilename(name: string) {
|
||||||
|
return (
|
||||||
|
name
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 80) || "projekt"
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -8,7 +8,7 @@ import { errorMiddleware } from "./middleware/error.middleware.js";
|
|||||||
const app = express();
|
const app = express();
|
||||||
const port = Number(process.env.PORT || 3000);
|
const port = Number(process.env.PORT || 3000);
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: "25mb" }));
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
|||||||
@@ -27,12 +27,18 @@ import {
|
|||||||
listProjectSnapshots,
|
listProjectSnapshots,
|
||||||
restoreProjectSnapshot,
|
restoreProjectSnapshot,
|
||||||
} from "../controllers/project-snapshot.controller.js";
|
} from "../controllers/project-snapshot.controller.js";
|
||||||
|
import {
|
||||||
|
exportProjectTransfer,
|
||||||
|
importProjectTransfer,
|
||||||
|
} from "../controllers/project-transfer.controller.js";
|
||||||
|
|
||||||
export const projectRouter = Router();
|
export const projectRouter = Router();
|
||||||
|
|
||||||
projectRouter.get("/", listProjects);
|
projectRouter.get("/", listProjects);
|
||||||
projectRouter.post("/", createProject);
|
projectRouter.post("/", createProject);
|
||||||
projectRouter.get("/:projectId", getProject);
|
projectRouter.get("/:projectId", getProject);
|
||||||
|
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
||||||
|
projectRouter.post("/:projectId/import", importProjectTransfer);
|
||||||
projectRouter.get("/:projectId/history", getProjectHistory);
|
projectRouter.get("/:projectId/history", getProjectHistory);
|
||||||
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
||||||
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||||
|
|
||||||
|
export const importProjectTransferSchema = z.discriminatedUnion("mode", [
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
mode: z.literal("replace"),
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
transfer: z.unknown(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
mode: z.literal("duplicate"),
|
||||||
|
transfer: z.unknown(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
]);
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
} from "../src/db/database-context.js";
|
} from "../src/db/database-context.js";
|
||||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.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 { 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";
|
||||||
@@ -317,3 +319,100 @@ describe("project snapshot repository", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("portable project transfer", () => {
|
||||||
|
it("exports and atomically duplicates a complete project with new ids", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectTransferRepository(context.db);
|
||||||
|
const transfer = repository.exportProject("project-1");
|
||||||
|
assert.ok(transfer);
|
||||||
|
const duplicate = repository.importDuplicate(transfer);
|
||||||
|
assert.notEqual(duplicate.projectId, "project-1");
|
||||||
|
assert.equal(duplicate.name, "Snapshot-Projekt (Kopie)");
|
||||||
|
|
||||||
|
const source = readProjectStateSnapshot(context.db, "project-1");
|
||||||
|
const copied = readProjectStateSnapshot(
|
||||||
|
context.db,
|
||||||
|
duplicate.projectId
|
||||||
|
);
|
||||||
|
assert.ok(source);
|
||||||
|
assert.ok(copied);
|
||||||
|
assert.equal(copied.currentRevision, 0);
|
||||||
|
assert.equal(copied.state.circuits.length, source.state.circuits.length);
|
||||||
|
assert.notEqual(
|
||||||
|
copied.state.circuits[0].id,
|
||||||
|
source.state.circuits[0].id
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
copied.state.circuits[0].deviceRows[0].linkedProjectDeviceId,
|
||||||
|
copied.state.projectDevices[0].id
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
copied.state.circuits[0].deviceRows[0].roomId,
|
||||||
|
copied.state.rooms[0].id
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
copied.state.circuits[0].deviceRows[0].legacyConsumerId,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prepares same-project replacement and rejects damaged payloads", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectTransferRepository(context.db);
|
||||||
|
const transfer = repository.exportProject("project-1");
|
||||||
|
assert.ok(transfer);
|
||||||
|
const prepared = repository.prepareReplace("project-1", transfer);
|
||||||
|
assert.ok(prepared);
|
||||||
|
assert.equal(
|
||||||
|
prepared.command.payload.targetState.project.id,
|
||||||
|
"project-1"
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.importDuplicate({
|
||||||
|
...transfer,
|
||||||
|
payloadSha256: "0".repeat(64),
|
||||||
|
}),
|
||||||
|
/checksum verification failed/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projects).all().length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back a duplicate when a late project-state insert fails", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository = new ProjectTransferRepository(context.db);
|
||||||
|
const transfer = repository.exportProject("project-1");
|
||||||
|
assert.ok(transfer);
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_imported_device_row
|
||||||
|
BEFORE INSERT ON circuit_device_rows
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'forced project transfer failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
assert.throws(
|
||||||
|
() => repository.importDuplicate(transfer),
|
||||||
|
/forced project transfer failure/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projects).all().length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user