Include external models in project snapshots
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user