Restore named project snapshots

This commit is contained in:
2026-07-25 22:41:24 +02:00
parent 7c670fece3
commit 384a5769ab
25 changed files with 1315 additions and 206 deletions
+6 -4
View File
@@ -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
+10
View File
@@ -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`,
+3 -2
View File
@@ -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
+9 -2
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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;
}
+31 -1
View File
@@ -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()) {
+5
View File
@@ -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();
+2
View File
@@ -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)
); );
} }
+37
View File
@@ -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();
}
});
});