Include components in project snapshots

This commit is contained in:
2026-07-30 19:24:28 +02:00
parent e7607c90c4
commit 012308984d
13 changed files with 915 additions and 46 deletions
+5
View File
@@ -255,6 +255,11 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart.
Snapshot schema version 7 also contains circuit-group identity,
distribution-board components and the separate circuit/component protection
records. Versions 1 through 6 remain readable. Portable duplicate imports
remap component ids and protection-owner references together with the existing
project graph.
The central revision boundary creates an automatic logical snapshot after each
25 new revisions and retains only the newest 12 automatic snapshots per
project. Named snapshots are never removed by this retention policy.
+13 -4
View File
@@ -261,8 +261,9 @@ mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu
zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt
die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen
innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
@@ -290,6 +291,13 @@ Snapshot-Schema 4 enthält bereits Etage und Netzart; Schema 1/2 sowie
gespeicherte Version-1-Strukturcommands werden ohne erfundene Zuordnung
hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als
Projektauswahl hochgestuft.
Snapshot-Schema 7 ergänzt Gruppenkategorie und -nummer,
Verteilerkomponenten sowie die getrennten 1:1-Schutzgerätedaten. Capture,
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
JSON-Importmodi verwenden denselben vollständigen Zustand. Schema 6 und älter
bleiben lesbar: Die drei bekannten Abschnittsschlüssel werden deterministisch
Gruppe 1 zugeordnet, neue Komponenten- und Schutzgerätesammlungen bleiben leer
und bestehende flache Schutzangaben unverändert erhalten.
`project-floor.insert` und `project-room.insert` versionieren die Anlage von
Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden
jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur
@@ -342,8 +350,9 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
bisherigen flachen Schutzfelder bleiben in dieser Übergangsphase unverändert.
Ein triggergeführtes Register erzwingt bereits eine normalisierte,
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
Verteilerkomponenten. Snapshot-, Transfer-, Command- und UI-Integration
folgen in den nächsten abgegrenzten Arbeitspaketen.
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden
Snapshot-Schema 7; Command- und UI-Integration folgen in den nächsten
abgegrenzten Arbeitspaketen.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
+1 -1
View File
@@ -16,7 +16,7 @@ requirements and intended sequencing, not proof of implementation.
BMK rules.
- [x] Phase B1: additive relational schema, group backfill, component/protection
tables and shared BMK uniqueness.
- [ ] Phase B2: snapshot/transfer schema upgrade and deterministic compatibility
- [x] Phase B2: snapshot/transfer schema upgrade and deterministic compatibility
mapping.
- [ ] Phase C: persistent commands and new-board defaults.
- [ ] Phase D: group numbering, moves and destructive operations.
@@ -545,8 +545,7 @@ Acceptance:
### B. Persistence and Compatibility Model
Status: In progress. Relational foundation B1 is complete; logical
snapshot/transfer integration B2 is pending.
Status: Complete.
- add group category and group number
- add distribution-board component persistence
@@ -570,6 +569,21 @@ Implemented in B1:
- newly created legacy-compatible board structures receive the same initial
group identity while their persisted command format remains unchanged
Implemented in B2:
- snapshot schema version 7 includes group identity, distribution-board
components and both one-to-one protection collections
- capture, named and automatic snapshots, restore, Undo/Redo and portable JSON
transfers preserve the complete new relational state
- duplicate imports remap component UUIDs and both protection-owner references
together with the existing project structure
- versions 1 through 6 remain readable; version 6 maps the three established
section keys to group 1 and leaves new collections empty
- older flat circuit protection values remain present and are not guessed into
strict new protection configurations
- current snapshots reject invalid ownership, duplicate group numbers,
cross-entity BMKs and invalid protection configurations before persistence
Acceptance:
- a current database migrates without data loss
@@ -9,6 +9,7 @@ import {
legacyProjectStateSnapshotSchemaVersion,
previousProjectStateSnapshotSchemaVersion,
projectStateSnapshotSchemaVersion,
simultaneityFactorProjectStateSnapshotSchemaVersion,
supplyTypesProjectStateSnapshotSchemaVersion,
voltageProjectStateSnapshotSchemaVersion,
} from "../../domain/models/project-state-snapshot.model.js";
@@ -152,6 +153,8 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
supplyTypesProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !==
voltageProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !==
simultaneityFactorProjectStateSnapshotSchemaVersion &&
stored.schemaVersion !== projectStateSnapshotSchemaVersion
) {
throw new Error("Project snapshot schema version is not supported.");
@@ -15,9 +15,12 @@ import type {
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { consumers } from "../schema/consumers.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
@@ -141,6 +144,17 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
distributionBoards: byId(state.distributionBoards),
circuitLists: byId(state.circuitLists),
circuitSections: byId(state.circuitSections),
distributionBoardComponents: byId(
state.distributionBoardComponents
),
circuitProtectionDevices: byKey(
state.circuitProtectionDevices,
"circuitId"
),
distributionBoardComponentProtectionDevices: byKey(
state.distributionBoardComponentProtectionDevices,
"componentId"
),
circuits: byId(
state.circuits.map((circuit) => ({
...circuit,
@@ -159,6 +173,15 @@ function byId<T extends { id: string }>(entries: readonly T[]) {
);
}
function byKey<
TKey extends string,
TEntry extends Record<TKey, string>,
>(entries: readonly TEntry[], key: TKey) {
return [...entries].sort((left, right) =>
left[key].localeCompare(right[key])
);
}
function replaceProjectState(
database: AppDatabase,
state: ProjectStateSnapshot
@@ -214,6 +237,21 @@ function replaceProjectState(
isReserve: circuit.isReserve ? 1 : 0,
}))
);
insertMany(
database,
distributionBoardComponents,
state.distributionBoardComponents
);
insertMany(
database,
circuitProtectionDevices,
state.circuitProtectionDevices
);
insertMany(
database,
distributionBoardComponentProtectionDevices,
state.distributionBoardComponentProtectionDevices
);
insertMany(
database,
circuitDeviceRows,
@@ -9,8 +9,11 @@ import {
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
@@ -69,6 +72,8 @@ export function readProjectStateSnapshot(
displayName: circuitSections.displayName,
prefix: circuitSections.prefix,
sortOrder: circuitSections.sortOrder,
category: circuitSections.category,
groupNumber: circuitSections.groupNumber,
})
.from(circuitSections)
.innerJoin(
@@ -82,6 +87,56 @@ export function readProjectStateSnapshot(
asc(circuitSections.id)
)
.all();
const componentRows = database
.select({ component: distributionBoardComponents })
.from(distributionBoardComponents)
.innerJoin(
circuitLists,
eq(circuitLists.id, distributionBoardComponents.circuitListId)
)
.where(eq(circuitLists.projectId, projectId))
.orderBy(
asc(distributionBoardComponents.circuitListId),
asc(distributionBoardComponents.placement),
asc(distributionBoardComponents.sortOrder),
asc(distributionBoardComponents.id)
)
.all()
.map((row) => row.component);
const circuitProtectionRows = database
.select({ protection: circuitProtectionDevices })
.from(circuitProtectionDevices)
.innerJoin(
circuits,
eq(circuits.id, circuitProtectionDevices.circuitId)
)
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(eq(circuitLists.projectId, projectId))
.orderBy(asc(circuitProtectionDevices.circuitId))
.all()
.map((row) => row.protection);
const componentProtectionRows = database
.select({
protection: distributionBoardComponentProtectionDevices,
})
.from(distributionBoardComponentProtectionDevices)
.innerJoin(
distributionBoardComponents,
eq(
distributionBoardComponents.id,
distributionBoardComponentProtectionDevices.componentId
)
)
.innerJoin(
circuitLists,
eq(circuitLists.id, distributionBoardComponents.circuitListId)
)
.where(eq(circuitLists.projectId, projectId))
.orderBy(
asc(distributionBoardComponentProtectionDevices.componentId)
)
.all()
.map((row) => row.protection);
const circuitRows = database
.select({
id: circuits.id,
@@ -190,6 +245,10 @@ export function readProjectStateSnapshot(
distributionBoards: boardRows,
circuitLists: listRows,
circuitSections: sectionRows,
distributionBoardComponents: componentRows,
circuitProtectionDevices: circuitProtectionRows,
distributionBoardComponentProtectionDevices:
componentProtectionRows,
circuits: circuitRows.map((circuit) => ({
...circuit,
isReserve: Boolean(circuit.isReserve),
@@ -10,8 +10,11 @@ import type { ProjectTransferStore } from "../../domain/ports/project-transfer.s
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
@@ -127,6 +130,21 @@ function insertProjectState(
isReserve: circuit.isReserve ? 1 : 0,
}))
);
insertMany(
database,
distributionBoardComponents,
state.distributionBoardComponents
);
insertMany(
database,
circuitProtectionDevices,
state.circuitProtectionDevices
);
insertMany(
database,
distributionBoardComponentProtectionDevices,
state.distributionBoardComponentProtectionDevices
);
insertMany(
database,
circuitDeviceRows,
+383 -34
View File
@@ -3,6 +3,21 @@ import {
defaultDistributionBoardSupplyTypes,
distributionBoardSupplyTypes,
} from "../../shared/constants/distribution-board.js";
import {
circuitGroupCategories,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group.js";
import {
distributionBoardComponentPlacements,
distributionBoardComponentRoles,
} from "../../shared/constants/distribution-board-component.js";
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
import {
breakerTripCharacteristics,
fuseUtilizationCategories,
protectionDeviceTypes,
rcdTypes,
} from "../../shared/constants/protection-device.js";
import {
resolveCircuitPhaseType,
resolveProjectVoltage,
@@ -14,7 +29,8 @@ export const previousProjectStateSnapshotSchemaVersion = 2 as const;
export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const;
export const supplyTypesProjectStateSnapshotSchemaVersion = 4 as const;
export const voltageProjectStateSnapshotSchemaVersion = 5 as const;
export const projectStateSnapshotSchemaVersion = 6 as const;
export const simultaneityFactorProjectStateSnapshotSchemaVersion = 6 as const;
export const projectStateSnapshotSchemaVersion = 7 as const;
const idSchema = z.string().trim().min(1);
const nullableStringSchema = z.string().nullable();
@@ -73,7 +89,7 @@ const circuitListSchema = z
})
.strict();
const circuitSectionSchema = z
const legacyCircuitSectionSchema = z
.object({
id: idSchema,
circuitListId: idSchema,
@@ -84,6 +100,22 @@ const circuitSectionSchema = z
})
.strict();
const circuitSectionSchema = legacyCircuitSectionSchema
.extend({
category: z.enum(circuitGroupCategories).nullable(),
groupNumber: z.number().int().positive().nullable(),
})
.strict()
.superRefine((section, context) => {
if ((section.category === null) !== (section.groupNumber === null)) {
context.addIssue({
code: "custom",
message:
"Circuit-section category and group number must both be set or both be null.",
});
}
});
const circuitDeviceRowSchema = z
.object({
id: idSchema,
@@ -173,21 +205,106 @@ const roomSchema = z
})
.strict();
const commonProjectStateSnapshotContents = {
const distributionBoardComponentSchema = z
.object({
id: idSchema,
circuitListId: idSchema,
sectionId: idSchema.nullable(),
equipmentIdentifier: z.string().trim().min(1),
name: z.string().trim().min(1),
role: z.enum(distributionBoardComponentRoles),
placement: z.enum(distributionBoardComponentPlacements),
sortOrder: finiteNumberSchema,
})
.strict();
const persistedProtectionDeviceFields = {
type: z.enum(protectionDeviceTypes),
ratedCurrentA: finiteNumberSchema.positive(),
fuseUtilizationCategory: z.enum(fuseUtilizationCategories).nullable(),
tripCharacteristic: z.enum(breakerTripCharacteristics).nullable(),
rcdType: z.enum(rcdTypes).nullable(),
ratedResidualCurrentMa: finiteNumberSchema.positive().nullable(),
};
function validatePersistedProtectionDevice(
value: {
type: (typeof protectionDeviceTypes)[number];
ratedCurrentA: number;
fuseUtilizationCategory:
| (typeof fuseUtilizationCategories)[number]
| null;
tripCharacteristic:
| (typeof breakerTripCharacteristics)[number]
| null;
rcdType: (typeof rcdTypes)[number] | null;
ratedResidualCurrentMa: number | null;
},
context: z.RefinementCtx
) {
const result = protectionDeviceConfigurationSchema.safeParse({
type: value.type,
ratedCurrentA: value.ratedCurrentA,
...(value.fuseUtilizationCategory === null
? {}
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
...(value.tripCharacteristic === null
? {}
: { tripCharacteristic: value.tripCharacteristic }),
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
...(value.ratedResidualCurrentMa === null
? {}
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
});
if (!result.success) {
context.addIssue({
code: "custom",
message: "Snapshot protection-device configuration is invalid.",
});
}
}
const circuitProtectionDeviceSchema = z
.object({
circuitId: idSchema,
...persistedProtectionDeviceFields,
})
.strict()
.superRefine(validatePersistedProtectionDevice);
const componentProtectionDeviceSchema = z
.object({
componentId: idSchema,
...persistedProtectionDeviceFields,
})
.strict()
.superRefine(validatePersistedProtectionDevice);
const legacyProjectStateSnapshotContents = {
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
circuitSections: z.array(legacyCircuitSectionSchema),
circuits: z.array(circuitSchema),
projectDevices: z.array(projectDeviceSchema),
floors: z.array(floorSchema),
rooms: z.array(roomSchema),
};
const currentProjectStateSnapshotContents = {
...legacyProjectStateSnapshotContents,
circuitSections: z.array(circuitSectionSchema),
distributionBoardComponents: z.array(distributionBoardComponentSchema),
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
distributionBoardComponentProtectionDevices: z.array(
componentProtectionDeviceSchema
),
};
const legacyProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: legacyProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
...commonProjectStateSnapshotContents,
...legacyProjectStateSnapshotContents,
})
.strict();
@@ -196,7 +313,7 @@ const previousProjectStateSnapshotSchema = z
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
...commonProjectStateSnapshotContents,
...legacyProjectStateSnapshotContents,
})
.strict();
@@ -207,7 +324,7 @@ const distributionBoardProjectStateSnapshotSchema = z
),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
...commonProjectStateSnapshotContents,
...legacyProjectStateSnapshotContents,
})
.strict();
@@ -216,7 +333,7 @@ const supplyTypesProjectStateSnapshotSchema = z
schemaVersion: z.literal(supplyTypesProjectStateSnapshotSchemaVersion),
project: currentProjectSchema,
distributionBoards: z.array(distributionBoardSchema),
...commonProjectStateSnapshotContents,
...legacyProjectStateSnapshotContents,
})
.strict();
@@ -227,12 +344,20 @@ const voltageProjectStateSnapshotSchema =
),
});
export const projectStateSnapshotSchema =
const simultaneityFactorProjectStateSnapshotSchema =
voltageProjectStateSnapshotSchema.extend({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
schemaVersion: z.literal(
simultaneityFactorProjectStateSnapshotSchemaVersion
),
distributionBoards: z.array(currentDistributionBoardSchema),
});
export const projectStateSnapshotSchema =
simultaneityFactorProjectStateSnapshotSchema.extend({
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
...currentProjectStateSnapshotContents,
});
export type ProjectStateSnapshot = z.infer<
typeof projectStateSnapshotSchema
>;
@@ -243,46 +368,65 @@ export function parseProjectStateSnapshot(
const version = isPlainObject(value) ? value.schemaVersion : undefined;
const parsedSnapshot =
version === legacyProjectStateSnapshotSchemaVersion
? upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot(
upgradeLegacyProjectStateSnapshot(
legacyProjectStateSnapshotSchema.parse(value)
? upgradeSimultaneityFactorProjectStateSnapshot(
upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot(
upgradeLegacyProjectStateSnapshot(
legacyProjectStateSnapshotSchema.parse(value)
)
)
)
)
)
)
: version === previousProjectStateSnapshotSchemaVersion
? upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot(
previousProjectStateSnapshotSchema.parse(value)
? upgradeSimultaneityFactorProjectStateSnapshot(
upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
upgradePreviousProjectStateSnapshot(
previousProjectStateSnapshotSchema.parse(value)
)
)
)
)
)
: version === distributionBoardProjectStateSnapshotSchemaVersion
? upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
distributionBoardProjectStateSnapshotSchema.parse(value)
? upgradeSimultaneityFactorProjectStateSnapshot(
upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
upgradeDistributionBoardProjectStateSnapshot(
distributionBoardProjectStateSnapshotSchema.parse(
value
)
)
)
)
)
: version === supplyTypesProjectStateSnapshotSchemaVersion
? upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
supplyTypesProjectStateSnapshotSchema.parse(value)
? upgradeSimultaneityFactorProjectStateSnapshot(
upgradeVoltageProjectStateSnapshot(
upgradeSupplyTypesProjectStateSnapshot(
supplyTypesProjectStateSnapshotSchema.parse(value)
)
)
)
: version === voltageProjectStateSnapshotSchemaVersion
? upgradeVoltageProjectStateSnapshot(
voltageProjectStateSnapshotSchema.parse(value)
? upgradeSimultaneityFactorProjectStateSnapshot(
upgradeVoltageProjectStateSnapshot(
voltageProjectStateSnapshotSchema.parse(value)
)
)
: projectStateSnapshotSchema.parse(value);
: version ===
simultaneityFactorProjectStateSnapshotSchemaVersion
? upgradeSimultaneityFactorProjectStateSnapshot(
simultaneityFactorProjectStateSnapshotSchema.parse(
value
)
)
: projectStateSnapshotSchema.parse(value);
const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot);
assertProjectStateSnapshotRelations(snapshot);
return snapshot;
@@ -412,10 +556,10 @@ function upgradeSupplyTypesProjectStateSnapshot(
function upgradeVoltageProjectStateSnapshot(
snapshot: z.infer<typeof voltageProjectStateSnapshotSchema>
): ProjectStateSnapshot {
): z.infer<typeof simultaneityFactorProjectStateSnapshotSchema> {
return {
...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion,
schemaVersion: simultaneityFactorProjectStateSnapshotSchemaVersion,
distributionBoards: snapshot.distributionBoards.map((board) => ({
...board,
simultaneityFactor: 1,
@@ -423,6 +567,39 @@ function upgradeVoltageProjectStateSnapshot(
};
}
function upgradeSimultaneityFactorProjectStateSnapshot(
snapshot: z.infer<typeof simultaneityFactorProjectStateSnapshotSchema>
): ProjectStateSnapshot {
return {
...snapshot,
schemaVersion: projectStateSnapshotSchemaVersion,
circuitSections: snapshot.circuitSections.map((section) => {
const category = initialCategoryBySectionKey(section.key);
return {
...section,
category,
groupNumber: category === null ? null : 1,
};
}),
distributionBoardComponents: [],
circuitProtectionDevices: [],
distributionBoardComponentProtectionDevices: [],
};
}
function initialCategoryBySectionKey(
key: string
): CircuitGroupCategory | null {
if (
key === "lighting" ||
key === "single_phase" ||
key === "three_phase"
) {
return key;
}
return null;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -461,10 +638,22 @@ function assertProjectStateSnapshotRelations(
);
const floorIds = uniqueIds(snapshot.floors, "floor");
const roomIds = uniqueIds(snapshot.rooms, "room");
uniqueIds(snapshot.circuits, "circuit");
const circuitIds = uniqueIds(snapshot.circuits, "circuit");
const componentIds = uniqueIds(
snapshot.distributionBoardComponents,
"distribution board component"
);
const sectionById = new Map(
snapshot.circuitSections.map((entry) => [entry.id, entry])
);
const componentById = new Map(
snapshot.distributionBoardComponents.map((entry) => [
entry.id,
entry,
])
);
const groupKeys = new Set<string>();
const equipmentIdentifiersByList = new Map<string, Set<string>>();
for (const board of snapshot.distributionBoards) {
assertProjectOwnership(board.projectId, projectId, "distribution board");
@@ -500,6 +689,19 @@ function assertProjectStateSnapshotRelations(
section.circuitListId,
"circuit section list"
);
if (section.category !== null && section.groupNumber !== null) {
const groupKey = [
section.circuitListId,
section.category,
section.groupNumber,
].join(":");
if (groupKeys.has(groupKey)) {
throw new Error(
"Snapshot contains a duplicate circuit group number."
);
}
groupKeys.add(groupKey);
}
}
for (const device of snapshot.projectDevices) {
assertProjectOwnership(device.projectId, projectId, "project device");
@@ -553,6 +755,11 @@ function assertProjectStateSnapshotRelations(
"Snapshot circuit voltage must match project settings."
);
}
registerEquipmentIdentifier(
equipmentIdentifiersByList,
circuit.circuitListId,
circuit.equipmentIdentifier
);
for (const row of circuit.deviceRows) {
if (row.circuitId !== circuit.id) {
throw new Error(
@@ -575,6 +782,148 @@ function assertProjectStateSnapshotRelations(
}
}
}
const fixedComponentRolesByList = new Set<string>();
const groupComponentRolesBySection = new Set<string>();
for (const component of snapshot.distributionBoardComponents) {
assertReference(
circuitListIds,
component.circuitListId,
"distribution board component circuit list"
);
if (component.sectionId !== null) {
assertReference(
sectionIds,
component.sectionId,
"distribution board component section"
);
if (
sectionById.get(component.sectionId)?.circuitListId !==
component.circuitListId
) {
throw new Error(
"Snapshot distribution board component section belongs to a different circuit list."
);
}
}
assertComponentPlacement(component);
registerEquipmentIdentifier(
equipmentIdentifiersByList,
component.circuitListId,
component.equipmentIdentifier
);
if (
component.role === "main_switch" ||
component.role === "surge_protective_device"
) {
assertUniqueKey(
fixedComponentRolesByList,
`${component.circuitListId}:${component.role}`,
"Snapshot contains duplicate fixed distribution board components."
);
}
if (
component.role === "group_upstream_protection" ||
component.role === "group_residual_current_protection"
) {
assertUniqueKey(
groupComponentRolesBySection,
`${component.sectionId}:${component.role}`,
"Snapshot contains duplicate group protection components."
);
}
}
const protectedCircuitIds = new Set<string>();
for (const protection of snapshot.circuitProtectionDevices) {
assertReference(
circuitIds,
protection.circuitId,
"circuit protection device circuit"
);
assertUniqueKey(
protectedCircuitIds,
protection.circuitId,
"Snapshot contains duplicate circuit protection devices."
);
}
const protectedComponentIds = new Set<string>();
for (const protection of snapshot.distributionBoardComponentProtectionDevices) {
assertReference(
componentIds,
protection.componentId,
"component protection device component"
);
assertUniqueKey(
protectedComponentIds,
protection.componentId,
"Snapshot contains duplicate component protection devices."
);
const component = componentById.get(protection.componentId)!;
if (
component.role !== "group_upstream_protection" &&
component.role !== "group_residual_current_protection"
) {
throw new Error(
"Snapshot protection data belongs to a non-protection component."
);
}
}
}
function assertComponentPlacement(
component: ProjectStateSnapshot["distributionBoardComponents"][number]
) {
const isFixedHeader =
component.role === "main_switch" ||
component.role === "surge_protective_device";
const isGroupProtection =
component.role === "group_upstream_protection" ||
component.role === "group_residual_current_protection";
if (
(isFixedHeader &&
(component.placement !== "header" ||
component.sectionId !== null)) ||
(isGroupProtection &&
(component.placement !== "group" ||
component.sectionId === null)) ||
(component.role === "auxiliary" &&
(component.placement !== "footer" ||
component.sectionId !== null))
) {
throw new Error(
"Snapshot distribution board component placement is invalid."
);
}
}
function registerEquipmentIdentifier(
identifiersByList: Map<string, Set<string>>,
circuitListId: string,
equipmentIdentifier: string
) {
const identifiers =
identifiersByList.get(circuitListId) ?? new Set<string>();
const normalized = equipmentIdentifier.trim().toLocaleLowerCase("de");
if (identifiers.has(normalized)) {
throw new Error(
"Snapshot contains duplicate equipment identifiers in one circuit list."
);
}
identifiers.add(normalized);
identifiersByList.set(circuitListId, identifiers);
}
function assertUniqueKey(
keys: Set<string>,
key: string,
message: string
) {
if (keys.has(key)) {
throw new Error(message);
}
keys.add(key);
}
function uniqueIds(
@@ -79,6 +79,10 @@ export function remapProjectState(
const listIds = idMap(source.circuitLists, createId);
const sectionIds = idMap(source.circuitSections, createId);
const circuitIds = idMap(source.circuits, createId);
const componentIds = idMap(
source.distributionBoardComponents,
createId
);
const deviceIds = idMap(source.projectDevices, createId);
const floorIds = idMap(source.floors, createId);
const roomIds = idMap(source.rooms, createId);
@@ -110,6 +114,35 @@ export function remapProjectState(
id: requiredId(sectionIds, section.id),
circuitListId: requiredId(listIds, section.circuitListId),
})),
distributionBoardComponents:
source.distributionBoardComponents.map((component) => ({
...component,
id: requiredId(componentIds, component.id),
circuitListId: requiredId(
listIds,
component.circuitListId
),
sectionId:
component.sectionId === null
? null
: requiredId(sectionIds, component.sectionId),
})),
circuitProtectionDevices: source.circuitProtectionDevices.map(
(protection) => ({
...protection,
circuitId: requiredId(circuitIds, protection.circuitId),
})
),
distributionBoardComponentProtectionDevices:
source.distributionBoardComponentProtectionDevices.map(
(protection) => ({
...protection,
componentId: requiredId(
componentIds,
protection.componentId
),
})
),
circuits: source.circuits.map((circuit) => ({
...circuit,
id: requiredId(circuitIds, circuit.id),
+73
View File
@@ -14,8 +14,11 @@ import { ProjectTransferRepository } from "../src/db/repositories/project-transf
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { floors } from "../src/db/schema/floors.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
@@ -129,9 +132,58 @@ function createTestDatabase(): DatabaseContext {
cosPhi: 0.9,
})
.run();
context.db
.insert(circuitProtectionDevices)
.values({
circuitId: "circuit-1",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
})
.run();
context.db
.insert(distributionBoardComponents)
.values({
id: "component-1",
circuitListId: list.id,
sectionId: section.id,
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 10,
})
.run();
context.db
.insert(distributionBoardComponentProtectionDevices)
.values({
componentId: "component-1",
type: "FI",
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A",
ratedResidualCurrentMa: 30,
})
.run();
return context;
}
function removeVersionSevenFields(state: Record<string, unknown>) {
delete state.distributionBoardComponents;
delete state.circuitProtectionDevices;
delete state.distributionBoardComponentProtectionDevices;
for (const section of state.circuitSections as Array<
Record<string, unknown>
>) {
delete section.category;
delete section.groupNumber;
}
}
describe("project snapshot repository", () => {
it("captures, hashes and lists a complete named logical snapshot", () => {
const context = createTestDatabase();
@@ -190,6 +242,12 @@ describe("project snapshot repository", () => {
context.db.select().from(circuitSections).all().length
);
assert.equal(state.circuits.length, 1);
assert.equal(state.circuitProtectionDevices.length, 1);
assert.equal(state.distributionBoardComponents.length, 1);
assert.equal(
state.distributionBoardComponentProtectionDevices.length,
1
);
assert.equal(state.circuits[0].deviceRows.length, 1);
assert.equal(
state.circuits[0].deviceRows[0].linkedProjectDeviceId,
@@ -291,6 +349,7 @@ describe("project snapshot repository", () => {
project: Record<string, unknown>;
};
currentPayload.schemaVersion = 1;
removeVersionSevenFields(currentPayload);
delete currentPayload.project.internalProjectNumber;
delete currentPayload.project.externalProjectNumber;
delete currentPayload.project.buildingOwner;
@@ -368,6 +427,19 @@ describe("portable project transfer", () => {
copied.state.circuits[0].id,
source.state.circuits[0].id
);
assert.notEqual(
copied.state.distributionBoardComponents[0].id,
source.state.distributionBoardComponents[0].id
);
assert.equal(
copied.state.circuitProtectionDevices[0].circuitId,
copied.state.circuits[0].id
);
assert.equal(
copied.state.distributionBoardComponentProtectionDevices[0]
.componentId,
copied.state.distributionBoardComponents[0].id
);
assert.equal(
copied.state.circuits[0].deviceRows[0].linkedProjectDeviceId,
copied.state.projectDevices[0].id
@@ -438,6 +510,7 @@ describe("portable project transfer", () => {
distributionBoards: Array<Record<string, unknown>>;
};
legacyState.schemaVersion = 2;
removeVersionSevenFields(legacyState);
delete (
legacyState.project as Record<string, unknown>
).enabledDistributionBoardSupplyTypes;
@@ -27,9 +27,12 @@ import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/pro
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { consumers } from "../src/db/schema/consumers.js";
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
import { floors } from "../src/db/schema/floors.js";
import { legacyConsumerCircuitMigrations } from "../src/db/schema/legacy-consumer-circuit-migrations.js";
import { legacyConsumerMigrationReports } from "../src/db/schema/legacy-consumer-migration-report.js";
@@ -132,6 +135,43 @@ function createTestDatabase(): DatabaseContext {
simultaneityFactor: 0.8,
})
.run();
context.db
.insert(circuitProtectionDevices)
.values({
circuitId: "circuit-1",
type: "LS",
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B",
rcdType: null,
ratedResidualCurrentMa: null,
})
.run();
context.db
.insert(distributionBoardComponents)
.values({
id: "component-1",
circuitListId: list.id,
sectionId: section.id,
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 10,
})
.run();
context.db
.insert(distributionBoardComponentProtectionDevices)
.values({
componentId: "component-1",
type: "FI",
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A",
ratedResidualCurrentMa: 30,
})
.run();
return context;
}
@@ -173,6 +213,16 @@ function changeCompleteProjectState(context: DatabaseContext) {
})
.where(eq(circuits.id, "circuit-1"))
.run();
context.db
.update(circuitProtectionDevices)
.set({ ratedCurrentA: 16 })
.where(eq(circuitProtectionDevices.circuitId, "circuit-1"))
.run();
context.db
.update(distributionBoardComponents)
.set({ name: "Geänderter Gruppen-FI" })
.where(eq(distributionBoardComponents.id, "component-1"))
.run();
context.db
.insert(floors)
.values({
+223 -5
View File
@@ -32,6 +32,9 @@ function minimalSnapshot() {
distributionBoards: [],
circuitLists: [],
circuitSections: [],
distributionBoardComponents: [],
circuitProtectionDevices: [],
distributionBoardComponentProtectionDevices: [],
circuits: [],
projectDevices: [],
floors: [],
@@ -39,6 +42,111 @@ function minimalSnapshot() {
};
}
function legacyMinimalSnapshot() {
const {
distributionBoardComponents: _distributionBoardComponents,
circuitProtectionDevices: _circuitProtectionDevices,
distributionBoardComponentProtectionDevices:
_distributionBoardComponentProtectionDevices,
...snapshot
} = minimalSnapshot();
return snapshot;
}
function protectedSnapshot() {
return {
...minimalSnapshot(),
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
floorId: null,
supplyType: null,
simultaneityFactor: 1,
},
],
circuitLists: [
{
id: "list-1",
projectId: "project-1",
distributionBoardId: "board-1",
name: "UV 1 Stromkreisliste",
},
],
circuitSections: [
{
id: "section-1",
circuitListId: "list-1",
key: "lighting",
displayName: "Beleuchtung 1",
prefix: "-1F1.",
sortOrder: 10,
category: "lighting" as const,
groupNumber: 1,
},
],
circuits: [
{
id: "circuit-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1F1.1",
displayName: "Reserve",
sortOrder: 10,
protectionType: null,
protectionRatedCurrent: null,
protectionCharacteristic: null,
cableType: null,
cableCrossSection: null,
cableLength: null,
rcdAssignment: null,
terminalDesignation: null,
voltage: 230,
controlRequirement: null,
status: null,
isReserve: true,
remark: null,
deviceRows: [],
},
],
distributionBoardComponents: [
{
id: "component-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection" as const,
placement: "group" as const,
sortOrder: 10,
},
],
circuitProtectionDevices: [
{
circuitId: "circuit-1",
type: "LS" as const,
ratedCurrentA: 10,
fuseUtilizationCategory: null,
tripCharacteristic: "B" as const,
rcdType: null,
ratedResidualCurrentMa: null,
},
],
distributionBoardComponentProtectionDevices: [
{
componentId: "component-1",
type: "FI" as const,
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A" as const,
ratedResidualCurrentMa: 30,
},
],
};
}
describe("project state snapshot model", () => {
it("round-trips a versioned logical project state", () => {
const snapshot = parseProjectStateSnapshot(minimalSnapshot());
@@ -52,7 +160,7 @@ describe("project state snapshot model", () => {
it("upgrades version-one snapshots with empty project metadata", () => {
const legacy = {
...minimalSnapshot(),
...legacyMinimalSnapshot(),
schemaVersion: 1 as const,
project: {
id: "project-1",
@@ -82,7 +190,7 @@ describe("project state snapshot model", () => {
it("upgrades version-two distribution boards without inferred assignments", () => {
const previous = {
...minimalSnapshot(),
...legacyMinimalSnapshot(),
schemaVersion: 2 as const,
project: {
...minimalSnapshot().project,
@@ -110,7 +218,7 @@ describe("project state snapshot model", () => {
it("upgrades version-three snapshots with all project supply types", () => {
const previous = {
...minimalSnapshot(),
...legacyMinimalSnapshot(),
schemaVersion: 3 as const,
project: {
...minimalSnapshot().project,
@@ -131,7 +239,7 @@ describe("project state snapshot model", () => {
it("normalizes version-four device voltages to project settings", () => {
const previous = {
...minimalSnapshot(),
...legacyMinimalSnapshot(),
schemaVersion: 4 as const,
projectDevices: [
{
@@ -161,7 +269,7 @@ describe("project state snapshot model", () => {
it("upgrades version-five distribution boards with a neutral factor", () => {
const previous = {
...minimalSnapshot(),
...legacyMinimalSnapshot(),
schemaVersion: 5 as const,
distributionBoards: [
{
@@ -183,6 +291,116 @@ describe("project state snapshot model", () => {
);
});
it("upgrades version-six sections without inventing component data", () => {
const previous = {
...legacyMinimalSnapshot(),
schemaVersion: 6 as const,
circuitSections: [
{
id: "section-lighting",
circuitListId: "list-1",
key: "lighting",
displayName: "Beleuchtung",
prefix: "-1F",
sortOrder: 10,
},
{
id: "section-unassigned",
circuitListId: "list-1",
key: "unassigned",
displayName: "Nicht zugeordnet",
prefix: "-UF",
sortOrder: 90,
},
],
distributionBoards: [
{
id: "board-1",
projectId: "project-1",
name: "UV 1",
floorId: null,
supplyType: null,
simultaneityFactor: 1,
},
],
circuitLists: [
{
id: "list-1",
projectId: "project-1",
distributionBoardId: "board-1",
name: "UV 1 Stromkreisliste",
},
],
};
const snapshot = parseProjectStateSnapshot(previous);
assert.deepEqual(
snapshot.circuitSections.map(
({ key, category, groupNumber }) => ({
key,
category,
groupNumber,
})
),
[
{
key: "lighting",
category: "lighting",
groupNumber: 1,
},
{
key: "unassigned",
category: null,
groupNumber: null,
},
]
);
assert.deepEqual(snapshot.distributionBoardComponents, []);
assert.deepEqual(snapshot.circuitProtectionDevices, []);
assert.deepEqual(
snapshot.distributionBoardComponentProtectionDevices,
[]
);
});
it("validates the complete group, component and protection graph", () => {
const snapshot = parseProjectStateSnapshot(protectedSnapshot());
assert.equal(snapshot.circuitSections[0]?.groupNumber, 1);
assert.equal(
snapshot.distributionBoardComponents[0]?.sectionId,
"section-1"
);
assert.equal(
snapshot.circuitProtectionDevices[0]?.tripCharacteristic,
"B"
);
assert.equal(
snapshot.distributionBoardComponentProtectionDevices[0]
?.ratedResidualCurrentMa,
30
);
const duplicateBmk = protectedSnapshot();
duplicateBmk.distributionBoardComponents[0]!.equipmentIdentifier =
" -1f1.1 ";
assert.throws(
() => parseProjectStateSnapshot(duplicateBmk),
/duplicate equipment identifiers/
);
const invalidProtection = protectedSnapshot();
invalidProtection.circuitProtectionDevices[0] = {
...invalidProtection.circuitProtectionDevices[0]!,
ratedCurrentA: 35,
};
assert.throws(
() => parseProjectStateSnapshot(invalidProtection),
/protection-device configuration is invalid/
);
});
it("rejects duplicate ids and cross-project ownership", () => {
assert.throws(
() =>