Include external models in project snapshots

This commit is contained in:
2026-08-02 17:25:25 +02:00
parent 76493b573e
commit e87a3289fa
10 changed files with 685 additions and 15 deletions
+9 -6
View File
@@ -250,9 +250,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 snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart. inverse command. Restore can therefore be undone and redone after a restart.
Current snapshot schema version 3 additionally stores the optional project-wide Current snapshot schema version 4 additionally stores the complete confirmed
Revit CSV configuration. Version 2 snapshots are upgraded with an empty external external-model source, import batches, room mappings and objects. Version 3
configuration. Supported baseline version 1 snapshots are additionally upgraded snapshots are upgraded with an empty external-model state; version 2 snapshots
are additionally upgraded with an empty external CSV configuration. Supported
baseline version 1 snapshots are additionally upgraded
with `isPublicBuilding = false`; pre-baseline snapshots remain unsupported. All with `isPublicBuilding = false`; pre-baseline snapshots remain unsupported. All
supported versions contain circuit-group identity, distribution-board components and the supported versions contain circuit-group identity, distribution-board components and the
separate circuit/component protection records. Portable duplicate imports separate circuit/component protection records. Portable duplicate imports
@@ -261,7 +263,7 @@ project graph.
Project-wide Revit CSV configuration uses Project-wide Revit CSV configuration uses
`external-csv-configuration.update`. Its complete expected/target snapshot is `external-csv-configuration.update`. Its complete expected/target snapshot is
persisted through the shared project-command transaction, so create, update, persisted through the shared project-command transaction, so create, update,
delete, Undo and Redo are revision-safe. Snapshot schema 3, restore and portable delete, Undo and Redo are revision-safe. Snapshot schema 4, restore and portable
project duplication preserve the configuration; duplication remaps its internal project duplication preserve the configuration; duplication remaps its internal
id and project link. id and project link.
The project page exposes the current Phase 14.1 Revit CSV modal. It edits the The project page exposes the current Phase 14.1 Revit CSV modal. It edits the
@@ -274,8 +276,9 @@ immutable import batches with original bytes plus the classified cell matrix,
normalized source-room mappings and external objects unique by normalized source-room mappings and external objects unique by
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate. `(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
The current repository is read-only; no runtime import command writes these The current repository is read-only; no runtime import command writes these
tables yet, and snapshot/transfer schema remains version 3 until the next work tables yet. Snapshot/restore and portable project transfer schema version 4
package adds version 4 atomically. capture the complete external state and remap every internal UUID and link while
preserving IFCGUIDs, source values, original bytes and the classified matrix.
The central revision boundary creates an automatic logical snapshot after each The central revision boundary creates an automatic logical snapshot after each
25 new revisions and retains only the newest 12 automatic snapshots per 25 new revisions and retains only the newest 12 automatic snapshots per
project. Named snapshots are never removed by this retention policy. project. Named snapshots are never removed by this retention policy.
+12 -7
View File
@@ -416,13 +416,15 @@ wieder her.
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`. ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
Snapshot-Schema 3 enthält zusätzlich die projektweite Revit-CSV-Konfiguration, Snapshot-Schema 4 enthält zusätzlich den vollständigen externen Modellzustand
die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor, aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
projektweite Revit-CSV-Konfiguration, die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten Gruppenkategorie und -nummer, Verteilerkomponenten sowie die getrennten
1:1-Schutzgerätedaten. Capture, 1:1-Schutzgerätedaten. Capture,
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
JSON-Importmodi verwenden denselben vollständigen Zustand. Die unterstützte JSON-Importmodi verwenden denselben vollständigen Zustand. Die unterstützte
Version 2 wird mit leerer externer Konfiguration hochgestuft. Die unterstützte Version 3 wird mit leerem externem Modellzustand und Version 2 zusätzlich mit
leerer externer Konfiguration hochgestuft. Die unterstützte
Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft; Formate vor Baseline-Version 1 wird zusätzlich mit `isPublicBuilding = false` hochgestuft; Formate vor
dieser Baseline werden nicht eingelesen. dieser Baseline werden nicht eingelesen.
Persistente Insert-, Update- und Delete-Commands versionieren Anlage, Persistente Insert-, Update- und Delete-Commands versionieren Anlage,
@@ -475,7 +477,7 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
Ein triggergeführtes Register erzwingt bereits eine normalisierte, Ein triggergeführtes Register erzwingt bereits eine normalisierte,
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden aktuell
Snapshot-Schema 3. Persistente Insert/Delete/Update-Commands für Snapshot-Schema 4. Persistente Insert/Delete/Update-Commands für
veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und
@@ -498,7 +500,7 @@ einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl
`external_csv_configurations` enthält höchstens eine versionierte Konfiguration `external_csv_configurations` enthält höchstens eine versionierte Konfiguration
je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 3, Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 4,
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
werden Konfigurations-UUID und Projektlink remapped. werden Konfigurations-UUID und Projektlink remapped.
@@ -512,8 +514,11 @@ und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den
vollständigen Zustand deterministisch und unterscheidet unbekannte von noch vollständigen Zustand deterministisch und unterscheidet unbekannte von noch
nicht importierten Projekten. Der letzte bestätigte Batch wird aus der nicht importierten Projekten. Der letzte bestätigte Batch wird aus der
Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert. Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert.
Snapshot/Transfer v4, Import-Command und bestätigte Importübernahme sind noch Snapshot/Transfer v4 erfasst Originalbytes, Matrix und alle internen Links,
nicht implementiert; bloße Vorschau erzeugt weiterhin keine dieser Zeilen. stellt sie in Fremdschlüsselreihenfolge wieder her und remappt beim
Projektduplikat jede interne UUID; IFCGUID und Quelltransport bleiben
unverändert. Import-Command und bestätigte Importübernahme sind noch nicht
implementiert; bloße Vorschau erzeugt weiterhin keine dieser Zeilen.
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder `GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
ändern die Konfiguration; der PUT plant Identität und nächsten ändern die Konfiguration; der PUT plant Identität und nächsten
Konfigurationsstand serverseitig und verwendet den typisierten Command. Konfigurationsstand serverseitig und verwendet den typisierten Command.
@@ -273,6 +273,10 @@ einen Commit aufgenommen.
exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt exaktes IFCGUID-, Raum- und Familie-und-Typ-Matching sind vorhanden. Es gibt
weiterhin keinen Import-Write außerhalb eines Commands. weiterhin keinen Import-Write außerhalb eines Commands.
2. Snapshot v4, Restore und Projektduplikat samt UUID-Remapping ergänzen. 2. Snapshot v4, Restore und Projektduplikat samt UUID-Remapping ergänzen.
**Erledigt:** Version 3 wird mit leerem externem Zustand hochgestuft;
Capture, Restore, Undo/Redo und beide Transfermodi erhalten Originalbytes,
Matrix und sämtliche internen Links. Duplikate remappen alle internen UUIDs,
lassen IFCGUID und Quelltransport jedoch unverändert.
3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen. 3. Erstimport-Command mit echten SQLite-Commit-/Rollback-Tests ergänzen.
4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard 4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard
ergänzen; der Import selbst erzeugt keine CircuitDeviceRow. ergänzen; der Import selbst erzeugt keine CircuitDeviceRow.
@@ -27,6 +27,10 @@ import { projectChangeSets } from "../schema/project-change-sets.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { import {
readProjectStateSnapshot, readProjectStateSnapshot,
@@ -163,6 +167,12 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
floors: byId(state.floors), floors: byId(state.floors),
rooms: byId(state.rooms), rooms: byId(state.rooms),
externalCsvConfiguration: state.externalCsvConfiguration, externalCsvConfiguration: state.externalCsvConfiguration,
externalModel: {
source: state.externalModel.source,
importBatches: byId(state.externalModel.importBatches),
roomMappings: byId(state.externalModel.roomMappings),
objects: byId(state.externalModel.objects),
},
}); });
} }
@@ -185,6 +195,10 @@ function replaceProjectState(
database: AppDatabase, database: AppDatabase,
state: ProjectStateSnapshot state: ProjectStateSnapshot
) { ) {
database
.delete(externalModelSources)
.where(eq(externalModelSources.projectId, state.project.id))
.run();
database database
.delete(externalCsvConfigurations) .delete(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, state.project.id)) .where(eq(externalCsvConfigurations.projectId, state.project.id))
@@ -263,6 +277,21 @@ function replaceProjectState(
circuitDeviceRows, circuitDeviceRows,
state.circuits.flatMap((circuit) => circuit.deviceRows) state.circuits.flatMap((circuit) => circuit.deviceRows)
); );
if (state.externalModel.source !== null) {
database.insert(externalModelSources).values(state.externalModel.source).run();
}
insertMany(
database,
externalImportBatches,
state.externalModel.importBatches.map(
({ originalContentBase64, ...batch }) => ({
...batch,
originalBytes: Buffer.from(originalContentBase64, "base64"),
})
)
);
insertMany(database, externalRoomMappings, state.externalModel.roomMappings);
insertMany(database, externalModelObjects, state.externalModel.objects);
} }
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>( function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
@@ -20,6 +20,7 @@ import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
export interface PersistedProjectStateSnapshot { export interface PersistedProjectStateSnapshot {
currentRevision: number; currentRevision: number;
@@ -65,6 +66,8 @@ export function readProjectStateSnapshot(
.from(externalCsvConfigurations) .from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, projectId)) .where(eq(externalCsvConfigurations.projectId, projectId))
.get() ?? null; .get() ?? null;
const externalModel = new ExternalModelStateRepository(database)
.getByProject(projectId).state;
const listRows = database const listRows = database
.select() .select()
.from(circuitLists) .from(circuitLists)
@@ -248,6 +251,7 @@ export function readProjectStateSnapshot(
}, },
distributionBoards: boardRows, distributionBoards: boardRows,
externalCsvConfiguration, externalCsvConfiguration,
externalModel,
circuitLists: listRows, circuitLists: listRows,
circuitSections: sectionRows, circuitSections: sectionRows,
distributionBoardComponents: componentRows, distributionBoardComponents: componentRows,
@@ -21,6 +21,10 @@ import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js"; import { rooms } from "../schema/rooms.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js"; import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { import {
hashProjectStatePayload, hashProjectStatePayload,
readProjectStateSnapshot, readProjectStateSnapshot,
@@ -157,6 +161,21 @@ function insertProjectState(
circuitDeviceRows, circuitDeviceRows,
state.circuits.flatMap((circuit) => circuit.deviceRows) state.circuits.flatMap((circuit) => circuit.deviceRows)
); );
if (state.externalModel.source !== null) {
database.insert(externalModelSources).values(state.externalModel.source).run();
}
insertMany(
database,
externalImportBatches,
state.externalModel.importBatches.map(
({ originalContentBase64, ...batch }) => ({
...batch,
originalBytes: Buffer.from(originalContentBase64, "base64"),
})
)
);
insertMany(database, externalRoomMappings, state.externalModel.roomMappings);
insertMany(database, externalModelObjects, state.externalModel.objects);
} }
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>( function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
@@ -20,12 +20,22 @@ import {
} from "../services/project-voltage.service.js"; } from "../services/project-voltage.service.js";
import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js"; import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js"; import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
import {
externalImportKinds,
externalModelSourceTypes,
externalObjectPresenceStatuses,
} from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 3 as const; export const projectStateSnapshotSchemaVersion = 4 as const;
const previousProjectStateSnapshotSchemaVersion = 2 as const; const previousProjectStateSnapshotSchemaVersion = 3 as const;
const legacyProjectStateSnapshotSchemaVersion = 2 as const;
const baselineProjectStateSnapshotSchemaVersion = 1 as const; const baselineProjectStateSnapshotSchemaVersion = 1 as const;
const idSchema = z.string().trim().min(1); const idSchema = z.string().trim().min(1);
const externalIfcGuidSchema = z.string().min(1).refine(
(value) => value === value.trim(),
"Snapshot IFCGUID must not contain outer whitespace."
);
const nullableStringSchema = z.string().nullable(); const nullableStringSchema = z.string().nullable();
const finiteNumberSchema = z.number().finite(); const finiteNumberSchema = z.number().finite();
@@ -263,6 +273,147 @@ const externalCsvConfigurationSnapshotSchema = z
}) })
.strict(); .strict();
const externalCsvDialectSchema = z.object({
encoding: z.literal("utf-8"),
hasBom: z.boolean(),
delimiter: z.enum([";", ",", "\t"]),
lineEnding: z.enum(["\r\n", "\n", "\r"]),
quoteCharacter: z.literal('"'),
quoteAllFields: z.boolean(),
hasTrailingLineEnding: z.boolean(),
}).strict();
const externalCsvDocumentSchema = z.object({
dialect: externalCsvDialectSchema,
headerRowIndex: z.number().int().nonnegative(),
rows: z.array(z.object({
index: z.number().int().nonnegative(),
cells: z.array(z.object({
value: z.string(),
wasQuoted: z.boolean(),
}).strict()),
classification: z.enum([
"metadata",
"header",
"passthrough",
"object",
"suspect-object",
]),
}).strict()).min(1),
}).strict().superRefine((document, context) => {
if (document.rows[document.headerRowIndex]?.classification !== "header") {
context.addIssue({
code: "custom",
message: "Snapshot external CSV document header is invalid.",
});
}
document.rows.forEach((row, index) => {
if (row.index !== index) {
context.addIssue({
code: "custom",
message: "Snapshot external CSV row indexes must be contiguous.",
});
}
});
});
const externalModelSourceSchema = z.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
sourceType: z.enum(externalModelSourceTypes),
}).strict();
const externalImportBatchSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
importKind: z.enum(externalImportKinds),
importedAtIso: z.string().refine((value) => Number.isFinite(Date.parse(value))),
fileName: z.string().trim().min(1),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
appliedProjectRevision: z.number().int().nonnegative(),
configurationSnapshot: z.custom<ExternalCsvConfiguration>(
(value) => validateExternalCsvConfiguration(value).success
),
originalContentBase64: z.string().min(1).regex(
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
),
document: externalCsvDocumentSchema,
}).strict();
const externalRoomMappingSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
normalizedSourceRoomKey: z.string().trim().min(1),
sourceFloorName: nullableStringSchema,
sourceRoomNumber: z.string(),
sourceRoomName: z.string(),
roomId: idSchema.nullable(),
defaultDistributionBoardId: idSchema.nullable(),
}).strict();
const externalPlanningFieldNames = [
"displayName",
"internalDeviceType",
"category",
"connectionKind",
"effectiveQuantity",
"powerPerUnitW",
"simultaneityFactor",
"cosPhi",
"costGroup",
"remark",
] as const;
const externalModelObjectSchema = z.object({
id: idSchema,
projectId: idSchema,
sourceId: idSchema,
ifcGuid: externalIfcGuidSchema,
lastSeenImportBatchId: idSchema,
lastAcceptedImportBatchId: idSchema,
acceptedSourceValues: z.object({
rowNumber: z.number().int().positive(),
roomNumber: z.string(),
roomName: z.string(),
familyAndType: z.string(),
selectionMarker: z.string(),
circuitIdentifier: z.string(),
power: z.string(),
quantity: z.string().nullable(),
additionalSourceValues: z.record(z.string(), z.string()),
}).strict(),
planningValues: z.object({
displayName: nullableStringSchema,
internalDeviceType: nullableStringSchema,
category: z.enum(circuitGroupCategories).nullable(),
connectionKind: nullableStringSchema,
effectiveQuantity: z.number().int().positive(),
powerPerUnitW: finiteNumberSchema.nonnegative().nullable(),
simultaneityFactor: finiteNumberSchema.min(0).max(1),
cosPhi: finiteNumberSchema.positive().max(1).nullable(),
costGroup: nullableStringSchema,
remark: nullableStringSchema,
}).strict(),
overriddenFields: z.array(z.enum(externalPlanningFieldNames)).refine(
(values) => new Set(values).size === values.length
),
externalRoomMappingId: idSchema.nullable(),
distributionBoardId: idSchema.nullable(),
linkedProjectDeviceId: idSchema.nullable(),
circuitDeviceRowId: idSchema.nullable(),
presenceStatus: z.enum(externalObjectPresenceStatuses),
}).strict();
const externalModelStateSchema = z.object({
source: externalModelSourceSchema.nullable(),
importBatches: z.array(externalImportBatchSchema),
roomMappings: z.array(externalRoomMappingSchema),
objects: z.array(externalModelObjectSchema),
}).strict();
const projectStateSnapshotContents = { const projectStateSnapshotContents = {
circuitLists: z.array(circuitListSchema), circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema), circuitSections: z.array(circuitSectionSchema),
@@ -283,6 +434,7 @@ export const projectStateSnapshotSchema = z
project: projectSchema, project: projectSchema,
distributionBoards: z.array(distributionBoardSchema), distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...projectStateSnapshotContents, ...projectStateSnapshotContents,
}) })
.strict(); .strict();
@@ -292,6 +444,16 @@ const previousProjectStateSnapshotSchema = z
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
project: projectSchema, project: projectSchema,
distributionBoards: z.array(distributionBoardSchema), distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
...projectStateSnapshotContents,
})
.strict();
const legacyProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
...projectStateSnapshotContents, ...projectStateSnapshotContents,
}) })
.strict(); .strict();
@@ -335,7 +497,16 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
return { return {
...previous, ...previous,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: projectStateSnapshotSchemaVersion,
externalModel: emptyExternalModelState(),
};
}
if (schemaVersion === legacyProjectStateSnapshotSchemaVersion) {
const legacy = legacyProjectStateSnapshotSchema.parse(value);
return {
...legacy,
schemaVersion: projectStateSnapshotSchemaVersion,
externalCsvConfiguration: null, externalCsvConfiguration: null,
externalModel: emptyExternalModelState(),
}; };
} }
if (schemaVersion === baselineProjectStateSnapshotSchemaVersion) { if (schemaVersion === baselineProjectStateSnapshotSchemaVersion) {
@@ -345,11 +516,21 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: projectStateSnapshotSchemaVersion,
project: { ...baseline.project, isPublicBuilding: false }, project: { ...baseline.project, isPublicBuilding: false },
externalCsvConfiguration: null, externalCsvConfiguration: null,
externalModel: emptyExternalModelState(),
}; };
} }
return value; return value;
} }
function emptyExternalModelState() {
return {
source: null,
importBatches: [],
roomMappings: [],
objects: [],
};
}
function normalizeSnapshotPhaseTypes( function normalizeSnapshotPhaseTypes(
snapshot: ProjectStateSnapshot snapshot: ProjectStateSnapshot
): ProjectStateSnapshot { ): ProjectStateSnapshot {
@@ -666,6 +847,94 @@ function assertProjectStateSnapshotRelations(
); );
} }
} }
assertExternalModelRelations(snapshot, {
boardIds,
deviceRowIds,
projectDeviceIds,
projectId,
roomIds,
});
}
function assertExternalModelRelations(
snapshot: ProjectStateSnapshot,
references: {
boardIds: ReadonlySet<string>;
deviceRowIds: ReadonlySet<string>;
projectDeviceIds: ReadonlySet<string>;
projectId: string;
roomIds: ReadonlySet<string>;
}
) {
const { source, importBatches, roomMappings, objects } = snapshot.externalModel;
if (source === null) {
if (importBatches.length || roomMappings.length || objects.length) {
throw new Error("Snapshot external model entries require a source.");
}
return;
}
assertProjectOwnership(source.projectId, references.projectId, "external model source");
const batchIds = uniqueIds(importBatches, "external import batch");
const mappingIds = uniqueIds(roomMappings, "external room mapping");
uniqueIds(objects, "external model object");
for (const batch of importBatches) {
assertProjectOwnership(batch.projectId, references.projectId, "external import batch");
if (batch.sourceId !== source.id) {
throw new Error("Snapshot external import batch belongs to a different source.");
}
}
const roomKeys = new Set<string>();
for (const mapping of roomMappings) {
assertProjectOwnership(mapping.projectId, references.projectId, "external room mapping");
if (mapping.sourceId !== source.id) {
throw new Error("Snapshot external room mapping belongs to a different source.");
}
assertUniqueKey(
roomKeys,
mapping.normalizedSourceRoomKey,
"Snapshot contains duplicate external room keys."
);
if (mapping.roomId !== null) {
assertReference(references.roomIds, mapping.roomId, "external room mapping room");
}
if (mapping.defaultDistributionBoardId !== null) {
assertReference(
references.boardIds,
mapping.defaultDistributionBoardId,
"external room mapping distribution board"
);
}
}
const ifcGuids = new Set<string>();
for (const object of objects) {
assertProjectOwnership(object.projectId, references.projectId, "external model object");
if (object.sourceId !== source.id) {
throw new Error("Snapshot external model object belongs to a different source.");
}
assertUniqueKey(
ifcGuids,
object.ifcGuid,
"Snapshot contains duplicate external IFCGUIDs."
);
assertReference(batchIds, object.lastSeenImportBatchId, "external object last-seen batch");
assertReference(batchIds, object.lastAcceptedImportBatchId, "external object accepted batch");
if (object.externalRoomMappingId !== null) {
assertReference(mappingIds, object.externalRoomMappingId, "external object room mapping");
}
if (object.distributionBoardId !== null) {
assertReference(references.boardIds, object.distributionBoardId, "external object distribution board");
}
if (object.linkedProjectDeviceId !== null) {
assertReference(references.projectDeviceIds, object.linkedProjectDeviceId, "external object project device");
}
if (object.circuitDeviceRowId !== null) {
assertReference(references.deviceRowIds, object.circuitDeviceRowId, "external object device row");
}
}
} }
function assertComponentPlacement( function assertComponentPlacement(
@@ -91,6 +91,13 @@ export function remapProjectState(
circuit.deviceRows.map((row) => [row.id, createId()] as const) circuit.deviceRows.map((row) => [row.id, createId()] as const)
) )
); );
const externalSourceId = source.externalModel.source ? createId() : null;
const externalBatchIds = idMap(source.externalModel.importBatches, createId);
const externalRoomMappingIds = idMap(
source.externalModel.roomMappings,
createId
);
const externalObjectIds = idMap(source.externalModel.objects, createId);
return parseProjectStateSnapshot({ return parseProjectStateSnapshot({
...source, ...source,
project: { ...source.project, id: targetProjectId, name }, project: { ...source.project, id: targetProjectId, name },
@@ -102,6 +109,68 @@ export function remapProjectState(
id: createId(), id: createId(),
projectId: targetProjectId, projectId: targetProjectId,
}, },
externalModel: {
source: source.externalModel.source === null
? null
: {
...source.externalModel.source,
id: externalSourceId!,
projectId: targetProjectId,
},
importBatches: source.externalModel.importBatches.map((batch) => ({
...batch,
id: requiredId(externalBatchIds, batch.id),
projectId: targetProjectId,
sourceId: externalSourceId!,
})),
roomMappings: source.externalModel.roomMappings.map((mapping) => ({
...mapping,
id: requiredId(externalRoomMappingIds, mapping.id),
projectId: targetProjectId,
sourceId: externalSourceId!,
roomId:
mapping.roomId === null
? null
: requiredId(roomIds, mapping.roomId),
defaultDistributionBoardId:
mapping.defaultDistributionBoardId === null
? null
: requiredId(boardIds, mapping.defaultDistributionBoardId),
})),
objects: source.externalModel.objects.map((object) => ({
...object,
id: requiredId(externalObjectIds, object.id),
projectId: targetProjectId,
sourceId: externalSourceId!,
lastSeenImportBatchId: requiredId(
externalBatchIds,
object.lastSeenImportBatchId
),
lastAcceptedImportBatchId: requiredId(
externalBatchIds,
object.lastAcceptedImportBatchId
),
externalRoomMappingId:
object.externalRoomMappingId === null
? null
: requiredId(
externalRoomMappingIds,
object.externalRoomMappingId
),
distributionBoardId:
object.distributionBoardId === null
? null
: requiredId(boardIds, object.distributionBoardId),
linkedProjectDeviceId:
object.linkedProjectDeviceId === null
? null
: requiredId(deviceIds, object.linkedProjectDeviceId),
circuitDeviceRowId:
object.circuitDeviceRowId === null
? null
: requiredId(rowIds, object.circuitDeviceRowId),
})),
},
distributionBoards: source.distributionBoards.map((board) => ({ distributionBoards: source.distributionBoards.map((board) => ({
...board, ...board,
id: requiredId(boardIds, board.id), id: requiredId(boardIds, board.id),
+237
View File
@@ -4,11 +4,23 @@ import { describe, it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js"; import { createDatabaseContext } from "../src/db/database-context.js";
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js"; import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.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 { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { externalImportBatches } from "../src/db/schema/external-import-batches.js"; import { externalImportBatches } from "../src/db/schema/external-import-batches.js";
import { externalModelObjects } from "../src/db/schema/external-model-objects.js"; import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
import { externalModelSources } from "../src/db/schema/external-model-sources.js"; import { externalModelSources } from "../src/db/schema/external-model-sources.js";
import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js"; import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js";
import { floors } from "../src/db/schema/floors.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projects } from "../src/db/schema/projects.js"; import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js";
import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js"; import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js"; import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
import { import {
@@ -189,3 +201,228 @@ describe("external model state repository", () => {
} }
}); });
}); });
describe("external model snapshot and transfer", () => {
it("captures, restores and undoes the complete external state", () => {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
try {
insertLinkedExternalFixture(context.db);
const populated = readProjectStateSnapshot(context.db, "project-1");
assert.ok(populated);
assert.equal(populated.state.schemaVersion, 4);
assert.equal(populated.state.externalModel.objects[0].circuitDeviceRowId, "row-1");
context.db.delete(externalModelSources).run();
const empty = readProjectStateSnapshot(context.db, "project-1");
assert.ok(empty);
assert.equal(empty.state.externalModel.source, null);
const repository = new ProjectStateRestoreCommandRepository(context.db);
const restored = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectStateRestoreCommand(
empty.payloadSha256,
populated.state
),
});
assert.equal(
new ExternalModelStateRepository(context.db).getByProject("project-1")
.state.objects[0].linkedProjectDeviceId,
"device-1"
);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: restored.revision.changeSetId,
command: restored.inverse,
});
assert.equal(
new ExternalModelStateRepository(context.db).getByProject("project-1")
.state.source,
null
);
} finally {
context.close();
}
});
it("remaps every internal external-model link for a project duplicate", () => {
const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
try {
insertLinkedExternalFixture(context.db);
const transferRepository = new ProjectTransferRepository(context.db);
const transfer = transferRepository.exportProject("project-1");
assert.ok(transfer);
const duplicate = transferRepository.importDuplicate(transfer);
const copied = new ExternalModelStateRepository(context.db)
.getByProject(duplicate.projectId).state;
assert.notEqual(copied.source?.id, "source-1");
assert.notEqual(copied.importBatches[0].id, "batch-1");
assert.notEqual(copied.roomMappings[0].id, "mapping-1");
assert.notEqual(copied.objects[0].id, "object-1");
assert.equal(copied.objects[0].ifcGuid, firstIfcGuid);
assert.equal(copied.objects[0].lastSeenImportBatchId, copied.importBatches[0].id);
assert.equal(copied.objects[0].externalRoomMappingId, copied.roomMappings[0].id);
assert.notEqual(copied.objects[0].distributionBoardId, "board-1");
assert.notEqual(copied.objects[0].linkedProjectDeviceId, "device-1");
assert.notEqual(copied.objects[0].circuitDeviceRowId, "row-1");
assert.equal(
copied.importBatches[0].originalContentBase64,
Buffer.from(utf8Bytes(quotedRevitCsv, true)).toString("base64")
);
} finally {
context.close();
}
});
});
function insertLinkedExternalFixture(database: ReturnType<typeof createDatabaseContext>["db"]) {
database.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
database.insert(floors).values({
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 10,
}).run();
database.insert(rooms).values({
id: "room-1",
projectId: "project-1",
floorId: "floor-1",
roomNumber: "01/101",
roomName: "Technik",
}).run();
database.insert(projectDevices).values({
id: "device-1",
projectId: "project-1",
name: "socket",
displayName: "Steckdose",
phaseType: "single_phase",
connectionKind: "socket",
costGroup: null,
category: "single_phase",
quantity: 1,
powerPerUnit: 0.12,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
voltageV: 230,
}).run();
database.insert(distributionBoards).values({
id: "board-1",
projectId: "project-1",
name: "UV 1",
floorId: "floor-1",
supplyType: "AV",
simultaneityFactor: 1,
}).run();
database.insert(circuitLists).values({
id: "list-1",
projectId: "project-1",
distributionBoardId: "board-1",
name: "UV 1 Stromkreisliste",
}).run();
database.insert(circuitSections).values({
id: "section-1",
circuitListId: "list-1",
key: "single_phase",
displayName: "1-phasig 1",
prefix: "-2F1.",
sortOrder: 10,
category: "single_phase",
groupNumber: 1,
}).run();
database.insert(circuits).values({
id: "circuit-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-2F1.1",
displayName: "Steckdosen",
sortOrder: 10,
voltage: 230,
isReserve: 0,
}).run();
database.insert(circuitDeviceRows).values({
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "device-1",
sortOrder: 10,
name: "socket",
displayName: "Steckdose",
phaseType: "single_phase",
connectionKind: "socket",
costGroup: null,
category: "single_phase",
level: null,
roomId: "room-1",
roomNumberSnapshot: "01/101",
roomNameSnapshot: "Technik",
quantity: 2,
powerPerUnit: 0.12,
simultaneityFactor: 1,
cosPhi: null,
remark: null,
overriddenFields: null,
}).run();
database.insert(externalModelSources).values({
id: "source-1",
projectId: "project-1",
name: "Revit-Gesamtmodell",
sourceType: "revit_csv",
}).run();
const configuration = configuredCsv();
const bytes = utf8Bytes(quotedRevitCsv, true);
database.insert(externalImportBatches).values({
id: "batch-1",
projectId: "project-1",
sourceId: "source-1",
importKind: "initial",
importedAtIso: "2026-08-02T15:30:00.000Z",
fileName: "revit.csv",
sha256: "a".repeat(64),
appliedProjectRevision: 0,
configurationSnapshot: configuration,
originalBytes: Buffer.from(bytes),
document: parseExternalCsv(bytes, configuration),
}).run();
database.insert(externalRoomMappings).values({
id: "mapping-1",
projectId: "project-1",
sourceId: "source-1",
normalizedSourceRoomKey: "number:01/101",
sourceFloorName: "EG",
sourceRoomNumber: "01/101",
sourceRoomName: "Technik",
roomId: "room-1",
defaultDistributionBoardId: "board-1",
}).run();
const preview = createExternalCsvPreview({
fileName: "revit.csv",
bytes,
configuration,
});
const projected = projectInitialExternalObject(preview.objects[0], configuration);
database.insert(externalModelObjects).values({
id: "object-1",
projectId: "project-1",
sourceId: "source-1",
ifcGuid: projected.ifcGuid,
lastSeenImportBatchId: "batch-1",
lastAcceptedImportBatchId: "batch-1",
acceptedSourceValues: projected.sourceValues,
planningValues: projected.planningValues,
overriddenFields: [],
externalRoomMappingId: "mapping-1",
distributionBoardId: "board-1",
linkedProjectDeviceId: "device-1",
circuitDeviceRowId: "row-1",
presenceStatus: "present",
}).run();
}
+31
View File
@@ -32,6 +32,12 @@ function minimalSnapshot() {
}, },
distributionBoards: [], distributionBoards: [],
externalCsvConfiguration: null, externalCsvConfiguration: null,
externalModel: {
source: null,
importBatches: [],
roomMappings: [],
objects: [],
},
circuitLists: [], circuitLists: [],
circuitSections: [], circuitSections: [],
distributionBoardComponents: [], distributionBoardComponents: [],
@@ -149,6 +155,7 @@ describe("project state snapshot model", () => {
baseline.schemaVersion = 1 as typeof baseline.schemaVersion; baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
delete (baseline.project as Record<string, unknown>).isPublicBuilding; delete (baseline.project as Record<string, unknown>).isPublicBuilding;
delete (baseline as Record<string, unknown>).externalCsvConfiguration; delete (baseline as Record<string, unknown>).externalCsvConfiguration;
delete (baseline as Record<string, unknown>).externalModel;
const upgraded = parseProjectStateSnapshot(baseline); const upgraded = parseProjectStateSnapshot(baseline);
@@ -161,11 +168,35 @@ describe("project state snapshot model", () => {
const previous = structuredClone(minimalSnapshot()); const previous = structuredClone(minimalSnapshot());
previous.schemaVersion = 2 as typeof previous.schemaVersion; previous.schemaVersion = 2 as typeof previous.schemaVersion;
delete (previous as Record<string, unknown>).externalCsvConfiguration; delete (previous as Record<string, unknown>).externalCsvConfiguration;
delete (previous as Record<string, unknown>).externalModel;
const upgraded = parseProjectStateSnapshot(previous); const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion); assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.externalCsvConfiguration, null); assert.equal(upgraded.externalCsvConfiguration, null);
assert.deepEqual(upgraded.externalModel, {
source: null,
importBatches: [],
roomMappings: [],
objects: [],
});
});
it("upgrades snapshot version 3 with an empty external model", () => {
const previous = structuredClone(minimalSnapshot());
previous.schemaVersion = 3 as typeof previous.schemaVersion;
delete (previous as Record<string, unknown>).externalModel;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.externalCsvConfiguration, null);
assert.deepEqual(upgraded.externalModel, {
source: null,
importBatches: [],
roomMappings: [],
objects: [],
});
}); });
it("rejects malformed baseline and unknown snapshot shapes", () => { it("rejects malformed baseline and unknown snapshot shapes", () => {