From 384a5769abde4c1043f0c3c983f0e53f8f345437 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 25 Jul 2026 22:41:24 +0200 Subject: [PATCH] Restore named project snapshots --- AGENTS.md | 10 +- docs/circuit-list-editor-api.md | 10 + docs/circuit-list-editor-interactions.md | 5 +- docs/circuit-list-editor-known-limitations.md | 5 +- docs/current-architecture.md | 11 +- ...history-and-external-model-architecture.md | 11 +- docs/spec/07-implementation-phases-todo.md | 3 +- package.json | 4 +- .../project-history.persistence.ts | 2 +- .../project-snapshot.repository.ts | 252 +++------- ...roject-state-restore-command.repository.ts | 290 +++++++++++ .../project-state-snapshot.persistence.ts | 201 ++++++++ .../errors/project-state-conflict.error.ts | 10 + .../project-state-restore-command.model.ts | 62 +++ src/domain/ports/project-command.executor.ts | 3 + src/domain/ports/project-snapshot.store.ts | 10 + .../project-state-restore-command.store.ts | 26 + .../services/project-command.service.ts | 32 +- .../composition/project-command-stores.ts | 4 + .../project-snapshot.controller.ts | 63 ++- src/server/routes/project.routes.ts | 5 + .../validation/project-snapshot.schemas.ts | 6 + tests/project-command.service.test.ts | 2 + tests/project-snapshot.repository.test.ts | 37 ++ ...t-state-restore-command.repository.test.ts | 457 ++++++++++++++++++ 25 files changed, 1315 insertions(+), 206 deletions(-) create mode 100644 src/db/repositories/project-state-restore-command.repository.ts create mode 100644 src/db/repositories/project-state-snapshot.persistence.ts create mode 100644 src/domain/errors/project-state-conflict.error.ts create mode 100644 src/domain/models/project-state-restore-command.model.ts create mode 100644 src/domain/ports/project-state-restore-command.store.ts create mode 100644 tests/project-state-restore-command.repository.test.ts diff --git a/AGENTS.md b/AGENTS.md index 3955210..3e0b6e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -233,8 +233,10 @@ stored command payloads. Named logical snapshots can be created and listed through project-scoped API endpoints. Their schema-versioned payload contains the complete supported project runtime state and a SHA-256, excludes global/upgrade-only data and does -not change the project revision or undo/redo stacks. Restore is not implemented -yet. +not change the project revision or undo/redo stacks. Restoring a server-stored +snapshot verifies its checksum and the current-state hash, replaces supported +project data atomically and records a new `restore` revision with a complete +inverse command. Restore can therefore be undone and redone after a restart. Insertions and generated move targets use client-generated stable UUIDs, and undo restores the same ids from complete server snapshots. The tree response supplies the optimistic `currentRevision`; obsolete direct field PATCH, @@ -298,7 +300,7 @@ Users must be able to override sizing suggestions. - Never edit an already applied migration. - Back up an existing database before applying a new migration. - Inspect generated SQL; it must contain only the intended schema change. -- Keep database backups separate from future logical project snapshots. +- Keep database backups separate from logical project snapshots. - Do not import the global SQLite singleton into domain services. - Keep synchronous SQLite transaction behavior inside persistence adapters. - Preserve stable UUIDs and explicit transaction boundaries for a later @@ -306,7 +308,7 @@ Users must be able to override sizing suggestions. ## Current Deferred Work -- snapshot restore, snapshot UI and periodic snapshot policy +- snapshot UI and periodic snapshot policy - Revit/CSV/IFCGUID round-trip - full electrical sizing - multi-user/PostgreSQL operation diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 6cd6e85..43dac62 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -44,6 +44,16 @@ this value for optimistic concurrency checks. - snapshot creation does not increment the project revision or alter undo/redo stacks - duplicate names and stale revisions return `409` +- `POST /projects/:projectId/snapshots/:snapshotId/restore` + - body: `{ "expectedRevision": 13 }` + - verifies the stored payload checksum and atomically restores the complete + supported project state as revision `14` with source `restore` + - stores the complete pre-restore state as the inverse command, so project + Undo/Redo can revert or repeat the restore after a restart + - stale revisions or state checks return `409`; unknown snapshots return + `404` + - clients cannot submit arbitrary `project.restore-state` payloads through + the generic command endpoint The public dispatcher currently supports `circuit.update`, `circuit.insert`, `circuit.delete`, `circuit-device-row.update`, diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index 5adc7d9..5564571 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -142,5 +142,6 @@ Covered operations include: - renumber and identifier update flows - apply sorted order -The toolbar currently exposes availability through its Undo/Redo buttons. A -browsable revision timeline and named restore points are separate future work. +The toolbar currently exposes availability through its Undo/Redo buttons. +Named restore points can be created, listed and restored through the API. A +browsable revision/snapshot UI remains future work. diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index 09f9d6f..0bd0dc9 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -7,8 +7,9 @@ - A paginated revision-metadata API exists, but there is no browsable timeline UI yet; the editor currently exposes only the next eligible Undo/Redo actions. -- Named logical project snapshots can be created and listed through the API, - but snapshot UI, periodic snapshots and restore are not implemented yet. +- Named logical project snapshots can be created, listed and restored through + the API with persistent Undo/Redo. Snapshot UI and periodic snapshots are + not implemented yet. - No Revit/CSV/IFCGUID import and export workflow is implemented yet. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - The global device library supports basic CRUD and copy operations, but has no diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 895afd3..8e960b7 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -108,8 +108,15 @@ schema-versionierten Projektzustand mit SHA-256-Prüfwert. Enthalten sind Projekteinstellungen, Verteiler, Stromkreislisten, Bereiche, Stromkreise und Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte, Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots. -Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel; Restore ist -noch nicht implementiert. +Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel. +`POST /api/projects/:projectId/snapshots/:snapshotId/restore` prüft Payload, +Prüfsumme, erwartete Revision und den unmittelbar zuvor gelesenen +Projektzustand. Der Restore ersetzt alle unterstützten Projektdaten in einer +Transaktion und schreibt dabei eine neue Revision mit Quelle `restore` sowie +ein vollständiges inverses Kommando. Undo und Redo können deshalb auch einen +Restore nach einem Neustart exakt zurücknehmen oder wiederholen. Kompatible +Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie +nicht Bestandteil des logischen Snapshots sind. `circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index af71f0d..b702c54 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -88,6 +88,15 @@ Implemented named-snapshot foundation: - snapshot creation/listing does not create a project revision or affect undo/redo eligibility - public Create/List endpoints expose metadata but not the logical payload +- restore verifies the stored checksum and a hash of the complete current + state before replacing supported project data in one transaction +- restore records a new `restore` revision containing both the selected target + state and the complete pre-restore inverse; undo/redo therefore remains + available after application restarts +- compatible upgrade-only Consumer links, mappings and reports are preserved + separately and are never imported into the logical snapshot payload +- arbitrary restore payloads are rejected by the generic user-command + endpoint; only server-stored snapshots can initiate a forward restore ## Revit / CSV / IFCGUID Round-Trip Direction @@ -243,7 +252,7 @@ Completed foundation: Remaining constraints before completing project version history: -- add a browsable timeline/snapshot UI, periodic snapshot policy and restore +- add a browsable timeline/snapshot UI and periodic snapshot policy - extend project-scoped commands only when further project mutations are deliberately added to history; the generic versioned envelope is complete - do not model IFCGUID as an overloaded circuit equipment identifier diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 70ef930..975f8a3 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -369,7 +369,8 @@ Tasks: - [x] add explicitly named logical project snapshots with schema-versioned payloads and integrity checksums - add periodic logical project snapshots -- restore a historical snapshot as a new revision +- [x] restore a historical snapshot as a new auditable revision with + persistent undo/redo - keep database backups separate from logical history Implemented foundation: diff --git a/package.json b/package.json index b392533..1bb5670 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/db/repositories/project-history.persistence.ts b/src/db/repositories/project-history.persistence.ts index 5dd7628..9887c13 100644 --- a/src/db/repositories/project-history.persistence.ts +++ b/src/db/repositories/project-history.persistence.ts @@ -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( diff --git a/src/db/repositories/project-snapshot.repository.ts b/src/db/repositories/project-snapshot.repository.ts index ae4a5c2..750aeec 100644 --- a/src/db/repositories/project-snapshot.repository.ts +++ b/src/db/repositories/project-snapshot.repository.ts @@ -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(); - 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) { diff --git a/src/db/repositories/project-state-restore-command.repository.ts b/src/db/repositories/project-state-restore-command.repository.ts new file mode 100644 index 0000000..51ee2f9 --- /dev/null +++ b/src/db/repositories/project-state-restore-command.repository.ts @@ -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 +) { + 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[0]>( + database: AppDatabase, + table: TTable, + values: unknown[] +) { + if (values.length === 0) { + return; + } + database + .insert(table) + .values(values as never) + .run(); +} diff --git a/src/db/repositories/project-state-snapshot.persistence.ts b/src/db/repositories/project-state-snapshot.persistence.ts new file mode 100644 index 0000000..3183c4f --- /dev/null +++ b/src/db/repositories/project-state-snapshot.persistence.ts @@ -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(); + 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"); +} diff --git a/src/domain/errors/project-state-conflict.error.ts b/src/domain/errors/project-state-conflict.error.ts new file mode 100644 index 0000000..6362736 --- /dev/null +++ b/src/domain/errors/project-state-conflict.error.ts @@ -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"; + } +} diff --git a/src/domain/models/project-state-restore-command.model.ts b/src/domain/models/project-state-restore-command.model.ts new file mode 100644 index 0000000..14b876c --- /dev/null +++ b/src/domain/models/project-state-restore-command.model.ts @@ -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; + 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; + if ( + typeof payload.expectedStateSha256 !== "string" || + !/^[a-f0-9]{64}$/.test(payload.expectedStateSha256) + ) { + throw new Error("Expected project state checksum is invalid."); + } + parseProjectStateSnapshot(payload.targetState); +} diff --git a/src/domain/ports/project-command.executor.ts b/src/domain/ports/project-command.executor.ts index 48125d7..8f67355 100644 --- a/src/domain/ports/project-command.executor.ts +++ b/src/domain/ports/project-command.executor.ts @@ -24,6 +24,9 @@ export interface ProjectCommandExecutor { executeUser( input: ExecuteUserProjectCommandInput ): ExecutedProjectCommand; + executeRestore( + input: ExecuteUserProjectCommandInput + ): ExecutedProjectCommand; undo( input: ExecuteProjectHistoryCommandInput ): ExecutedProjectCommand; diff --git a/src/domain/ports/project-snapshot.store.ts b/src/domain/ports/project-snapshot.store.ts index fd02fd4..cdfabfd 100644 --- a/src/domain/ports/project-snapshot.store.ts +++ b/src/domain/ports/project-snapshot.store.ts @@ -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"; diff --git a/src/domain/ports/project-state-restore-command.store.ts b/src/domain/ports/project-state-restore-command.store.ts new file mode 100644 index 0000000..5c37dfd --- /dev/null +++ b/src/domain/ports/project-state-restore-command.store.ts @@ -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; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index cd44fc0..8ada794 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -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; } 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}.` diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 7077539..9d3a123 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -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 ); diff --git a/src/server/controllers/project-snapshot.controller.ts b/src/server/controllers/project-snapshot.controller.ts index 4a72496..518a112 100644 --- a/src/server/controllers/project-snapshot.controller.ts +++ b/src/server/controllers/project-snapshot.controller.ts @@ -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()) { diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 1341c8b..b956dc2 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -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); diff --git a/src/shared/validation/project-snapshot.schemas.ts b/src/shared/validation/project-snapshot.schemas.ts index d7edbb5..89fa3d8 100644 --- a/src/shared/validation/project-snapshot.schemas.ts +++ b/src/shared/validation/project-snapshot.schemas.ts @@ -8,3 +8,9 @@ export const createNamedProjectSnapshotSchema = z description: z.string().trim().max(500).optional(), }) .strict(); + +export const restoreProjectSnapshotSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + }) + .strict(); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 31ab626..9c8e172 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -19,6 +19,7 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js"; import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js"; import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js"; +import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; @@ -101,6 +102,7 @@ function createService(context: DatabaseContext) { new ProjectDeviceStructureProjectCommandRepository(context.db), new ProjectDeviceProjectCommandRepository(context.db), new ProjectDeviceRowSyncProjectCommandRepository(context.db), + new ProjectStateRestoreCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); } diff --git a/tests/project-snapshot.repository.test.ts b/tests/project-snapshot.repository.test.ts index 70afab3..53d5e25 100644 --- a/tests/project-snapshot.repository.test.ts +++ b/tests/project-snapshot.repository.test.ts @@ -242,4 +242,41 @@ describe("project snapshot repository", () => { context.close(); } }); + + it("verifies stored payload integrity before preparing a restore", () => { + const context = createTestDatabase(); + try { + const repository = new ProjectSnapshotRepository(context.db); + const snapshot = repository.createNamed({ + projectId: "project-1", + expectedRevision: 0, + name: "Freigabe", + }); + assert.ok(snapshot); + const prepared = repository.prepareRestore( + "project-1", + snapshot.id + ); + assert.ok(prepared); + assert.equal( + prepared.command.payload.targetState.project.id, + "project-1" + ); + context.db + .update(projectSnapshots) + .set({ payloadJson: `${JSON.stringify({})}\n` }) + .where(eq(projectSnapshots.id, snapshot.id)) + .run(); + assert.throws( + () => repository.prepareRestore("project-1", snapshot.id), + /checksum verification failed/ + ); + assert.equal( + repository.prepareRestore("project-1", "missing"), + null + ); + } finally { + context.close(); + } + }); }); diff --git a/tests/project-state-restore-command.repository.test.ts b/tests/project-state-restore-command.repository.test.ts new file mode 100644 index 0000000..3b44397 --- /dev/null +++ b/tests/project-state-restore-command.repository.test.ts @@ -0,0 +1,457 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-project-command.repository.js"; +import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js"; +import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js"; +import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js"; +import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js"; +import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js"; +import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; +import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; +import { ProjectDeviceProjectCommandRepository } from "../src/db/repositories/project-device-project-command.repository.js"; +import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/repositories/project-device-row-sync-project-command.repository.js"; +import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js"; +import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-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 { circuitLists } from "../src/db/schema/circuit-lists.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { consumers } from "../src/db/schema/consumers.js"; +import { floors } from "../src/db/schema/floors.js"; +import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js"; +import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js"; +import { projectDevices } from "../src/db/schema/project-devices.js"; +import { projectRevisions } from "../src/db/schema/project-revisions.js"; +import { projectSnapshots } from "../src/db/schema/project-snapshots.js"; +import { projects } from "../src/db/schema/projects.js"; +import { rooms } from "../src/db/schema/rooms.js"; +import { ProjectStateConflictError } from "../src/domain/errors/project-state-conflict.error.js"; +import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; + +function createTestDatabase(): DatabaseContext { + const context = createDatabaseContext(":memory:"); + migrate(context.db, { + migrationsFolder: path.resolve("src", "db", "migrations"), + }); + context.db + .insert(projects) + .values({ id: "project-1", name: "Ausgangsprojekt" }) + .run(); + const board = new DistributionBoardRepository( + context.db + ).createWithCircuitListAndDefaultSections("project-1", "UV-01"); + const list = context.db + .select() + .from(circuitLists) + .where(eq(circuitLists.distributionBoardId, board.id)) + .get(); + assert.ok(list); + const section = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, list.id)) + .get(); + assert.ok(section); + context.db + .insert(floors) + .values({ + id: "floor-1", + projectId: "project-1", + name: "EG", + sortOrder: 10, + }) + .run(); + context.db + .insert(rooms) + .values({ + id: "room-1", + projectId: "project-1", + floorId: "floor-1", + roomNumber: "001", + roomName: "Technik", + }) + .run(); + context.db + .insert(projectDevices) + .values({ + id: "device-1", + projectId: "project-1", + name: "Pumpe", + displayName: "Pumpe", + phaseType: "three_phase", + quantity: 1, + powerPerUnit: 2.5, + simultaneityFactor: 0.8, + }) + .run(); + context.db + .insert(circuits) + .values({ + id: "circuit-1", + circuitListId: list.id, + sectionId: section.id, + equipmentIdentifier: "-1F1", + displayName: "Ausgang", + sortOrder: 10, + isReserve: 0, + }) + .run(); + context.db + .insert(circuitDeviceRows) + .values({ + id: "row-1", + circuitId: "circuit-1", + linkedProjectDeviceId: "device-1", + roomId: "room-1", + sortOrder: 10, + name: "Pumpe", + displayName: "Pumpe", + quantity: 1, + powerPerUnit: 2.5, + simultaneityFactor: 0.8, + }) + .run(); + return context; +} + +function createService(context: DatabaseContext) { + return new ProjectCommandService( + new CircuitProjectCommandRepository(context.db), + new CircuitDeviceRowProjectCommandRepository(context.db), + new CircuitDeviceRowStructureProjectCommandRepository(context.db), + new CircuitDeviceRowMoveProjectCommandRepository(context.db), + new CircuitStructureProjectCommandRepository(context.db), + new CircuitSectionReorderProjectCommandRepository(context.db), + new CircuitSectionRenumberProjectCommandRepository(context.db), + new ProjectDeviceStructureProjectCommandRepository(context.db), + new ProjectDeviceProjectCommandRepository(context.db), + new ProjectDeviceRowSyncProjectCommandRepository(context.db), + new ProjectStateRestoreCommandRepository(context.db), + new ProjectHistoryRepository(context.db) + ); +} + +function changeCompleteProjectState(context: DatabaseContext) { + context.db + .update(projects) + .set({ + name: "Geänderter Stand", + singlePhaseVoltageV: 240, + threePhaseVoltageV: 415, + }) + .where(eq(projects.id, "project-1")) + .run(); + context.db + .update(circuits) + .set({ displayName: "Geänderter Stromkreis" }) + .where(eq(circuits.id, "circuit-1")) + .run(); + context.db + .insert(floors) + .values({ + id: "floor-2", + projectId: "project-1", + name: "OG", + sortOrder: 20, + }) + .run(); + context.db + .insert(rooms) + .values({ + id: "room-2", + projectId: "project-1", + floorId: "floor-2", + roomNumber: "101", + roomName: "Büro", + }) + .run(); + context.db + .insert(projectDevices) + .values({ + id: "device-2", + projectId: "project-1", + name: "Leuchte", + displayName: "Leuchte", + phaseType: "single_phase", + quantity: 2, + powerPerUnit: 0.05, + simultaneityFactor: 1, + }) + .run(); + new DistributionBoardRepository( + context.db + ).createWithCircuitListAndDefaultSections("project-1", "UV-02"); +} + +function seedUpgradeOnlyData(context: DatabaseContext) { + const circuit = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-1")) + .get(); + assert.ok(circuit); + context.db + .insert(consumers) + .values({ + id: "consumer-1", + projectId: "project-1", + distributionBoardId: + context.db.select().from(circuitLists).get()! + .distributionBoardId, + circuitListId: circuit.circuitListId, + projectDeviceId: "device-1", + roomId: "room-1", + name: "Legacy-Pumpe", + quantity: 1, + installedPowerPerUnitKw: 2.5, + demandFactor: 0.8, + }) + .run(); + context.db + .insert(legacyConsumerCircuitMigrations) + .values({ + consumerId: "consumer-1", + circuitId: "circuit-1", + circuitDeviceRowId: "row-1", + circuitListId: circuit.circuitListId, + createdAtIso: "2026-07-25T00:00:00.000Z", + }) + .run(); + context.db + .insert(legacyConsumerMigrationReports) + .values({ + id: "report-1", + circuitListId: circuit.circuitListId, + legacyConsumerCount: 1, + createdCircuitCount: 1, + createdDeviceRowCount: 1, + duplicateGroupedCount: 0, + generatedIdentifierCount: 0, + unassignedRowCount: 0, + warningsJson: "[]", + generatedIdentifiersJson: "[]", + duplicateGroupsJson: "[]", + createdAtIso: "2026-07-25T00:00:00.000Z", + }) + .run(); +} + +describe("project state restore command", () => { + it("restores a complete snapshot as a revision and supports undo and redo", () => { + const context = createTestDatabase(); + try { + const snapshots = new ProjectSnapshotRepository(context.db); + seedUpgradeOnlyData(context); + const snapshot = snapshots.createNamed({ + projectId: "project-1", + expectedRevision: 0, + name: "Freigabe", + }); + assert.ok(snapshot); + const original = readProjectStateSnapshot(context.db, "project-1"); + assert.ok(original); + + changeCompleteProjectState(context); + const changed = readProjectStateSnapshot(context.db, "project-1"); + assert.ok(changed); + assert.notEqual(changed.payloadSha256, original.payloadSha256); + + const prepared = snapshots.prepareRestore( + "project-1", + snapshot.id + ); + assert.ok(prepared); + const service = createService(context); + assert.throws( + () => + service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: prepared.command, + }), + /stored server snapshot/ + ); + const restored = service.executeRestore({ + projectId: "project-1", + expectedRevision: 0, + description: "Snapshot wiederherstellen", + command: prepared.command, + }); + assert.equal(restored.revision.revisionNumber, 1); + assert.equal(restored.history.undoDepth, 1); + assert.equal( + readProjectStateSnapshot(context.db, "project-1")?.payloadSha256, + original.payloadSha256 + ); + assert.equal( + context.db.select().from(projectSnapshots).all().length, + 1 + ); + assert.equal( + context.db + .select() + .from(legacyConsumerCircuitMigrations) + .all().length, + 1 + ); + assert.equal( + context.db + .select() + .from(legacyConsumerMigrationReports) + .all().length, + 1 + ); + assert.equal( + context.db + .select({ roomId: consumers.roomId }) + .from(consumers) + .where(eq(consumers.id, "consumer-1")) + .get()?.roomId, + "room-1" + ); + + const undone = service.undo({ + projectId: "project-1", + expectedRevision: 1, + }); + assert.equal(undone.revision.revisionNumber, 2); + assert.equal(undone.history.redoDepth, 1); + assert.equal( + readProjectStateSnapshot(context.db, "project-1")?.payloadSha256, + changed.payloadSha256 + ); + + const redone = service.redo({ + projectId: "project-1", + expectedRevision: 2, + }); + assert.equal(redone.revision.revisionNumber, 3); + assert.equal( + readProjectStateSnapshot(context.db, "project-1")?.payloadSha256, + original.payloadSha256 + ); + assert.deepEqual( + context.db + .select({ source: projectRevisions.source }) + .from(projectRevisions) + .all() + .map((entry) => entry.source), + ["restore", "undo", "redo"] + ); + } finally { + context.close(); + } + }); + + it("rejects a prepared restore after an unversioned state change", () => { + const context = createTestDatabase(); + try { + const snapshots = new ProjectSnapshotRepository(context.db); + const snapshot = snapshots.createNamed({ + projectId: "project-1", + expectedRevision: 0, + name: "Freigabe", + }); + assert.ok(snapshot); + changeCompleteProjectState(context); + const prepared = snapshots.prepareRestore( + "project-1", + snapshot.id + ); + assert.ok(prepared); + context.db + .update(projects) + .set({ name: "Noch neuer" }) + .where(eq(projects.id, "project-1")) + .run(); + + assert.throws( + () => + createService(context).executeRestore({ + projectId: "project-1", + expectedRevision: 0, + command: prepared.command, + }), + ProjectStateConflictError + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 0 + ); + assert.equal( + context.db + .select({ name: projects.name }) + .from(projects) + .get()?.name, + "Noch neuer" + ); + } finally { + context.close(); + } + }); + + it("rolls back the complete replacement after a late history failure", () => { + const context = createTestDatabase(); + try { + const snapshots = new ProjectSnapshotRepository(context.db); + const snapshot = snapshots.createNamed({ + projectId: "project-1", + expectedRevision: 0, + name: "Freigabe", + }); + assert.ok(snapshot); + changeCompleteProjectState(context); + const changed = readProjectStateSnapshot(context.db, "project-1"); + const prepared = snapshots.prepareRestore( + "project-1", + snapshot.id + ); + assert.ok(changed); + assert.ok(prepared); + context.sqlite.exec(` + CREATE TRIGGER fail_restore_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced restore history failure'); + END; + `); + + assert.throws( + () => + createService(context).executeRestore({ + projectId: "project-1", + expectedRevision: 0, + command: prepared.command, + }), + /forced restore history failure/ + ); + assert.equal( + readProjectStateSnapshot(context.db, "project-1")?.payloadSha256, + changed.payloadSha256 + ); + assert.equal( + context.db + .select({ currentRevision: projects.currentRevision }) + .from(projects) + .get()?.currentRevision, + 0 + ); + assert.equal( + context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + context.close(); + } + }); +});