Restore named project snapshots
This commit is contained in:
@@ -233,8 +233,10 @@ stored command payloads.
|
|||||||
Named logical snapshots can be created and listed through project-scoped API
|
Named logical snapshots can be created and listed through project-scoped API
|
||||||
endpoints. Their schema-versioned payload contains the complete supported
|
endpoints. Their schema-versioned payload contains the complete supported
|
||||||
project runtime state and a SHA-256, excludes global/upgrade-only data and does
|
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
|
not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||||
yet.
|
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
|
Insertions and generated move targets use client-generated stable UUIDs, and
|
||||||
undo restores the same ids from complete server snapshots. The tree response
|
undo restores the same ids from complete server snapshots. The tree response
|
||||||
supplies the optimistic `currentRevision`; obsolete direct field PATCH,
|
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.
|
- Never edit an already applied migration.
|
||||||
- Back up an existing database before applying a new migration.
|
- Back up an existing database before applying a new migration.
|
||||||
- Inspect generated SQL; it must contain only the intended schema change.
|
- 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.
|
- Do not import the global SQLite singleton into domain services.
|
||||||
- Keep synchronous SQLite transaction behavior inside persistence adapters.
|
- Keep synchronous SQLite transaction behavior inside persistence adapters.
|
||||||
- Preserve stable UUIDs and explicit transaction boundaries for a later
|
- 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
|
## Current Deferred Work
|
||||||
|
|
||||||
- snapshot restore, snapshot UI and periodic snapshot policy
|
- snapshot UI and periodic snapshot policy
|
||||||
- Revit/CSV/IFCGUID round-trip
|
- Revit/CSV/IFCGUID round-trip
|
||||||
- full electrical sizing
|
- full electrical sizing
|
||||||
- multi-user/PostgreSQL operation
|
- multi-user/PostgreSQL operation
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ this value for optimistic concurrency checks.
|
|||||||
- snapshot creation does not increment the project revision or alter
|
- snapshot creation does not increment the project revision or alter
|
||||||
undo/redo stacks
|
undo/redo stacks
|
||||||
- duplicate names and stale revisions return `409`
|
- 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`,
|
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
|
||||||
`circuit.delete`, `circuit-device-row.update`,
|
`circuit.delete`, `circuit-device-row.update`,
|
||||||
|
|||||||
@@ -142,5 +142,6 @@ Covered operations include:
|
|||||||
- renumber and identifier update flows
|
- renumber and identifier update flows
|
||||||
- apply sorted order
|
- apply sorted order
|
||||||
|
|
||||||
The toolbar currently exposes availability through its Undo/Redo buttons. A
|
The toolbar currently exposes availability through its Undo/Redo buttons.
|
||||||
browsable revision timeline and named restore points are separate future work.
|
Named restore points can be created, listed and restored through the API. A
|
||||||
|
browsable revision/snapshot UI remains future work.
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
- A paginated revision-metadata API exists, but there is no browsable timeline
|
- A paginated revision-metadata API exists, but there is no browsable timeline
|
||||||
UI yet; the editor currently exposes only the next eligible Undo/Redo
|
UI yet; the editor currently exposes only the next eligible Undo/Redo
|
||||||
actions.
|
actions.
|
||||||
- Named logical project snapshots can be created and listed through the API,
|
- Named logical project snapshots can be created, listed and restored through
|
||||||
but snapshot UI, periodic snapshots and restore are not implemented yet.
|
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.
|
- 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.
|
- 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
|
- The global device library supports basic CRUD and copy operations, but has no
|
||||||
|
|||||||
@@ -108,8 +108,15 @@ schema-versionierten Projektzustand mit SHA-256-Prüfwert. Enthalten sind
|
|||||||
Projekteinstellungen, Verteiler, Stromkreislisten, Bereiche, Stromkreise und
|
Projekteinstellungen, Verteiler, Stromkreislisten, Bereiche, Stromkreise und
|
||||||
Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte,
|
Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte,
|
||||||
Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots.
|
Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots.
|
||||||
Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel; Restore ist
|
Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel.
|
||||||
noch nicht implementiert.
|
`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
|
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
|
||||||
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
|
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
|
||||||
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
|
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
|
||||||
|
|||||||
@@ -88,6 +88,15 @@ Implemented named-snapshot foundation:
|
|||||||
- snapshot creation/listing does not create a project revision or affect
|
- snapshot creation/listing does not create a project revision or affect
|
||||||
undo/redo eligibility
|
undo/redo eligibility
|
||||||
- public Create/List endpoints expose metadata but not the logical payload
|
- 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
|
## Revit / CSV / IFCGUID Round-Trip Direction
|
||||||
|
|
||||||
@@ -243,7 +252,7 @@ Completed foundation:
|
|||||||
|
|
||||||
Remaining constraints before completing project version history:
|
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
|
- extend project-scoped commands only when further project mutations are
|
||||||
deliberately added to history; the generic versioned envelope is complete
|
deliberately added to history; the generic versioned envelope is complete
|
||||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||||
|
|||||||
@@ -369,7 +369,8 @@ Tasks:
|
|||||||
- [x] add explicitly named logical project snapshots with schema-versioned
|
- [x] add explicitly named logical project snapshots with schema-versioned
|
||||||
payloads and integrity checksums
|
payloads and integrity checksums
|
||||||
- add periodic logical project snapshots
|
- 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
|
- keep database backups separate from logical history
|
||||||
|
|
||||||
Implemented foundation:
|
Implemented foundation:
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"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": "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-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:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "tsx scripts/db-backup.ts",
|
"db:backup": "tsx scripts/db-backup.ts",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function applyProjectHistoryTransition(
|
|||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
input: ApplyProjectHistoryTransitionInput
|
input: ApplyProjectHistoryTransitionInput
|
||||||
) {
|
) {
|
||||||
if (input.source === "user") {
|
if (input.source === "user" || input.source === "restore") {
|
||||||
database
|
database
|
||||||
.delete(projectHistoryStackEntries)
|
.delete(projectHistoryStackEntries)
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
import crypto from "node:crypto";
|
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 { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||||
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-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 {
|
import {
|
||||||
parseProjectStateSnapshot,
|
deserializeProjectStateSnapshot,
|
||||||
projectStateSnapshotSchemaVersion,
|
projectStateSnapshotSchemaVersion,
|
||||||
serializeProjectStateSnapshot,
|
|
||||||
} from "../../domain/models/project-state-snapshot.model.js";
|
} from "../../domain/models/project-state-snapshot.model.js";
|
||||||
import type {
|
import type {
|
||||||
CreateNamedProjectSnapshotInput,
|
CreateNamedProjectSnapshotInput,
|
||||||
|
PreparedProjectSnapshotRestore,
|
||||||
ProjectSnapshotMetadata,
|
ProjectSnapshotMetadata,
|
||||||
ProjectSnapshotStore,
|
ProjectSnapshotStore,
|
||||||
} from "../../domain/ports/project-snapshot.store.js";
|
} from "../../domain/ports/project-snapshot.store.js";
|
||||||
import type { AppDatabase } from "../database-context.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 { projectSnapshots } from "../schema/project-snapshots.js";
|
||||||
import { projects } from "../schema/projects.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 {
|
export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
||||||
constructor(private readonly database: AppDatabase) {}
|
constructor(private readonly database: AppDatabase) {}
|
||||||
@@ -35,25 +32,15 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
|||||||
const snapshotDescription =
|
const snapshotDescription =
|
||||||
input.description?.trim() || null;
|
input.description?.trim() || null;
|
||||||
return this.database.transaction((tx) => {
|
return this.database.transaction((tx) => {
|
||||||
const project = tx
|
const current = readProjectStateSnapshot(tx, input.projectId);
|
||||||
.select({
|
if (!current) {
|
||||||
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) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (project.currentRevision !== input.expectedRevision) {
|
if (current.currentRevision !== input.expectedRevision) {
|
||||||
throw new ProjectRevisionConflictError(
|
throw new ProjectRevisionConflictError(
|
||||||
input.projectId,
|
input.projectId,
|
||||||
input.expectedRevision,
|
input.expectedRevision,
|
||||||
project.currentRevision
|
current.currentRevision
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const existing = tx
|
const existing = tx
|
||||||
@@ -73,182 +60,21 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const boardRows = tx
|
|
||||||
.select()
|
|
||||||
.from(distributionBoards)
|
|
||||||
.where(eq(distributionBoards.projectId, input.projectId))
|
|
||||||
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
|
|
||||||
.all();
|
|
||||||
const listRows = tx
|
|
||||||
.select()
|
|
||||||
.from(circuitLists)
|
|
||||||
.where(eq(circuitLists.projectId, input.projectId))
|
|
||||||
.orderBy(asc(circuitLists.name), asc(circuitLists.id))
|
|
||||||
.all();
|
|
||||||
const sectionRows = tx
|
|
||||||
.select({
|
|
||||||
id: circuitSections.id,
|
|
||||||
circuitListId: circuitSections.circuitListId,
|
|
||||||
key: circuitSections.key,
|
|
||||||
displayName: circuitSections.displayName,
|
|
||||||
prefix: circuitSections.prefix,
|
|
||||||
sortOrder: circuitSections.sortOrder,
|
|
||||||
})
|
|
||||||
.from(circuitSections)
|
|
||||||
.innerJoin(
|
|
||||||
circuitLists,
|
|
||||||
eq(circuitLists.id, circuitSections.circuitListId)
|
|
||||||
)
|
|
||||||
.where(eq(circuitLists.projectId, input.projectId))
|
|
||||||
.orderBy(
|
|
||||||
asc(circuitSections.circuitListId),
|
|
||||||
asc(circuitSections.sortOrder),
|
|
||||||
asc(circuitSections.id)
|
|
||||||
)
|
|
||||||
.all();
|
|
||||||
const circuitRows = tx
|
|
||||||
.select({
|
|
||||||
id: circuits.id,
|
|
||||||
circuitListId: circuits.circuitListId,
|
|
||||||
sectionId: circuits.sectionId,
|
|
||||||
equipmentIdentifier: circuits.equipmentIdentifier,
|
|
||||||
displayName: circuits.displayName,
|
|
||||||
sortOrder: circuits.sortOrder,
|
|
||||||
protectionType: circuits.protectionType,
|
|
||||||
protectionRatedCurrent: circuits.protectionRatedCurrent,
|
|
||||||
protectionCharacteristic: circuits.protectionCharacteristic,
|
|
||||||
cableType: circuits.cableType,
|
|
||||||
cableCrossSection: circuits.cableCrossSection,
|
|
||||||
cableLength: circuits.cableLength,
|
|
||||||
rcdAssignment: circuits.rcdAssignment,
|
|
||||||
terminalDesignation: circuits.terminalDesignation,
|
|
||||||
voltage: circuits.voltage,
|
|
||||||
controlRequirement: circuits.controlRequirement,
|
|
||||||
status: circuits.status,
|
|
||||||
isReserve: circuits.isReserve,
|
|
||||||
remark: circuits.remark,
|
|
||||||
})
|
|
||||||
.from(circuits)
|
|
||||||
.innerJoin(
|
|
||||||
circuitLists,
|
|
||||||
eq(circuitLists.id, circuits.circuitListId)
|
|
||||||
)
|
|
||||||
.where(eq(circuitLists.projectId, input.projectId))
|
|
||||||
.orderBy(
|
|
||||||
asc(circuits.circuitListId),
|
|
||||||
asc(circuits.sortOrder),
|
|
||||||
asc(circuits.id)
|
|
||||||
)
|
|
||||||
.all();
|
|
||||||
const deviceRowRows = tx
|
|
||||||
.select({
|
|
||||||
id: circuitDeviceRows.id,
|
|
||||||
circuitId: circuitDeviceRows.circuitId,
|
|
||||||
linkedProjectDeviceId:
|
|
||||||
circuitDeviceRows.linkedProjectDeviceId,
|
|
||||||
legacyConsumerId: circuitDeviceRows.legacyConsumerId,
|
|
||||||
sortOrder: circuitDeviceRows.sortOrder,
|
|
||||||
name: circuitDeviceRows.name,
|
|
||||||
displayName: circuitDeviceRows.displayName,
|
|
||||||
phaseType: circuitDeviceRows.phaseType,
|
|
||||||
connectionKind: circuitDeviceRows.connectionKind,
|
|
||||||
costGroup: circuitDeviceRows.costGroup,
|
|
||||||
category: circuitDeviceRows.category,
|
|
||||||
level: circuitDeviceRows.level,
|
|
||||||
roomId: circuitDeviceRows.roomId,
|
|
||||||
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
|
|
||||||
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
|
|
||||||
quantity: circuitDeviceRows.quantity,
|
|
||||||
powerPerUnit: circuitDeviceRows.powerPerUnit,
|
|
||||||
simultaneityFactor:
|
|
||||||
circuitDeviceRows.simultaneityFactor,
|
|
||||||
cosPhi: circuitDeviceRows.cosPhi,
|
|
||||||
remark: circuitDeviceRows.remark,
|
|
||||||
overriddenFields: circuitDeviceRows.overriddenFields,
|
|
||||||
})
|
|
||||||
.from(circuitDeviceRows)
|
|
||||||
.innerJoin(
|
|
||||||
circuits,
|
|
||||||
eq(circuits.id, circuitDeviceRows.circuitId)
|
|
||||||
)
|
|
||||||
.innerJoin(
|
|
||||||
circuitLists,
|
|
||||||
eq(circuitLists.id, circuits.circuitListId)
|
|
||||||
)
|
|
||||||
.where(eq(circuitLists.projectId, input.projectId))
|
|
||||||
.orderBy(
|
|
||||||
asc(circuitDeviceRows.circuitId),
|
|
||||||
asc(circuitDeviceRows.sortOrder),
|
|
||||||
asc(circuitDeviceRows.id)
|
|
||||||
)
|
|
||||||
.all();
|
|
||||||
const projectDeviceRows = tx
|
|
||||||
.select()
|
|
||||||
.from(projectDevices)
|
|
||||||
.where(eq(projectDevices.projectId, input.projectId))
|
|
||||||
.orderBy(
|
|
||||||
asc(projectDevices.displayName),
|
|
||||||
asc(projectDevices.id)
|
|
||||||
)
|
|
||||||
.all();
|
|
||||||
const floorRows = tx
|
|
||||||
.select()
|
|
||||||
.from(floors)
|
|
||||||
.where(eq(floors.projectId, input.projectId))
|
|
||||||
.orderBy(asc(floors.sortOrder), asc(floors.id))
|
|
||||||
.all();
|
|
||||||
const roomRows = tx
|
|
||||||
.select()
|
|
||||||
.from(rooms)
|
|
||||||
.where(eq(rooms.projectId, input.projectId))
|
|
||||||
.orderBy(asc(rooms.roomNumber), asc(rooms.id))
|
|
||||||
.all();
|
|
||||||
const deviceRowsByCircuit = new Map<string, typeof deviceRowRows>();
|
|
||||||
for (const row of deviceRowRows) {
|
|
||||||
const entries = deviceRowsByCircuit.get(row.circuitId) ?? [];
|
|
||||||
entries.push(row);
|
|
||||||
deviceRowsByCircuit.set(row.circuitId, entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = parseProjectStateSnapshot({
|
|
||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
|
||||||
project: {
|
|
||||||
id: project.id,
|
|
||||||
name: project.name,
|
|
||||||
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
|
||||||
threePhaseVoltageV: project.threePhaseVoltageV,
|
|
||||||
},
|
|
||||||
distributionBoards: boardRows,
|
|
||||||
circuitLists: listRows,
|
|
||||||
circuitSections: sectionRows,
|
|
||||||
circuits: circuitRows.map((circuit) => ({
|
|
||||||
...circuit,
|
|
||||||
isReserve: Boolean(circuit.isReserve),
|
|
||||||
deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [],
|
|
||||||
})),
|
|
||||||
projectDevices: projectDeviceRows,
|
|
||||||
floors: floorRows,
|
|
||||||
rooms: roomRows,
|
|
||||||
});
|
|
||||||
const payloadJson = serializeProjectStateSnapshot(snapshot);
|
|
||||||
const metadata: ProjectSnapshotMetadata = {
|
const metadata: ProjectSnapshotMetadata = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
sourceRevision: project.currentRevision,
|
sourceRevision: current.currentRevision,
|
||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
name: snapshotName,
|
name: snapshotName,
|
||||||
description: snapshotDescription,
|
description: snapshotDescription,
|
||||||
payloadSha256: crypto
|
payloadSha256: current.payloadSha256,
|
||||||
.createHash("sha256")
|
|
||||||
.update(payloadJson)
|
|
||||||
.digest("hex"),
|
|
||||||
createdAtIso: new Date().toISOString(),
|
createdAtIso: new Date().toISOString(),
|
||||||
createdByActorId: input.actorId ?? null,
|
createdByActorId: input.actorId ?? null,
|
||||||
};
|
};
|
||||||
tx.insert(projectSnapshots)
|
tx.insert(projectSnapshots)
|
||||||
.values({
|
.values({
|
||||||
...metadata,
|
...metadata,
|
||||||
payloadJson,
|
payloadJson: current.payloadJson,
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
return metadata;
|
return metadata;
|
||||||
@@ -284,6 +110,54 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
|||||||
)
|
)
|
||||||
.all();
|
.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) {
|
function validateCreateInput(input: CreateNamedProjectSnapshotInput) {
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js";
|
||||||
|
import {
|
||||||
|
assertProjectStateRestoreCommand,
|
||||||
|
createProjectStateRestoreCommand,
|
||||||
|
} from "../../domain/models/project-state-restore-command.model.js";
|
||||||
|
import {
|
||||||
|
serializeProjectStateSnapshot,
|
||||||
|
type ProjectStateSnapshot,
|
||||||
|
} from "../../domain/models/project-state-snapshot.model.js";
|
||||||
|
import type {
|
||||||
|
ExecuteProjectStateRestoreCommandInput,
|
||||||
|
ProjectStateRestoreCommandStore,
|
||||||
|
} from "../../domain/ports/project-state-restore-command.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { consumers } from "../schema/consumers.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { floors } from "../schema/floors.js";
|
||||||
|
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
||||||
|
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { rooms } from "../schema/rooms.js";
|
||||||
|
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||||
|
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||||
|
import {
|
||||||
|
hashProjectStatePayload,
|
||||||
|
readProjectStateSnapshot,
|
||||||
|
} from "./project-state-snapshot.persistence.js";
|
||||||
|
|
||||||
|
export class ProjectStateRestoreCommandRepository
|
||||||
|
implements ProjectStateRestoreCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteProjectStateRestoreCommandInput) {
|
||||||
|
assertProjectStateRestoreCommand(input.command);
|
||||||
|
if (input.command.payload.targetState.project.id !== input.projectId) {
|
||||||
|
throw new Error("Restore state belongs to a different project.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.database.transaction((tx) => {
|
||||||
|
const current = readProjectStateSnapshot(tx, input.projectId);
|
||||||
|
if (!current) {
|
||||||
|
throw new Error("Project not found.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
current.payloadSha256 !==
|
||||||
|
input.command.payload.expectedStateSha256
|
||||||
|
) {
|
||||||
|
throw new ProjectStateConflictError(
|
||||||
|
input.projectId,
|
||||||
|
input.command.payload.expectedStateSha256,
|
||||||
|
current.payloadSha256
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPayloadJson = serializeProjectStateSnapshot(
|
||||||
|
input.command.payload.targetState
|
||||||
|
);
|
||||||
|
const inverse = createProjectStateRestoreCommand(
|
||||||
|
hashProjectStatePayload(targetPayloadJson),
|
||||||
|
current.state
|
||||||
|
);
|
||||||
|
|
||||||
|
replaceProjectState(tx, input.command.payload.targetState);
|
||||||
|
|
||||||
|
const revision = appendProjectRevision(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
expectedRevision: input.expectedRevision,
|
||||||
|
source: input.source,
|
||||||
|
description: input.description,
|
||||||
|
actorId: input.actorId,
|
||||||
|
forward: input.command,
|
||||||
|
inverse,
|
||||||
|
});
|
||||||
|
applyProjectHistoryTransition(tx, {
|
||||||
|
projectId: input.projectId,
|
||||||
|
source: input.source,
|
||||||
|
recordedChangeSetId: revision.changeSetId,
|
||||||
|
targetChangeSetId: input.historyTargetChangeSetId,
|
||||||
|
});
|
||||||
|
return { revision, inverse };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceProjectState(
|
||||||
|
database: AppDatabase,
|
||||||
|
state: ProjectStateSnapshot
|
||||||
|
) {
|
||||||
|
const upgradeOnlyData = captureUpgradeOnlyData(
|
||||||
|
database,
|
||||||
|
state.project.id
|
||||||
|
);
|
||||||
|
database
|
||||||
|
.delete(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.projectId, state.project.id))
|
||||||
|
.run();
|
||||||
|
database
|
||||||
|
.delete(projectDevices)
|
||||||
|
.where(eq(projectDevices.projectId, state.project.id))
|
||||||
|
.run();
|
||||||
|
database
|
||||||
|
.delete(rooms)
|
||||||
|
.where(eq(rooms.projectId, state.project.id))
|
||||||
|
.run();
|
||||||
|
database
|
||||||
|
.delete(floors)
|
||||||
|
.where(eq(floors.projectId, state.project.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
database
|
||||||
|
.update(projects)
|
||||||
|
.set({
|
||||||
|
name: state.project.name,
|
||||||
|
singlePhaseVoltageV: state.project.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: state.project.threePhaseVoltageV,
|
||||||
|
})
|
||||||
|
.where(eq(projects.id, state.project.id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
insertMany(database, floors, state.floors);
|
||||||
|
insertMany(database, rooms, state.rooms);
|
||||||
|
insertMany(database, projectDevices, state.projectDevices);
|
||||||
|
insertMany(database, distributionBoards, state.distributionBoards);
|
||||||
|
insertMany(database, circuitLists, state.circuitLists);
|
||||||
|
insertMany(database, circuitSections, state.circuitSections);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
circuits,
|
||||||
|
state.circuits.map(({ deviceRows: _deviceRows, ...circuit }) => ({
|
||||||
|
...circuit,
|
||||||
|
isReserve: circuit.isReserve ? 1 : 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
circuitDeviceRows,
|
||||||
|
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
||||||
|
);
|
||||||
|
restoreCompatibleUpgradeOnlyData(database, state, upgradeOnlyData);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureUpgradeOnlyData(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string
|
||||||
|
) {
|
||||||
|
const consumerLinks = database
|
||||||
|
.select({
|
||||||
|
id: consumers.id,
|
||||||
|
distributionBoardId: consumers.distributionBoardId,
|
||||||
|
circuitListId: consumers.circuitListId,
|
||||||
|
projectDeviceId: consumers.projectDeviceId,
|
||||||
|
roomId: consumers.roomId,
|
||||||
|
})
|
||||||
|
.from(consumers)
|
||||||
|
.where(eq(consumers.projectId, projectId))
|
||||||
|
.all();
|
||||||
|
const migrationMappings = database
|
||||||
|
.select({
|
||||||
|
consumerId: legacyConsumerCircuitMigrations.consumerId,
|
||||||
|
circuitId: legacyConsumerCircuitMigrations.circuitId,
|
||||||
|
circuitDeviceRowId:
|
||||||
|
legacyConsumerCircuitMigrations.circuitDeviceRowId,
|
||||||
|
circuitListId: legacyConsumerCircuitMigrations.circuitListId,
|
||||||
|
createdAtIso: legacyConsumerCircuitMigrations.createdAtIso,
|
||||||
|
})
|
||||||
|
.from(legacyConsumerCircuitMigrations)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(
|
||||||
|
circuitLists.id,
|
||||||
|
legacyConsumerCircuitMigrations.circuitListId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.all();
|
||||||
|
const migrationReports = database
|
||||||
|
.select({
|
||||||
|
id: legacyConsumerMigrationReports.id,
|
||||||
|
circuitListId: legacyConsumerMigrationReports.circuitListId,
|
||||||
|
legacyConsumerCount:
|
||||||
|
legacyConsumerMigrationReports.legacyConsumerCount,
|
||||||
|
createdCircuitCount:
|
||||||
|
legacyConsumerMigrationReports.createdCircuitCount,
|
||||||
|
createdDeviceRowCount:
|
||||||
|
legacyConsumerMigrationReports.createdDeviceRowCount,
|
||||||
|
duplicateGroupedCount:
|
||||||
|
legacyConsumerMigrationReports.duplicateGroupedCount,
|
||||||
|
generatedIdentifierCount:
|
||||||
|
legacyConsumerMigrationReports.generatedIdentifierCount,
|
||||||
|
unassignedRowCount:
|
||||||
|
legacyConsumerMigrationReports.unassignedRowCount,
|
||||||
|
warningsJson: legacyConsumerMigrationReports.warningsJson,
|
||||||
|
generatedIdentifiersJson:
|
||||||
|
legacyConsumerMigrationReports.generatedIdentifiersJson,
|
||||||
|
duplicateGroupsJson:
|
||||||
|
legacyConsumerMigrationReports.duplicateGroupsJson,
|
||||||
|
createdAtIso: legacyConsumerMigrationReports.createdAtIso,
|
||||||
|
})
|
||||||
|
.from(legacyConsumerMigrationReports)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, legacyConsumerMigrationReports.circuitListId)
|
||||||
|
)
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.all();
|
||||||
|
return { consumerLinks, migrationMappings, migrationReports };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCompatibleUpgradeOnlyData(
|
||||||
|
database: AppDatabase,
|
||||||
|
state: ProjectStateSnapshot,
|
||||||
|
data: ReturnType<typeof captureUpgradeOnlyData>
|
||||||
|
) {
|
||||||
|
const boardIds = new Set(
|
||||||
|
state.distributionBoards.map((entry) => entry.id)
|
||||||
|
);
|
||||||
|
const listIds = new Set(state.circuitLists.map((entry) => entry.id));
|
||||||
|
const deviceIds = new Set(
|
||||||
|
state.projectDevices.map((entry) => entry.id)
|
||||||
|
);
|
||||||
|
const roomIds = new Set(state.rooms.map((entry) => entry.id));
|
||||||
|
const circuitIds = new Set(state.circuits.map((entry) => entry.id));
|
||||||
|
const rowIds = new Set(
|
||||||
|
state.circuits.flatMap((circuit) =>
|
||||||
|
circuit.deviceRows.map((entry) => entry.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const link of data.consumerLinks) {
|
||||||
|
database
|
||||||
|
.update(consumers)
|
||||||
|
.set({
|
||||||
|
distributionBoardId:
|
||||||
|
link.distributionBoardId &&
|
||||||
|
boardIds.has(link.distributionBoardId)
|
||||||
|
? link.distributionBoardId
|
||||||
|
: null,
|
||||||
|
circuitListId:
|
||||||
|
link.circuitListId && listIds.has(link.circuitListId)
|
||||||
|
? link.circuitListId
|
||||||
|
: null,
|
||||||
|
projectDeviceId:
|
||||||
|
link.projectDeviceId && deviceIds.has(link.projectDeviceId)
|
||||||
|
? link.projectDeviceId
|
||||||
|
: null,
|
||||||
|
roomId:
|
||||||
|
link.roomId && roomIds.has(link.roomId) ? link.roomId : null,
|
||||||
|
})
|
||||||
|
.where(eq(consumers.id, link.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
legacyConsumerCircuitMigrations,
|
||||||
|
data.migrationMappings.filter(
|
||||||
|
(entry) =>
|
||||||
|
listIds.has(entry.circuitListId) &&
|
||||||
|
circuitIds.has(entry.circuitId) &&
|
||||||
|
rowIds.has(entry.circuitDeviceRowId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
legacyConsumerMigrationReports,
|
||||||
|
data.migrationReports.filter((entry) =>
|
||||||
|
listIds.has(entry.circuitListId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||||
|
database: AppDatabase,
|
||||||
|
table: TTable,
|
||||||
|
values: unknown[]
|
||||||
|
) {
|
||||||
|
if (values.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
database
|
||||||
|
.insert(table)
|
||||||
|
.values(values as never)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
parseProjectStateSnapshot,
|
||||||
|
projectStateSnapshotSchemaVersion,
|
||||||
|
serializeProjectStateSnapshot,
|
||||||
|
type ProjectStateSnapshot,
|
||||||
|
} from "../../domain/models/project-state-snapshot.model.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { floors } from "../schema/floors.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { rooms } from "../schema/rooms.js";
|
||||||
|
|
||||||
|
export interface PersistedProjectStateSnapshot {
|
||||||
|
currentRevision: number;
|
||||||
|
state: ProjectStateSnapshot;
|
||||||
|
payloadJson: string;
|
||||||
|
payloadSha256: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readProjectStateSnapshot(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string
|
||||||
|
): PersistedProjectStateSnapshot | null {
|
||||||
|
const project = database
|
||||||
|
.select({
|
||||||
|
id: projects.id,
|
||||||
|
name: projects.name,
|
||||||
|
singlePhaseVoltageV: projects.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||||
|
currentRevision: projects.currentRevision,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.get();
|
||||||
|
if (!project) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boardRows = database
|
||||||
|
.select()
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.projectId, projectId))
|
||||||
|
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
|
||||||
|
.all();
|
||||||
|
const listRows = database
|
||||||
|
.select()
|
||||||
|
.from(circuitLists)
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.orderBy(asc(circuitLists.name), asc(circuitLists.id))
|
||||||
|
.all();
|
||||||
|
const sectionRows = database
|
||||||
|
.select({
|
||||||
|
id: circuitSections.id,
|
||||||
|
circuitListId: circuitSections.circuitListId,
|
||||||
|
key: circuitSections.key,
|
||||||
|
displayName: circuitSections.displayName,
|
||||||
|
prefix: circuitSections.prefix,
|
||||||
|
sortOrder: circuitSections.sortOrder,
|
||||||
|
})
|
||||||
|
.from(circuitSections)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuitSections.circuitListId)
|
||||||
|
)
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.orderBy(
|
||||||
|
asc(circuitSections.circuitListId),
|
||||||
|
asc(circuitSections.sortOrder),
|
||||||
|
asc(circuitSections.id)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
const circuitRows = database
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
circuitListId: circuits.circuitListId,
|
||||||
|
sectionId: circuits.sectionId,
|
||||||
|
equipmentIdentifier: circuits.equipmentIdentifier,
|
||||||
|
displayName: circuits.displayName,
|
||||||
|
sortOrder: circuits.sortOrder,
|
||||||
|
protectionType: circuits.protectionType,
|
||||||
|
protectionRatedCurrent: circuits.protectionRatedCurrent,
|
||||||
|
protectionCharacteristic: circuits.protectionCharacteristic,
|
||||||
|
cableType: circuits.cableType,
|
||||||
|
cableCrossSection: circuits.cableCrossSection,
|
||||||
|
cableLength: circuits.cableLength,
|
||||||
|
rcdAssignment: circuits.rcdAssignment,
|
||||||
|
terminalDesignation: circuits.terminalDesignation,
|
||||||
|
voltage: circuits.voltage,
|
||||||
|
controlRequirement: circuits.controlRequirement,
|
||||||
|
status: circuits.status,
|
||||||
|
isReserve: circuits.isReserve,
|
||||||
|
remark: circuits.remark,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.orderBy(
|
||||||
|
asc(circuits.circuitListId),
|
||||||
|
asc(circuits.sortOrder),
|
||||||
|
asc(circuits.id)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
const deviceRowRows = database
|
||||||
|
.select({
|
||||||
|
id: circuitDeviceRows.id,
|
||||||
|
circuitId: circuitDeviceRows.circuitId,
|
||||||
|
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
|
||||||
|
legacyConsumerId: circuitDeviceRows.legacyConsumerId,
|
||||||
|
sortOrder: circuitDeviceRows.sortOrder,
|
||||||
|
name: circuitDeviceRows.name,
|
||||||
|
displayName: circuitDeviceRows.displayName,
|
||||||
|
phaseType: circuitDeviceRows.phaseType,
|
||||||
|
connectionKind: circuitDeviceRows.connectionKind,
|
||||||
|
costGroup: circuitDeviceRows.costGroup,
|
||||||
|
category: circuitDeviceRows.category,
|
||||||
|
level: circuitDeviceRows.level,
|
||||||
|
roomId: circuitDeviceRows.roomId,
|
||||||
|
roomNumberSnapshot: circuitDeviceRows.roomNumberSnapshot,
|
||||||
|
roomNameSnapshot: circuitDeviceRows.roomNameSnapshot,
|
||||||
|
quantity: circuitDeviceRows.quantity,
|
||||||
|
powerPerUnit: circuitDeviceRows.powerPerUnit,
|
||||||
|
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
|
||||||
|
cosPhi: circuitDeviceRows.cosPhi,
|
||||||
|
remark: circuitDeviceRows.remark,
|
||||||
|
overriddenFields: circuitDeviceRows.overriddenFields,
|
||||||
|
})
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
|
||||||
|
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.orderBy(
|
||||||
|
asc(circuitDeviceRows.circuitId),
|
||||||
|
asc(circuitDeviceRows.sortOrder),
|
||||||
|
asc(circuitDeviceRows.id)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
const projectDeviceRows = database
|
||||||
|
.select()
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(eq(projectDevices.projectId, projectId))
|
||||||
|
.orderBy(asc(projectDevices.displayName), asc(projectDevices.id))
|
||||||
|
.all();
|
||||||
|
const floorRows = database
|
||||||
|
.select()
|
||||||
|
.from(floors)
|
||||||
|
.where(eq(floors.projectId, projectId))
|
||||||
|
.orderBy(asc(floors.sortOrder), asc(floors.id))
|
||||||
|
.all();
|
||||||
|
const roomRows = database
|
||||||
|
.select()
|
||||||
|
.from(rooms)
|
||||||
|
.where(eq(rooms.projectId, projectId))
|
||||||
|
.orderBy(asc(rooms.roomNumber), asc(rooms.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const deviceRowsByCircuit = new Map<string, typeof deviceRowRows>();
|
||||||
|
for (const row of deviceRowRows) {
|
||||||
|
const entries = deviceRowsByCircuit.get(row.circuitId) ?? [];
|
||||||
|
entries.push(row);
|
||||||
|
deviceRowsByCircuit.set(row.circuitId, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = parseProjectStateSnapshot({
|
||||||
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
|
project: {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||||
|
},
|
||||||
|
distributionBoards: boardRows,
|
||||||
|
circuitLists: listRows,
|
||||||
|
circuitSections: sectionRows,
|
||||||
|
circuits: circuitRows.map((circuit) => ({
|
||||||
|
...circuit,
|
||||||
|
isReserve: Boolean(circuit.isReserve),
|
||||||
|
deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [],
|
||||||
|
})),
|
||||||
|
projectDevices: projectDeviceRows,
|
||||||
|
floors: floorRows,
|
||||||
|
rooms: roomRows,
|
||||||
|
});
|
||||||
|
const payloadJson = serializeProjectStateSnapshot(state);
|
||||||
|
return {
|
||||||
|
currentRevision: project.currentRevision,
|
||||||
|
state,
|
||||||
|
payloadJson,
|
||||||
|
payloadSha256: hashProjectStatePayload(payloadJson),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashProjectStatePayload(payloadJson: string) {
|
||||||
|
return crypto.createHash("sha256").update(payloadJson).digest("hex");
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export class ProjectStateConflictError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly projectId: string,
|
||||||
|
public readonly expectedSha256: string,
|
||||||
|
public readonly actualSha256: string
|
||||||
|
) {
|
||||||
|
super(`Project ${projectId} changed outside the prepared restore state.`);
|
||||||
|
this.name = "ProjectStateConflictError";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
parseProjectStateSnapshot,
|
||||||
|
type ProjectStateSnapshot,
|
||||||
|
} from "./project-state-snapshot.model.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const projectStateRestoreCommandType = "project.restore-state";
|
||||||
|
export const projectStateRestoreCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface ProjectStateRestoreCommandPayload {
|
||||||
|
expectedStateSha256: string;
|
||||||
|
targetState: ProjectStateSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectStateRestoreCommand = SerializedProjectCommand<
|
||||||
|
ProjectStateRestoreCommandPayload
|
||||||
|
> & {
|
||||||
|
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
|
||||||
|
type: typeof projectStateRestoreCommandType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createProjectStateRestoreCommand(
|
||||||
|
expectedStateSha256: string,
|
||||||
|
targetState: ProjectStateSnapshot
|
||||||
|
): ProjectStateRestoreCommand {
|
||||||
|
const command: ProjectStateRestoreCommand = {
|
||||||
|
schemaVersion: projectStateRestoreCommandSchemaVersion,
|
||||||
|
type: projectStateRestoreCommandType,
|
||||||
|
payload: {
|
||||||
|
expectedStateSha256,
|
||||||
|
targetState: parseProjectStateSnapshot(targetState),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assertProjectStateRestoreCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertProjectStateRestoreCommand(
|
||||||
|
value: unknown
|
||||||
|
): asserts value is ProjectStateRestoreCommand {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
throw new Error("Project state restore command must be an object.");
|
||||||
|
}
|
||||||
|
const command = value as Partial<ProjectStateRestoreCommand>;
|
||||||
|
if (
|
||||||
|
command.type !== projectStateRestoreCommandType ||
|
||||||
|
command.schemaVersion !== projectStateRestoreCommandSchemaVersion
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported project state restore command.");
|
||||||
|
}
|
||||||
|
if (!command.payload || typeof command.payload !== "object") {
|
||||||
|
throw new Error("Project state restore payload is required.");
|
||||||
|
}
|
||||||
|
const payload = command.payload as Partial<ProjectStateRestoreCommandPayload>;
|
||||||
|
if (
|
||||||
|
typeof payload.expectedStateSha256 !== "string" ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(payload.expectedStateSha256)
|
||||||
|
) {
|
||||||
|
throw new Error("Expected project state checksum is invalid.");
|
||||||
|
}
|
||||||
|
parseProjectStateSnapshot(payload.targetState);
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ export interface ProjectCommandExecutor {
|
|||||||
executeUser(
|
executeUser(
|
||||||
input: ExecuteUserProjectCommandInput
|
input: ExecuteUserProjectCommandInput
|
||||||
): ExecutedProjectCommand;
|
): ExecutedProjectCommand;
|
||||||
|
executeRestore(
|
||||||
|
input: ExecuteUserProjectCommandInput
|
||||||
|
): ExecutedProjectCommand;
|
||||||
undo(
|
undo(
|
||||||
input: ExecuteProjectHistoryCommandInput
|
input: ExecuteProjectHistoryCommandInput
|
||||||
): ExecutedProjectCommand;
|
): ExecutedProjectCommand;
|
||||||
|
|||||||
@@ -18,9 +18,19 @@ export interface CreateNamedProjectSnapshotInput {
|
|||||||
actorId?: string;
|
actorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PreparedProjectSnapshotRestore {
|
||||||
|
snapshot: ProjectSnapshotMetadata;
|
||||||
|
command: ProjectStateRestoreCommand;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectSnapshotStore {
|
export interface ProjectSnapshotStore {
|
||||||
createNamed(
|
createNamed(
|
||||||
input: CreateNamedProjectSnapshotInput
|
input: CreateNamedProjectSnapshotInput
|
||||||
): ProjectSnapshotMetadata | null;
|
): ProjectSnapshotMetadata | null;
|
||||||
listByProject(projectId: string): ProjectSnapshotMetadata[] | null;
|
listByProject(projectId: string): ProjectSnapshotMetadata[] | null;
|
||||||
|
prepareRestore(
|
||||||
|
projectId: string,
|
||||||
|
snapshotId: string
|
||||||
|
): PreparedProjectSnapshotRestore | null;
|
||||||
}
|
}
|
||||||
|
import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js";
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js";
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteProjectStateRestoreCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: ProjectStateRestoreCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedProjectStateRestoreCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: ProjectStateRestoreCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectStateRestoreCommandStore {
|
||||||
|
execute(
|
||||||
|
input: ExecuteProjectStateRestoreCommandInput
|
||||||
|
): ExecutedProjectStateRestoreCommand;
|
||||||
|
}
|
||||||
@@ -41,6 +41,10 @@ import {
|
|||||||
assertSerializedProjectCommand,
|
assertSerializedProjectCommand,
|
||||||
type SerializedProjectCommand,
|
type SerializedProjectCommand,
|
||||||
} from "../models/project-command.model.js";
|
} from "../models/project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertProjectStateRestoreCommand,
|
||||||
|
projectStateRestoreCommandType,
|
||||||
|
} from "../models/project-state-restore-command.model.js";
|
||||||
import {
|
import {
|
||||||
assertProjectDeviceRowSyncProjectCommand,
|
assertProjectDeviceRowSyncProjectCommand,
|
||||||
projectDeviceRowSyncCommandType,
|
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 { 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 { ProjectDeviceStructureProjectCommandStore } from "../ports/project-device-structure-project-command.store.js";
|
||||||
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
|
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
|
||||||
|
import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js";
|
||||||
|
|
||||||
interface DispatchProjectCommandInput {
|
interface DispatchProjectCommandInput {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -84,7 +89,7 @@ interface DispatchProjectCommandInput {
|
|||||||
description?: string;
|
description?: string;
|
||||||
actorId?: string;
|
actorId?: string;
|
||||||
historyTargetChangeSetId?: string;
|
historyTargetChangeSetId?: string;
|
||||||
command: SerializedProjectCommand;
|
command: SerializedProjectCommand<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProjectCommandService implements ProjectCommandExecutor {
|
export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
@@ -99,6 +104,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
|
private readonly projectDeviceStructureStore: ProjectDeviceStructureProjectCommandStore,
|
||||||
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
|
private readonly projectDeviceStore: ProjectDeviceProjectCommandStore,
|
||||||
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
|
private readonly projectDeviceRowSyncStore: ProjectDeviceRowSyncProjectCommandStore,
|
||||||
|
private readonly projectStateRestoreStore: ProjectStateRestoreCommandStore,
|
||||||
private readonly historyStore: ProjectHistoryStore
|
private readonly historyStore: ProjectHistoryStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -107,6 +113,11 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
): ExecutedProjectCommand {
|
): ExecutedProjectCommand {
|
||||||
assertExpectedRevision(input.expectedRevision);
|
assertExpectedRevision(input.expectedRevision);
|
||||||
assertSerializedProjectCommand(input.command);
|
assertSerializedProjectCommand(input.command);
|
||||||
|
if (input.command.type === projectStateRestoreCommandType) {
|
||||||
|
throw new Error(
|
||||||
|
"Project state restore commands require a stored server snapshot."
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.dispatch({
|
return this.dispatch({
|
||||||
...input,
|
...input,
|
||||||
source: "user",
|
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(
|
undo(
|
||||||
input: ExecuteProjectHistoryCommandInput
|
input: ExecuteProjectHistoryCommandInput
|
||||||
): ExecutedProjectCommand {
|
): ExecutedProjectCommand {
|
||||||
@@ -271,6 +294,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
|
case projectStateRestoreCommandType: {
|
||||||
|
assertProjectStateRestoreCommand(input.command);
|
||||||
|
return this.projectStateRestoreStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unsupported project command type: ${input.command.type}.`
|
`Unsupported project command type: ${input.command.type}.`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ProjectHistoryRepository } from "../../db/repositories/project-history.
|
|||||||
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
|
import { ProjectDeviceProjectCommandRepository } from "../../db/repositories/project-device-project-command.repository.js";
|
||||||
import { ProjectDeviceRowSyncProjectCommandRepository } from "../../db/repositories/project-device-row-sync-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 { 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";
|
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
||||||
|
|
||||||
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||||
@@ -31,6 +32,8 @@ export const projectDeviceProjectCommandStore =
|
|||||||
new ProjectDeviceProjectCommandRepository(db);
|
new ProjectDeviceProjectCommandRepository(db);
|
||||||
export const projectDeviceRowSyncProjectCommandStore =
|
export const projectDeviceRowSyncProjectCommandStore =
|
||||||
new ProjectDeviceRowSyncProjectCommandRepository(db);
|
new ProjectDeviceRowSyncProjectCommandRepository(db);
|
||||||
|
export const projectStateRestoreCommandStore =
|
||||||
|
new ProjectStateRestoreCommandRepository(db);
|
||||||
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
||||||
export const projectCommandService = new ProjectCommandService(
|
export const projectCommandService = new ProjectCommandService(
|
||||||
circuitProjectCommandStore,
|
circuitProjectCommandStore,
|
||||||
@@ -43,5 +46,6 @@ export const projectCommandService = new ProjectCommandService(
|
|||||||
projectDeviceStructureProjectCommandStore,
|
projectDeviceStructureProjectCommandStore,
|
||||||
projectDeviceProjectCommandStore,
|
projectDeviceProjectCommandStore,
|
||||||
projectDeviceRowSyncProjectCommandStore,
|
projectDeviceRowSyncProjectCommandStore,
|
||||||
|
projectStateRestoreCommandStore,
|
||||||
projectHistoryStore
|
projectHistoryStore
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { NextFunction, Request, Response } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||||
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-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";
|
import { projectSnapshotStore } from "../composition/project-snapshot-store.js";
|
||||||
|
|
||||||
export function listProjectSnapshots(req: Request, res: Response) {
|
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) {
|
function getProjectId(req: Request, res: Response) {
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
if (typeof projectId !== "string" || !projectId.trim()) {
|
if (typeof projectId !== "string" || !projectId.trim()) {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createNamedProjectSnapshot,
|
createNamedProjectSnapshot,
|
||||||
listProjectSnapshots,
|
listProjectSnapshots,
|
||||||
|
restoreProjectSnapshot,
|
||||||
} from "../controllers/project-snapshot.controller.js";
|
} from "../controllers/project-snapshot.controller.js";
|
||||||
|
|
||||||
export const projectRouter = Router();
|
export const projectRouter = Router();
|
||||||
@@ -39,6 +40,10 @@ projectRouter.post("/:projectId/history/undo", undoProjectCommand);
|
|||||||
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
|
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
|
||||||
projectRouter.get("/:projectId/snapshots", listProjectSnapshots);
|
projectRouter.get("/:projectId/snapshots", listProjectSnapshots);
|
||||||
projectRouter.post("/:projectId/snapshots", createNamedProjectSnapshot);
|
projectRouter.post("/:projectId/snapshots", createNamedProjectSnapshot);
|
||||||
|
projectRouter.post(
|
||||||
|
"/:projectId/snapshots/:snapshotId/restore",
|
||||||
|
restoreProjectSnapshot
|
||||||
|
);
|
||||||
projectRouter.put("/:projectId", updateProjectSettings);
|
projectRouter.put("/:projectId", updateProjectSettings);
|
||||||
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
||||||
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
||||||
|
|||||||
@@ -8,3 +8,9 @@ export const createNamedProjectSnapshotSchema = z
|
|||||||
description: z.string().trim().max(500).optional(),
|
description: z.string().trim().max(500).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
export const restoreProjectSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|||||||
@@ -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 { 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 { 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 { 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 { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
@@ -101,6 +102,7 @@ function createService(context: DatabaseContext) {
|
|||||||
new ProjectDeviceStructureProjectCommandRepository(context.db),
|
new ProjectDeviceStructureProjectCommandRepository(context.db),
|
||||||
new ProjectDeviceProjectCommandRepository(context.db),
|
new ProjectDeviceProjectCommandRepository(context.db),
|
||||||
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
|
new ProjectDeviceRowSyncProjectCommandRepository(context.db),
|
||||||
|
new ProjectStateRestoreCommandRepository(context.db),
|
||||||
new ProjectHistoryRepository(context.db)
|
new ProjectHistoryRepository(context.db)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,4 +242,41 @@ describe("project snapshot repository", () => {
|
|||||||
context.close();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user