Add named project snapshots
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user