Add named project snapshots

This commit is contained in:
2026-07-25 22:15:22 +02:00
parent 5a0c9019af
commit 7c670fece3
21 changed files with 3076 additions and 7 deletions
+6 -1
View File
@@ -230,6 +230,11 @@ command and one undo step. Explicit renumbering uses the collision-safe
Immutable revision metadata is available through the paginated
`GET /api/projects/:projectId/history/revisions` endpoint; it does not expose
stored command payloads.
Named logical snapshots can be created and listed through project-scoped API
endpoints. Their schema-versioned payload contains the complete supported
project runtime state and a SHA-256, excludes global/upgrade-only data and does
not change the project revision or undo/redo stacks. Restore is not implemented
yet.
Insertions and generated move targets use client-generated stable UUIDs, and
undo restores the same ids from complete server snapshots. The tree response
supplies the optimistic `currentRevision`; obsolete direct field PATCH,
@@ -301,7 +306,7 @@ Users must be able to override sizing suggestions.
## Current Deferred Work
- named logical snapshots and restore
- snapshot restore, snapshot UI and periodic snapshot policy
- Revit/CSV/IFCGUID round-trip
- full electrical sizing
- multi-user/PostgreSQL operation
+12
View File
@@ -32,6 +32,18 @@ this value for optimistic concurrency checks.
- body: `{ "expectedRevision": 1 }`
- executes the eligible inverse or forward command as a new auditable
revision
- `GET /projects/:projectId/snapshots`
- lists named logical snapshot metadata without returning payload JSON
- `POST /projects/:projectId/snapshots`
- body:
`{ "expectedRevision": 12, "name": "Vor Ausschreibung", "description": "Optional" }`
- atomically captures the complete project-scoped runtime state at the
expected revision
- returns `201` with source revision, schema version, SHA-256 and creation
metadata
- snapshot creation does not increment the project revision or alter
undo/redo stacks
- duplicate names and stale revisions return `409`
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit.delete`, `circuit-device-row.update`,
@@ -7,7 +7,8 @@
- A paginated revision-metadata API exists, but there is no browsable timeline
UI yet; the editor currently exposes only the next eligible Undo/Redo
actions.
- Named logical project snapshots and restore are not implemented yet.
- Named logical project snapshots can be created and listed through the API,
but snapshot UI, periodic snapshots and restore are not implemented yet.
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
- The global device library supports basic CRUD and copy operations, but has no
+10
View File
@@ -100,6 +100,16 @@ Stromkreis-Reorders und die explizite Neunummerierung im Grid verwenden die
persistenten Kommandos. Die Toolbar leitet ihre Undo-/Redo-Verfügbarkeit direkt
aus den serverseitigen Stack-Tiefen ab, sodass ein Reload die Bedienbarkeit
nicht unterbricht.
Benannte logische Projektstände werden in `project_snapshots` getrennt von
Datenbank-Backups gespeichert. `POST /api/projects/:projectId/snapshots`
erzeugt bei passender erwarteter Revision transaktional einen vollständigen,
schema-versionierten Projektzustand mit SHA-256-Prüfwert. Enthalten sind
Projekteinstellungen, Verteiler, Stromkreislisten, Bereiche, Stromkreise und
Gerätezeilen sowie Projektgeräte, Geschosse und Räume. Globale Geräte,
Legacy-Consumer und Migrationsberichte sind nicht Teil des Projekt-Snapshots.
Create/List verändern weder Projektrevision noch Undo-/Redo-Stapel; Restore ist
noch nicht implementiert.
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
@@ -75,6 +75,20 @@ Project history must use logical project data, not copies of the SQLite database
Large snapshots may later be compressed or stored in object storage, with metadata and checksums retained in the database.
Implemented named-snapshot foundation:
- `project_snapshots` stores project ownership, source revision, schema
version, name, optional description, logical payload, SHA-256 and creation
metadata
- snapshot creation requires the expected current revision and reads plus
stores the complete project state in one SQLite transaction
- the versioned payload contains project settings, distribution boards,
circuit lists, sections, circuits with device rows, project devices, floors
and rooms while excluding global and upgrade-only data
- snapshot creation/listing does not create a project revision or affect
undo/redo eligibility
- public Create/List endpoints expose metadata but not the logical payload
## Revit / CSV / IFCGUID Round-Trip Direction
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
@@ -229,7 +243,7 @@ Completed foundation:
Remaining constraints before completing project version history:
- add a browsable timeline UI plus named logical snapshots and restore
- add a browsable timeline/snapshot UI, periodic snapshot policy and restore
- extend project-scoped commands only when further project mutations are
deliberately added to history; the generic versioned envelope is complete
- do not model IFCGUID as an overloaded circuit equipment identifier
+6 -1
View File
@@ -366,7 +366,9 @@ Tasks:
update command types
- [x] expose a cursor-paginated, read-only revision metadata timeline without
command payloads
- add named and periodic logical project snapshots
- [x] add explicitly named logical project snapshots with schema-versioned
payloads and integrity checksums
- add periodic logical project snapshots
- restore a historical snapshot as a new revision
- keep database backups separate from logical history
@@ -391,6 +393,9 @@ Implemented foundation:
change-set ids
- a separate read-only timeline endpoint exposes immutable revision metadata
in descending, cursor-paginated pages without forward/inverse payloads
- named snapshot Create/List endpoints persist complete project-scoped runtime
state with the source revision and SHA-256 without changing revision or
undo/redo stacks
- a central application dispatcher reconstructs persisted commands and exposes
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
field updates
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"start": "node dist/server/index.js",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-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-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-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",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts",
@@ -0,0 +1,16 @@
CREATE TABLE `project_snapshots` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`source_revision` integer NOT NULL,
`schema_version` integer NOT NULL,
`name` text NOT NULL,
`description` text,
`payload_json` text NOT NULL,
`payload_sha256` text NOT NULL,
`created_at_iso` text NOT NULL,
`created_by_actor_id` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `project_snapshots_project_created_idx` ON `project_snapshots` (`project_id`,`created_at_iso`);--> statement-breakpoint
CREATE UNIQUE INDEX `project_snapshots_project_name_unique` ON `project_snapshots` (`project_id`,`name`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -99,6 +99,13 @@
"when": 1784835703230,
"tag": "0013_project_history_stacks",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1785009799979,
"tag": "0014_project_snapshots",
"breakpoints": true
}
]
}
@@ -0,0 +1,314 @@
import crypto from "node:crypto";
import { and, asc, desc, eq } from "drizzle-orm";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
import {
parseProjectStateSnapshot,
projectStateSnapshotSchemaVersion,
serializeProjectStateSnapshot,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
CreateNamedProjectSnapshotInput,
ProjectSnapshotMetadata,
ProjectSnapshotStore,
} from "../../domain/ports/project-snapshot.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projectSnapshots } from "../schema/project-snapshots.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
export class ProjectSnapshotRepository implements ProjectSnapshotStore {
constructor(private readonly database: AppDatabase) {}
createNamed(
input: CreateNamedProjectSnapshotInput
): ProjectSnapshotMetadata | null {
validateCreateInput(input);
const snapshotName = input.name.trim();
const snapshotDescription =
input.description?.trim() || null;
return this.database.transaction((tx) => {
const project = tx
.select({
id: projects.id,
name: projects.name,
singlePhaseVoltageV: projects.singlePhaseVoltageV,
threePhaseVoltageV: projects.threePhaseVoltageV,
currentRevision: projects.currentRevision,
})
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
return null;
}
if (project.currentRevision !== input.expectedRevision) {
throw new ProjectRevisionConflictError(
input.projectId,
input.expectedRevision,
project.currentRevision
);
}
const existing = tx
.select({ id: projectSnapshots.id })
.from(projectSnapshots)
.where(
and(
eq(projectSnapshots.projectId, input.projectId),
eq(projectSnapshots.name, snapshotName)
)
)
.get();
if (existing) {
throw new ProjectSnapshotNameConflictError(
input.projectId,
snapshotName
);
}
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 = {
id: crypto.randomUUID(),
projectId: input.projectId,
sourceRevision: project.currentRevision,
schemaVersion: projectStateSnapshotSchemaVersion,
name: snapshotName,
description: snapshotDescription,
payloadSha256: crypto
.createHash("sha256")
.update(payloadJson)
.digest("hex"),
createdAtIso: new Date().toISOString(),
createdByActorId: input.actorId ?? null,
};
tx.insert(projectSnapshots)
.values({
...metadata,
payloadJson,
})
.run();
return metadata;
});
}
listByProject(projectId: string): ProjectSnapshotMetadata[] | null {
const project = this.database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
return null;
}
return this.database
.select({
id: projectSnapshots.id,
projectId: projectSnapshots.projectId,
sourceRevision: projectSnapshots.sourceRevision,
schemaVersion: projectSnapshots.schemaVersion,
name: projectSnapshots.name,
description: projectSnapshots.description,
payloadSha256: projectSnapshots.payloadSha256,
createdAtIso: projectSnapshots.createdAtIso,
createdByActorId: projectSnapshots.createdByActorId,
})
.from(projectSnapshots)
.where(eq(projectSnapshots.projectId, projectId))
.orderBy(
desc(projectSnapshots.createdAtIso),
desc(projectSnapshots.id)
)
.all();
}
}
function validateCreateInput(input: CreateNamedProjectSnapshotInput) {
if (
!Number.isSafeInteger(input.expectedRevision) ||
input.expectedRevision < 0
) {
throw new Error(
"Expected project revision must be a non-negative integer."
);
}
if (!input.name.trim()) {
throw new Error("Project snapshot name is required.");
}
if (input.name.trim().length > 100) {
throw new Error(
"Project snapshot name must not exceed 100 characters."
);
}
if (
input.description !== undefined &&
input.description.trim().length > 500
) {
throw new Error(
"Project snapshot description must not exceed 500 characters."
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import {
index,
integer,
sqliteTable,
text,
unique,
} from "drizzle-orm/sqlite-core";
import { projects } from "./projects.js";
export const projectSnapshots = sqliteTable(
"project_snapshots",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
sourceRevision: integer("source_revision").notNull(),
schemaVersion: integer("schema_version").notNull(),
name: text("name").notNull(),
description: text("description"),
payloadJson: text("payload_json").notNull(),
payloadSha256: text("payload_sha256").notNull(),
createdAtIso: text("created_at_iso").notNull(),
createdByActorId: text("created_by_actor_id"),
},
(table) => [
unique("project_snapshots_project_name_unique").on(
table.projectId,
table.name
),
index("project_snapshots_project_created_idx").on(
table.projectId,
table.createdAtIso
),
]
);
@@ -0,0 +1,11 @@
export class ProjectSnapshotNameConflictError extends Error {
constructor(
readonly projectId: string,
readonly snapshotName: string
) {
super(
`Project ${projectId} already has a snapshot named "${snapshotName}".`
);
this.name = "ProjectSnapshotNameConflictError";
}
}
@@ -0,0 +1,306 @@
import { z } from "zod";
export const projectStateSnapshotSchemaVersion = 1 as const;
const idSchema = z.string().trim().min(1);
const nullableStringSchema = z.string().nullable();
const finiteNumberSchema = z.number().finite();
const projectSchema = z
.object({
id: idSchema,
name: z.string().trim().min(1),
singlePhaseVoltageV: finiteNumberSchema.positive(),
threePhaseVoltageV: finiteNumberSchema.positive(),
})
.strict();
const distributionBoardSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
})
.strict();
const circuitListSchema = z
.object({
id: idSchema,
projectId: idSchema,
distributionBoardId: idSchema,
name: z.string().trim().min(1),
})
.strict();
const circuitSectionSchema = z
.object({
id: idSchema,
circuitListId: idSchema,
key: z.string().trim().min(1),
displayName: z.string().trim().min(1),
prefix: z.string().trim().min(1),
sortOrder: finiteNumberSchema,
})
.strict();
const circuitDeviceRowSchema = z
.object({
id: idSchema,
circuitId: idSchema,
linkedProjectDeviceId: idSchema.nullable(),
legacyConsumerId: z.string().nullable(),
sortOrder: finiteNumberSchema,
name: z.string().trim().min(1),
displayName: z.string().trim().min(1),
phaseType: nullableStringSchema,
connectionKind: nullableStringSchema,
costGroup: nullableStringSchema,
category: nullableStringSchema,
level: nullableStringSchema,
roomId: idSchema.nullable(),
roomNumberSnapshot: nullableStringSchema,
roomNameSnapshot: nullableStringSchema,
quantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.nonnegative(),
cosPhi: finiteNumberSchema.positive().nullable(),
remark: nullableStringSchema,
overriddenFields: nullableStringSchema,
})
.strict();
const circuitSchema = z
.object({
id: idSchema,
circuitListId: idSchema,
sectionId: idSchema,
equipmentIdentifier: z.string().trim().min(1),
displayName: nullableStringSchema,
sortOrder: finiteNumberSchema,
protectionType: nullableStringSchema,
protectionRatedCurrent: finiteNumberSchema.nonnegative().nullable(),
protectionCharacteristic: nullableStringSchema,
cableType: nullableStringSchema,
cableCrossSection: nullableStringSchema,
cableLength: finiteNumberSchema.nonnegative().nullable(),
rcdAssignment: nullableStringSchema,
terminalDesignation: nullableStringSchema,
voltage: finiteNumberSchema.positive().nullable(),
controlRequirement: nullableStringSchema,
status: nullableStringSchema,
isReserve: z.boolean(),
remark: nullableStringSchema,
deviceRows: z.array(circuitDeviceRowSchema),
})
.strict();
const projectDeviceSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
displayName: z.string().trim().min(1),
phaseType: z.enum(["single_phase", "three_phase"]),
connectionKind: nullableStringSchema,
costGroup: nullableStringSchema,
category: nullableStringSchema,
quantity: finiteNumberSchema.nonnegative(),
powerPerUnit: finiteNumberSchema.nonnegative(),
simultaneityFactor: finiteNumberSchema.min(0).max(1),
cosPhi: finiteNumberSchema.min(0).max(1).nullable(),
remark: nullableStringSchema,
voltageV: finiteNumberSchema.positive().nullable(),
})
.strict();
const floorSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
sortOrder: finiteNumberSchema,
})
.strict();
const roomSchema = z
.object({
id: idSchema,
projectId: idSchema,
floorId: idSchema.nullable(),
roomNumber: z.string().trim().min(1),
roomName: z.string().trim().min(1),
})
.strict();
export const projectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
circuits: z.array(circuitSchema),
projectDevices: z.array(projectDeviceSchema),
floors: z.array(floorSchema),
rooms: z.array(roomSchema),
})
.strict();
export type ProjectStateSnapshot = z.infer<
typeof projectStateSnapshotSchema
>;
export function parseProjectStateSnapshot(
value: unknown
): ProjectStateSnapshot {
const snapshot = projectStateSnapshotSchema.parse(value);
assertProjectStateSnapshotRelations(snapshot);
return snapshot;
}
export function serializeProjectStateSnapshot(
snapshot: ProjectStateSnapshot
): string {
return JSON.stringify(parseProjectStateSnapshot(snapshot));
}
export function deserializeProjectStateSnapshot(
serialized: string
): ProjectStateSnapshot {
return parseProjectStateSnapshot(JSON.parse(serialized) as unknown);
}
function assertProjectStateSnapshotRelations(
snapshot: ProjectStateSnapshot
) {
const projectId = snapshot.project.id;
const boardIds = uniqueIds(
snapshot.distributionBoards,
"distribution board"
);
const circuitListIds = uniqueIds(
snapshot.circuitLists,
"circuit list"
);
const sectionIds = uniqueIds(
snapshot.circuitSections,
"circuit section"
);
const projectDeviceIds = uniqueIds(
snapshot.projectDevices,
"project device"
);
const floorIds = uniqueIds(snapshot.floors, "floor");
const roomIds = uniqueIds(snapshot.rooms, "room");
uniqueIds(snapshot.circuits, "circuit");
const sectionById = new Map(
snapshot.circuitSections.map((entry) => [entry.id, entry])
);
for (const board of snapshot.distributionBoards) {
assertProjectOwnership(board.projectId, projectId, "distribution board");
}
for (const list of snapshot.circuitLists) {
assertProjectOwnership(list.projectId, projectId, "circuit list");
assertReference(
boardIds,
list.distributionBoardId,
"circuit list distribution board"
);
}
for (const section of snapshot.circuitSections) {
assertReference(
circuitListIds,
section.circuitListId,
"circuit section list"
);
}
for (const device of snapshot.projectDevices) {
assertProjectOwnership(device.projectId, projectId, "project device");
}
for (const floor of snapshot.floors) {
assertProjectOwnership(floor.projectId, projectId, "floor");
}
for (const room of snapshot.rooms) {
assertProjectOwnership(room.projectId, projectId, "room");
if (room.floorId !== null) {
assertReference(floorIds, room.floorId, "room floor");
}
}
const deviceRowIds = new Set<string>();
for (const circuit of snapshot.circuits) {
assertReference(
circuitListIds,
circuit.circuitListId,
"circuit list"
);
assertReference(sectionIds, circuit.sectionId, "circuit section");
const section = sectionById.get(circuit.sectionId)!;
if (section.circuitListId !== circuit.circuitListId) {
throw new Error(
"Snapshot circuit section belongs to a different circuit list."
);
}
if (circuit.isReserve !== (circuit.deviceRows.length === 0)) {
throw new Error(
"Snapshot circuit reserve state must match its device rows."
);
}
for (const row of circuit.deviceRows) {
if (row.circuitId !== circuit.id) {
throw new Error(
"Snapshot device row belongs to a different circuit."
);
}
if (deviceRowIds.has(row.id)) {
throw new Error("Snapshot contains duplicate device row ids.");
}
deviceRowIds.add(row.id);
if (row.linkedProjectDeviceId !== null) {
assertReference(
projectDeviceIds,
row.linkedProjectDeviceId,
"device row project device"
);
}
if (row.roomId !== null) {
assertReference(roomIds, row.roomId, "device row room");
}
}
}
}
function uniqueIds(
entries: ReadonlyArray<{ id: string }>,
label: string
) {
const ids = new Set<string>();
for (const entry of entries) {
if (ids.has(entry.id)) {
throw new Error(`Snapshot contains duplicate ${label} ids.`);
}
ids.add(entry.id);
}
return ids;
}
function assertProjectOwnership(
actualProjectId: string,
expectedProjectId: string,
label: string
) {
if (actualProjectId !== expectedProjectId) {
throw new Error(`Snapshot ${label} belongs to a different project.`);
}
}
function assertReference(
ids: ReadonlySet<string>,
id: string,
label: string
) {
if (!ids.has(id)) {
throw new Error(`Snapshot ${label} reference is invalid.`);
}
}
@@ -0,0 +1,26 @@
export interface ProjectSnapshotMetadata {
id: string;
projectId: string;
sourceRevision: number;
schemaVersion: number;
name: string;
description: string | null;
payloadSha256: string;
createdAtIso: string;
createdByActorId: string | null;
}
export interface CreateNamedProjectSnapshotInput {
projectId: string;
expectedRevision: number;
name: string;
description?: string;
actorId?: string;
}
export interface ProjectSnapshotStore {
createNamed(
input: CreateNamedProjectSnapshotInput
): ProjectSnapshotMetadata | null;
listByProject(projectId: string): ProjectSnapshotMetadata[] | null;
}
@@ -0,0 +1,5 @@
import { db } from "../../db/client.js";
import { ProjectSnapshotRepository } from "../../db/repositories/project-snapshot.repository.js";
export const projectSnapshotStore =
new ProjectSnapshotRepository(db);
@@ -0,0 +1,69 @@
import type { NextFunction, Request, Response } from "express";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
import { createNamedProjectSnapshotSchema } from "../../shared/validation/project-snapshot.schemas.js";
import { projectSnapshotStore } from "../composition/project-snapshot-store.js";
export function listProjectSnapshots(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const snapshots = projectSnapshotStore.listByProject(projectId);
if (!snapshots) {
return res.status(404).json({ error: "Project not found" });
}
return res.json(snapshots);
}
export function createNamedProjectSnapshot(
req: Request,
res: Response,
next: NextFunction
) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const parsed = createNamedProjectSnapshotSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const snapshot = projectSnapshotStore.createNamed({
projectId,
...parsed.data,
});
if (!snapshot) {
return res.status(404).json({ error: "Project not found" });
}
return res.status(201).json(snapshot);
} 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 ProjectSnapshotNameConflictError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_SNAPSHOT_NAME_CONFLICT",
snapshotName: error.snapshotName,
});
}
return next(error);
}
}
function getProjectId(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
res.status(400).json({ error: "Invalid projectId" });
return null;
}
return projectId;
}
+6
View File
@@ -22,6 +22,10 @@ import {
redoProjectCommand,
undoProjectCommand,
} from "../controllers/project-command.controller.js";
import {
createNamedProjectSnapshot,
listProjectSnapshots,
} from "../controllers/project-snapshot.controller.js";
export const projectRouter = Router();
@@ -33,6 +37,8 @@ projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
projectRouter.post("/:projectId/commands", executeProjectCommand);
projectRouter.post("/:projectId/history/undo", undoProjectCommand);
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
projectRouter.get("/:projectId/snapshots", listProjectSnapshots);
projectRouter.post("/:projectId/snapshots", createNamedProjectSnapshot);
projectRouter.put("/:projectId", updateProjectSettings);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
@@ -0,0 +1,10 @@
import { z } from "zod";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
export const createNamedProjectSnapshotSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(100),
description: z.string().trim().max(500).optional(),
})
.strict();
+245
View File
@@ -0,0 +1,245 @@
import crypto from "node:crypto";
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 { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.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 { floors } from "../src/db/schema/floors.js";
import { projectDevices } from "../src/db/schema/project-devices.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 { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-conflict.error.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: "Snapshot-Projekt",
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
})
.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,
cosPhi: 0.9,
voltageV: 400,
})
.run();
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: list.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Technik",
sortOrder: 10,
voltage: 230,
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 lokal",
phaseType: "three_phase",
quantity: 1,
powerPerUnit: 2.5,
simultaneityFactor: 0.8,
cosPhi: 0.9,
})
.run();
return context;
}
describe("project snapshot repository", () => {
it("captures, hashes and lists a complete named logical snapshot", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSnapshotRepository(context.db);
const created = repository.createNamed({
projectId: "project-1",
expectedRevision: 0,
name: " Vor Ausschreibung ",
description: " Sicherer Stand ",
actorId: "planner-1",
});
assert.ok(created);
assert.equal(created.sourceRevision, 0);
assert.equal(created.schemaVersion, 1);
assert.equal(created.name, "Vor Ausschreibung");
assert.equal(created.description, "Sicherer Stand");
assert.equal(created.createdByActorId, "planner-1");
assert.match(created.payloadSha256, /^[a-f0-9]{64}$/);
assert.equal(
context.db
.select({ currentRevision: projects.currentRevision })
.from(projects)
.where(eq(projects.id, "project-1"))
.get()?.currentRevision,
0
);
const persisted = context.db
.select()
.from(projectSnapshots)
.where(eq(projectSnapshots.id, created.id))
.get();
assert.ok(persisted);
assert.equal(
crypto
.createHash("sha256")
.update(persisted.payloadJson)
.digest("hex"),
persisted.payloadSha256
);
const state = deserializeProjectStateSnapshot(
persisted.payloadJson
);
assert.equal(state.project.id, "project-1");
assert.equal(state.distributionBoards.length, 1);
assert.equal(state.circuitLists.length, 1);
assert.equal(
state.circuitSections.length,
context.db.select().from(circuitSections).all().length
);
assert.equal(state.circuits.length, 1);
assert.equal(state.circuits[0].deviceRows.length, 1);
assert.equal(
state.circuits[0].deviceRows[0].linkedProjectDeviceId,
"device-1"
);
assert.equal(state.projectDevices.length, 1);
assert.equal(state.floors.length, 1);
assert.equal(state.rooms.length, 1);
assert.deepEqual(repository.listByProject("project-1"), [
created,
]);
} finally {
context.close();
}
});
it("rejects stale revisions and duplicate names without partial rows", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSnapshotRepository(context.db);
assert.throws(
() =>
repository.createNamed({
projectId: "project-1",
expectedRevision: 1,
name: "Stale",
}),
ProjectRevisionConflictError
);
const created = repository.createNamed({
projectId: "project-1",
expectedRevision: 0,
name: "Freigabe",
});
assert.ok(created);
assert.throws(
() =>
repository.createNamed({
projectId: "project-1",
expectedRevision: 0,
name: " Freigabe ",
}),
ProjectSnapshotNameConflictError
);
assert.equal(
context.db.select().from(projectSnapshots).all().length,
1
);
} finally {
context.close();
}
});
it("distinguishes unknown projects from empty snapshot lists", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSnapshotRepository(context.db);
assert.deepEqual(repository.listByProject("project-1"), []);
assert.equal(repository.listByProject("missing"), null);
assert.equal(
repository.createNamed({
projectId: "missing",
expectedRevision: 0,
name: "Unbekannt",
}),
null
);
} finally {
context.close();
}
});
});
+166
View File
@@ -0,0 +1,166 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
deserializeProjectStateSnapshot,
parseProjectStateSnapshot,
serializeProjectStateSnapshot,
} from "../src/domain/models/project-state-snapshot.model.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
function minimalSnapshot() {
return {
schemaVersion: 1 as const,
project: {
id: "project-1",
name: "Projekt",
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
},
distributionBoards: [],
circuitLists: [],
circuitSections: [],
circuits: [],
projectDevices: [],
floors: [],
rooms: [],
};
}
describe("project state snapshot model", () => {
it("round-trips a versioned logical project state", () => {
const snapshot = parseProjectStateSnapshot(minimalSnapshot());
assert.deepEqual(
deserializeProjectStateSnapshot(
serializeProjectStateSnapshot(snapshot)
),
snapshot
);
});
it("rejects duplicate ids and cross-project ownership", () => {
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
},
{
id: "board-1",
projectId: "project-1",
name: "UV 2",
},
],
}),
/duplicate distribution board ids/
);
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
floors: [
{
id: "floor-1",
projectId: "project-2",
name: "EG",
sortOrder: 10,
},
],
}),
/floor belongs to a different project/
);
});
it("rejects invalid references and inconsistent reserve circuits", () => {
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
rooms: [
{
id: "room-1",
projectId: "project-1",
floorId: "missing",
roomNumber: "001",
roomName: "Technik",
},
],
}),
/room floor reference is invalid/
);
assert.throws(
() =>
parseProjectStateSnapshot({
...minimalSnapshot(),
circuits: [
{
id: "circuit-1",
circuitListId: "missing",
sectionId: "missing",
equipmentIdentifier: "-1F1",
displayName: null,
sortOrder: 10,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: null,
controlRequirement: null,
status: null,
isReserve: true,
remark: null,
deviceRows: [],
},
],
}),
/circuit list reference is invalid/
);
});
});
describe("named project snapshot request", () => {
it("normalizes bounded snapshot metadata", () => {
assert.deepEqual(
createNamedProjectSnapshotSchema.parse({
expectedRevision: 12,
name: " Freigabe ",
description: " Stand vor Ausschreibung ",
}),
{
expectedRevision: 12,
name: "Freigabe",
description: "Stand vor Ausschreibung",
}
);
});
it("rejects stale-shaped and excessive input", () => {
for (const input of [
{ expectedRevision: -1, name: "Stand" },
{ expectedRevision: 0, name: " " },
{ expectedRevision: 0, name: "x".repeat(101) },
{
expectedRevision: 0,
name: "Stand",
description: "x".repeat(501),
},
{
expectedRevision: 0,
name: "Stand",
unexpected: true,
},
]) {
assert.equal(
createNamedProjectSnapshotSchema.safeParse(input).success,
false
);
}
});
});