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
project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart.
Current snapshot schema version 3 additionally stores the optional project-wide
Revit CSV configuration. Version 2 snapshots are upgraded with an empty external
configuration. Supported baseline version 1 snapshots are additionally upgraded
Current snapshot schema version 4 additionally stores the complete confirmed
external-model source, import batches, room mappings and objects. Version 3
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
supported versions contain circuit-group identity, distribution-board components and the
separate circuit/component protection records. Portable duplicate imports
@@ -261,7 +263,7 @@ project graph.
Project-wide Revit CSV configuration uses
`external-csv-configuration.update`. Its complete expected/target snapshot is
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
id and project link.
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
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
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
package adds version 4 atomically.
tables yet. Snapshot/restore and portable project transfer schema version 4
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
25 new revisions and retains only the newest 12 automatic snapshots per
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
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
Snapshot-Schema 3 enthält zusätzlich die projektweite Revit-CSV-Konfiguration,
die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
Snapshot-Schema 4 enthält zusätzlich den vollständigen externen Modellzustand
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
1:1-Schutzgerätedaten. Capture,
benannte und automatische Snapshots, Wiederherstellung, Undo/Redo und beide
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
dieser Baseline werden nicht eingelesen.
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,
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
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
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
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
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
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
nicht importierten Projekten. Der letzte bestätigte Batch wird aus der
Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert.
Snapshot/Transfer v4, Import-Command und bestätigte Importübernahme sind noch
nicht implementiert; bloße Vorschau erzeugt weiterhin keine dieser Zeilen.
Snapshot/Transfer v4 erfasst Originalbytes, Matrix und alle internen Links,
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
ändern die Konfiguration; der PUT plant Identität und nächsten
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
weiterhin keinen Import-Write außerhalb eines Commands.
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.
4. Raum-, Verteilungs-, Klassifizierungs- und ProjectDevice-Schritte im Wizard
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 { rooms } from "../schema/rooms.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 {
readProjectStateSnapshot,
@@ -163,6 +167,12 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
floors: byId(state.floors),
rooms: byId(state.rooms),
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,
state: ProjectStateSnapshot
) {
database
.delete(externalModelSources)
.where(eq(externalModelSources.projectId, state.project.id))
.run();
database
.delete(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, state.project.id))
@@ -263,6 +277,21 @@ function replaceProjectState(
circuitDeviceRows,
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]>(
@@ -20,6 +20,7 @@ import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
export interface PersistedProjectStateSnapshot {
currentRevision: number;
@@ -65,6 +66,8 @@ export function readProjectStateSnapshot(
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, projectId))
.get() ?? null;
const externalModel = new ExternalModelStateRepository(database)
.getByProject(projectId).state;
const listRows = database
.select()
.from(circuitLists)
@@ -248,6 +251,7 @@ export function readProjectStateSnapshot(
},
distributionBoards: boardRows,
externalCsvConfiguration,
externalModel,
circuitLists: listRows,
circuitSections: sectionRows,
distributionBoardComponents: componentRows,
@@ -21,6 +21,10 @@ import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.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 {
hashProjectStatePayload,
readProjectStateSnapshot,
@@ -157,6 +161,21 @@ function insertProjectState(
circuitDeviceRows,
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]>(
@@ -20,12 +20,22 @@ import {
} from "../services/project-voltage.service.js";
import { validateExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.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;
const previousProjectStateSnapshotSchemaVersion = 2 as const;
export const projectStateSnapshotSchemaVersion = 4 as const;
const previousProjectStateSnapshotSchemaVersion = 3 as const;
const legacyProjectStateSnapshotSchemaVersion = 2 as const;
const baselineProjectStateSnapshotSchemaVersion = 1 as const;
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 finiteNumberSchema = z.number().finite();
@@ -263,6 +273,147 @@ const externalCsvConfigurationSnapshotSchema = z
})
.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 = {
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
@@ -283,6 +434,7 @@ export const projectStateSnapshotSchema = z
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...projectStateSnapshotContents,
})
.strict();
@@ -292,6 +444,16 @@ const previousProjectStateSnapshotSchema = z
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
...projectStateSnapshotContents,
})
.strict();
const legacyProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
...projectStateSnapshotContents,
})
.strict();
@@ -335,7 +497,16 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
externalModel: emptyExternalModelState(),
};
}
if (schemaVersion === legacyProjectStateSnapshotSchemaVersion) {
const legacy = legacyProjectStateSnapshotSchema.parse(value);
return {
...legacy,
schemaVersion: projectStateSnapshotSchemaVersion,
externalCsvConfiguration: null,
externalModel: emptyExternalModelState(),
};
}
if (schemaVersion === baselineProjectStateSnapshotSchemaVersion) {
@@ -345,11 +516,21 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
schemaVersion: projectStateSnapshotSchemaVersion,
project: { ...baseline.project, isPublicBuilding: false },
externalCsvConfiguration: null,
externalModel: emptyExternalModelState(),
};
}
return value;
}
function emptyExternalModelState() {
return {
source: null,
importBatches: [],
roomMappings: [],
objects: [],
};
}
function normalizeSnapshotPhaseTypes(
snapshot: 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(
@@ -91,6 +91,13 @@ export function remapProjectState(
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({
...source,
project: { ...source.project, id: targetProjectId, name },
@@ -102,6 +109,68 @@ export function remapProjectState(
id: createId(),
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) => ({
...board,
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 { createDatabaseContext } from "../src/db/database-context.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 { externalModelObjects } from "../src/db/schema/external-model-objects.js";
import { externalModelSources } from "../src/db/schema/external-model-sources.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 { 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 { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
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: [],
externalCsvConfiguration: null,
externalModel: {
source: null,
importBatches: [],
roomMappings: [],
objects: [],
},
circuitLists: [],
circuitSections: [],
distributionBoardComponents: [],
@@ -149,6 +155,7 @@ describe("project state snapshot model", () => {
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
delete (baseline as Record<string, unknown>).externalModel;
const upgraded = parseProjectStateSnapshot(baseline);
@@ -161,11 +168,35 @@ describe("project state snapshot model", () => {
const previous = structuredClone(minimalSnapshot());
previous.schemaVersion = 2 as typeof previous.schemaVersion;
delete (previous as Record<string, unknown>).externalCsvConfiguration;
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("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", () => {