Add atomic Revit initial import
This commit is contained in:
@@ -275,10 +275,20 @@ Phase 14.2 migration `0003` provides one implicit Revit CSV source per project,
|
|||||||
immutable import batches with original bytes plus the classified cell matrix,
|
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 general external-model repository is read-only; runtime writes belong only
|
||||||
tables yet. Snapshot/restore and portable project transfer schema version 4
|
to typed command adapters. Snapshot/restore and portable project transfer schema version 4
|
||||||
capture the complete external state and remap every internal UUID and link while
|
capture the complete external state and remap every internal UUID and link while
|
||||||
preserving IFCGUIDs, source values, original bytes and the classified matrix.
|
preserving IFCGUIDs, source values, original bytes and the classified matrix.
|
||||||
|
Import batches additionally store the monotonic project CSV configuration
|
||||||
|
version introduced by additive migration `0004`; it is distinct from the CSV
|
||||||
|
configuration JSON schema version.
|
||||||
|
Confirmed initial state is written only through
|
||||||
|
`external-import.apply-initial`. The command rechecks configuration version,
|
||||||
|
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
|
||||||
|
planning overrides and internal project links in the shared transaction. It
|
||||||
|
never creates or links a CircuitDeviceRow. Its exact inverse removes the whole
|
||||||
|
unchanged external state, and Redo restores the same UUIDs and bytes. No public
|
||||||
|
apply endpoint or staging wizard invokes this command yet.
|
||||||
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.
|
||||||
|
|||||||
@@ -508,7 +508,9 @@ Migration `0003` ergänzt die relationale Grundlage für genau eine implizite
|
|||||||
`revit_csv`-Quelle je Projekt, geordnete Importbatches, normalisierte
|
`revit_csv`-Quelle je Projekt, geordnete Importbatches, normalisierte
|
||||||
Quellraummappings und über `(sourceId, ifcGuid)` eindeutige externe Objekte.
|
Quellraummappings und über `(sourceId, ifcGuid)` eindeutige externe Objekte.
|
||||||
Batches halten Konfigurationssnapshot, Originalbytes und die klassifizierte
|
Batches halten Konfigurationssnapshot, Originalbytes und die klassifizierte
|
||||||
Zellmatrix zusammen. Quellwerte, lokale Planungswerte und Override-Metadaten
|
Zellmatrix zusammen. Migration `0004` ergänzt die monotone projektweite
|
||||||
|
Konfigurationsversion am Batch, getrennt von der Schema-Version des
|
||||||
|
Konfigurationsformats. Quellwerte, lokale Planungswerte und Override-Metadaten
|
||||||
der Objekte sind getrennt; optionale Links auf Raum, Verteilung, ProjectDevice
|
der Objekte sind getrennt; optionale Links auf Raum, Verteilung, ProjectDevice
|
||||||
und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den
|
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
|
||||||
@@ -517,8 +519,17 @@ Batchreihenfolge abgeleitet statt redundant an der Quelle gespeichert.
|
|||||||
Snapshot/Transfer v4 erfasst Originalbytes, Matrix und alle internen Links,
|
Snapshot/Transfer v4 erfasst Originalbytes, Matrix und alle internen Links,
|
||||||
stellt sie in Fremdschlüsselreihenfolge wieder her und remappt beim
|
stellt sie in Fremdschlüsselreihenfolge wieder her und remappt beim
|
||||||
Projektduplikat jede interne UUID; IFCGUID und Quelltransport bleiben
|
Projektduplikat jede interne UUID; IFCGUID und Quelltransport bleiben
|
||||||
unverändert. Import-Command und bestätigte Importübernahme sind noch nicht
|
unverändert. `external-import.apply-initial` ist die atomare Schreibgrenze für
|
||||||
implementiert; bloße Vorschau erzeugt weiterhin keine dieser Zeilen.
|
einen bestätigten Erstimport. Vor dem Insert prüft sie den aktuellen
|
||||||
|
Konfigurationsstand, SHA-256 und Base64-Kanonizität, parst die Originalbytes
|
||||||
|
erneut, vergleicht Matrix, vollständige IFCGUID-Menge und Quellwerte und
|
||||||
|
validiert alle Raum-, Verteiler- und ProjectDevice-Links gegen das Projekt.
|
||||||
|
Nicht explizit als Override markierte Planungswerte müssen dem regelbasierten
|
||||||
|
Vorschlag entsprechen. Der Command erzeugt und verknüpft ausdrücklich keine
|
||||||
|
CircuitDeviceRow; Undo entfernt den vollständigen unveränderten Importzustand,
|
||||||
|
Redo verwendet dieselben UUIDs und Bytes. Ein aufrufender Import-Endpunkt und
|
||||||
|
der mehrstufige Wizard 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.
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ ohne eine zweite veränderbare Historientabelle einzuführen.
|
|||||||
|
|
||||||
- stabile UUID, `projectId`, `sourceId`, Importart und Zeitstempel;
|
- stabile UUID, `projectId`, `sourceId`, Importart und Zeitstempel;
|
||||||
- Dateiname, SHA-256 und zugehörige Projektrevision;
|
- Dateiname, SHA-256 und zugehörige Projektrevision;
|
||||||
- verwendeter Konfigurationssnapshot;
|
- verwendete monotone Konfigurationsversion und vollständiger
|
||||||
|
Konfigurationssnapshot;
|
||||||
- Originaldatei als Bytes;
|
- Originaldatei als Bytes;
|
||||||
- verlustarme logische Zellmatrix mit Zeilenklassen und Dialektmetadaten.
|
- verlustarme logische Zellmatrix mit Zeilenklassen und Dialektmetadaten.
|
||||||
|
|
||||||
@@ -278,6 +279,14 @@ einen Commit aufgenommen.
|
|||||||
Matrix und sämtliche internen Links. Duplikate remappen alle internen UUIDs,
|
Matrix und sämtliche internen Links. Duplikate remappen alle internen UUIDs,
|
||||||
lassen IFCGUID und Quelltransport jedoch unverändert.
|
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.
|
||||||
|
**Erledigt:** `external-import.apply-initial` wechselt ausschließlich
|
||||||
|
zwischen leerem und vollständig bestätigtem externem Zustand. Er prüft
|
||||||
|
Konfigurationsversion, Original-Hash, erneut geparste Matrix, vollständige
|
||||||
|
IFCGUID-Menge, Quellwerte, explizite Overrides und projektinterne Links.
|
||||||
|
Der Command verbietet CircuitDeviceRow-Links, ist ein atomarer Undo-Schritt
|
||||||
|
und verwendet für Redo dieselben UUIDs und Bytes. Migration `0004` ergänzt
|
||||||
|
die zuvor noch fehlende monotone Konfigurationsversion am Batch; `0003`
|
||||||
|
bleibt unverändert.
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `external_import_batches` ADD `configuration_version` integer NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
|||||||
"when": 1785683710155,
|
"when": 1785683710155,
|
||||||
"tag": "0003_outstanding_maddog",
|
"tag": "0003_outstanding_maddog",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785684457208,
|
||||||
|
"tag": "0004_illegal_the_stranger",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertExternalInitialImportProjectCommand,
|
||||||
|
createExternalInitialImportProjectCommand,
|
||||||
|
isEmptyExternalModelState,
|
||||||
|
} from "../../domain/models/external-initial-import-project-command.model.js";
|
||||||
|
import type { ExternalInitialImportProjectCommandStore } from "../../domain/ports/external-initial-import-project-command.store.js";
|
||||||
|
import { createExternalCsvPreview } from "../../external-model/application/external-csv-preview.js";
|
||||||
|
import { parseExternalCsv } from "../../external-model/csv/external-csv-transport.js";
|
||||||
|
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
import {
|
||||||
|
createExternalRoomKey,
|
||||||
|
indexExternalObjectsByIfcGuid,
|
||||||
|
projectInitialExternalObject,
|
||||||
|
} from "../../external-model/domain/external-model-matching.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.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 { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { rooms } from "../schema/rooms.js";
|
||||||
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { ExternalModelStateRepository } from "./external-model-state.repository.js";
|
||||||
|
|
||||||
|
export class ExternalInitialImportProjectCommandRepository
|
||||||
|
implements ExternalInitialImportProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0]) {
|
||||||
|
assertExternalInitialImportProjectCommand(input.command);
|
||||||
|
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||||
|
const currentResult = new ExternalModelStateRepository(tx)
|
||||||
|
.getByProject(input.projectId);
|
||||||
|
if (!currentResult.projectExists) throw new Error("Project not found.");
|
||||||
|
const expected = input.command.payload.expected;
|
||||||
|
if (!sameState(currentResult.state, expected)) {
|
||||||
|
throw new Error("External model state changed before initial import.");
|
||||||
|
}
|
||||||
|
const target = input.command.payload.target;
|
||||||
|
if (!isEmptyExternalModelState(target)) {
|
||||||
|
validatePopulatedTarget(tx, input, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceExternalState(tx, input.projectId, target);
|
||||||
|
const persisted = new ExternalModelStateRepository(tx)
|
||||||
|
.getByProject(input.projectId).state;
|
||||||
|
if (!sameState(persisted, target)) {
|
||||||
|
throw new Error("Persisted external initial import differs from its target.");
|
||||||
|
}
|
||||||
|
return createExternalInitialImportProjectCommand(target, currentResult.state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePopulatedTarget(
|
||||||
|
database: AppDatabase,
|
||||||
|
input: Parameters<ExternalInitialImportProjectCommandStore["execute"]>[0],
|
||||||
|
target: ExternalModelStateSnapshot
|
||||||
|
) {
|
||||||
|
const source = target.source!;
|
||||||
|
const batch = target.importBatches[0];
|
||||||
|
if (source.projectId !== input.projectId) {
|
||||||
|
throw new Error("External initial import belongs to a different project.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.source === "user" &&
|
||||||
|
batch.appliedProjectRevision !== input.expectedRevision + 1
|
||||||
|
) {
|
||||||
|
throw new Error("External initial import revision is invalid.");
|
||||||
|
}
|
||||||
|
const configuration = database
|
||||||
|
.select()
|
||||||
|
.from(externalCsvConfigurations)
|
||||||
|
.where(eq(externalCsvConfigurations.projectId, input.projectId))
|
||||||
|
.get();
|
||||||
|
if (
|
||||||
|
!configuration ||
|
||||||
|
configuration.configurationVersion !== batch.configurationVersion ||
|
||||||
|
canonicalJson(configuration.configuration) !==
|
||||||
|
canonicalJson(batch.configurationSnapshot)
|
||||||
|
) {
|
||||||
|
throw new Error("External initial import configuration changed before apply.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = decodeCanonicalBase64(batch.originalContentBase64);
|
||||||
|
const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
|
||||||
|
if (sha256 !== batch.sha256) {
|
||||||
|
throw new Error("External initial import checksum is invalid.");
|
||||||
|
}
|
||||||
|
const parsedDocument = parseExternalCsv(bytes, batch.configurationSnapshot);
|
||||||
|
if (canonicalJson(parsedDocument) !== canonicalJson(batch.document)) {
|
||||||
|
throw new Error("External initial import document differs from the original bytes.");
|
||||||
|
}
|
||||||
|
const preview = createExternalCsvPreview({
|
||||||
|
fileName: batch.fileName,
|
||||||
|
bytes,
|
||||||
|
configuration: batch.configurationSnapshot,
|
||||||
|
});
|
||||||
|
const targetByIfcGuid = indexExternalObjectsByIfcGuid(target.objects);
|
||||||
|
if (targetByIfcGuid.size !== preview.objects.length) {
|
||||||
|
throw new Error("External initial import must contain every CSV object exactly once.");
|
||||||
|
}
|
||||||
|
const mappingsById = new Map(
|
||||||
|
target.roomMappings.map((mapping) => [mapping.id, mapping])
|
||||||
|
);
|
||||||
|
const expectedRoomKeys = new Set<string>();
|
||||||
|
for (const candidate of preview.objects) {
|
||||||
|
const object = targetByIfcGuid.get(candidate.ifcGuid);
|
||||||
|
if (!object) {
|
||||||
|
throw new Error(`External initial import is missing IFCGUID ${candidate.ifcGuid}.`);
|
||||||
|
}
|
||||||
|
const projected = projectInitialExternalObject(
|
||||||
|
candidate,
|
||||||
|
batch.configurationSnapshot
|
||||||
|
);
|
||||||
|
if (canonicalJson(object.acceptedSourceValues) !== canonicalJson(projected.sourceValues)) {
|
||||||
|
throw new Error("External initial import source values differ from the CSV.");
|
||||||
|
}
|
||||||
|
for (const field of Object.keys(projected.planningValues) as Array<keyof typeof projected.planningValues>) {
|
||||||
|
if (
|
||||||
|
!object.overriddenFields.includes(field) &&
|
||||||
|
canonicalJson(object.planningValues[field]) !==
|
||||||
|
canonicalJson(projected.planningValues[field])
|
||||||
|
) {
|
||||||
|
throw new Error(`External initial planning field ${field} changed without an override.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName);
|
||||||
|
if (roomKey === null) {
|
||||||
|
if (object.externalRoomMappingId !== null) {
|
||||||
|
throw new Error("External object without a source room must not use a room mapping.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expectedRoomKeys.add(roomKey);
|
||||||
|
const mapping = object.externalRoomMappingId === null
|
||||||
|
? null
|
||||||
|
: mappingsById.get(object.externalRoomMappingId);
|
||||||
|
if (!mapping || mapping.normalizedSourceRoomKey !== roomKey) {
|
||||||
|
throw new Error("External object source room mapping is incomplete.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
target.roomMappings.length !== expectedRoomKeys.size ||
|
||||||
|
target.roomMappings.some(
|
||||||
|
(mapping) => !expectedRoomKeys.has(mapping.normalizedSourceRoomKey)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("External initial import room mappings do not match CSV rooms.");
|
||||||
|
}
|
||||||
|
validateInternalLinks(database, input.projectId, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInternalLinks(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
target: ExternalModelStateSnapshot
|
||||||
|
) {
|
||||||
|
const roomIds = new Set(
|
||||||
|
database.select({ id: rooms.id }).from(rooms)
|
||||||
|
.where(eq(rooms.projectId, projectId)).all().map((row) => row.id)
|
||||||
|
);
|
||||||
|
const boardIds = new Set(
|
||||||
|
database.select({ id: distributionBoards.id }).from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.projectId, projectId)).all().map((row) => row.id)
|
||||||
|
);
|
||||||
|
const deviceIds = new Set(
|
||||||
|
database.select({ id: projectDevices.id }).from(projectDevices)
|
||||||
|
.where(eq(projectDevices.projectId, projectId)).all().map((row) => row.id)
|
||||||
|
);
|
||||||
|
for (const mapping of target.roomMappings) {
|
||||||
|
if (mapping.roomId !== null && !roomIds.has(mapping.roomId)) {
|
||||||
|
throw new Error("External room mapping references another project.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
mapping.defaultDistributionBoardId !== null &&
|
||||||
|
!boardIds.has(mapping.defaultDistributionBoardId)
|
||||||
|
) {
|
||||||
|
throw new Error("External room mapping distribution board belongs to another project.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const object of target.objects) {
|
||||||
|
if (object.distributionBoardId !== null && !boardIds.has(object.distributionBoardId)) {
|
||||||
|
throw new Error("External object distribution board belongs to another project.");
|
||||||
|
}
|
||||||
|
if (object.linkedProjectDeviceId !== null && !deviceIds.has(object.linkedProjectDeviceId)) {
|
||||||
|
throw new Error("External object project device belongs to another project.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceExternalState(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
target: ExternalModelStateSnapshot
|
||||||
|
) {
|
||||||
|
database.delete(externalModelSources)
|
||||||
|
.where(eq(externalModelSources.projectId, projectId)).run();
|
||||||
|
if (target.source === null) return;
|
||||||
|
database.insert(externalModelSources).values(target.source).run();
|
||||||
|
database.insert(externalImportBatches).values(
|
||||||
|
target.importBatches.map(({ originalContentBase64, ...batch }) => ({
|
||||||
|
...batch,
|
||||||
|
originalBytes: Buffer.from(originalContentBase64, "base64"),
|
||||||
|
}))
|
||||||
|
).run();
|
||||||
|
if (target.roomMappings.length) {
|
||||||
|
database.insert(externalRoomMappings).values(target.roomMappings).run();
|
||||||
|
}
|
||||||
|
database.insert(externalModelObjects).values(target.objects).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCanonicalBase64(value: string) {
|
||||||
|
const bytes = Buffer.from(value, "base64");
|
||||||
|
if (bytes.toString("base64") !== value) {
|
||||||
|
throw new Error("External initial import bytes are not canonical base64.");
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameState(left: ExternalModelStateSnapshot, right: ExternalModelStateSnapshot) {
|
||||||
|
return canonicalJson(sortState(left)) === canonicalJson(sortState(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortState(state: ExternalModelStateSnapshot): ExternalModelStateSnapshot {
|
||||||
|
const byId = <T extends { id: string }>(entries: readonly T[]) =>
|
||||||
|
[...entries].sort((left, right) => left.id.localeCompare(right.id));
|
||||||
|
return {
|
||||||
|
source: state.source,
|
||||||
|
importBatches: byId(state.importBatches),
|
||||||
|
roomMappings: byId(state.roomMappings),
|
||||||
|
objects: byId(state.objects),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalJson(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||||
|
if (value !== null && typeof value === "object") {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return `{${Object.keys(record).sort().map(
|
||||||
|
(key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
||||||
|
).join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ export const externalImportBatches = sqliteTable(
|
|||||||
fileName: text("file_name").notNull(),
|
fileName: text("file_name").notNull(),
|
||||||
sha256: text("sha256").notNull(),
|
sha256: text("sha256").notNull(),
|
||||||
appliedProjectRevision: integer("applied_project_revision").notNull(),
|
appliedProjectRevision: integer("applied_project_revision").notNull(),
|
||||||
|
configurationVersion: integer("configuration_version").notNull(),
|
||||||
configurationSnapshot: text("configuration_snapshot", { mode: "json" })
|
configurationSnapshot: text("configuration_snapshot", { mode: "json" })
|
||||||
.$type<ExternalCsvConfiguration>()
|
.$type<ExternalCsvConfiguration>()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const externalInitialImportCommandType =
|
||||||
|
"external-import.apply-initial" as const;
|
||||||
|
export const externalInitialImportCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface ExternalInitialImportPayload {
|
||||||
|
expected: ExternalModelStateSnapshot;
|
||||||
|
target: ExternalModelStateSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalInitialImportProjectCommand
|
||||||
|
extends SerializedProjectCommand<ExternalInitialImportPayload> {
|
||||||
|
schemaVersion: typeof externalInitialImportCommandSchemaVersion;
|
||||||
|
type: typeof externalInitialImportCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExternalInitialImportProjectCommand(
|
||||||
|
expected: ExternalModelStateSnapshot,
|
||||||
|
target: ExternalModelStateSnapshot
|
||||||
|
): ExternalInitialImportProjectCommand {
|
||||||
|
const command: ExternalInitialImportProjectCommand = {
|
||||||
|
schemaVersion: externalInitialImportCommandSchemaVersion,
|
||||||
|
type: externalInitialImportCommandType,
|
||||||
|
payload: { expected, target },
|
||||||
|
};
|
||||||
|
assertExternalInitialImportProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertExternalInitialImportProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is ExternalInitialImportProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== externalInitialImportCommandSchemaVersion ||
|
||||||
|
command.type !== externalInitialImportCommandType ||
|
||||||
|
!isRecord(command.payload) ||
|
||||||
|
Object.keys(command.payload).length !== 2
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported external initial import command.");
|
||||||
|
}
|
||||||
|
const expected = parseExternalModelStateSnapshot(command.payload.expected);
|
||||||
|
const target = parseExternalModelStateSnapshot(command.payload.target);
|
||||||
|
const expectedIsEmpty = isEmptyExternalModelState(expected);
|
||||||
|
const targetIsEmpty = isEmptyExternalModelState(target);
|
||||||
|
if (expectedIsEmpty === targetIsEmpty) {
|
||||||
|
throw new Error("External initial import must transition between empty and populated state.");
|
||||||
|
}
|
||||||
|
assertPopulatedInitialState(expectedIsEmpty ? target : expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyExternalModelState(): ExternalModelStateSnapshot {
|
||||||
|
return { source: null, importBatches: [], roomMappings: [], objects: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmptyExternalModelState(state: ExternalModelStateSnapshot) {
|
||||||
|
return (
|
||||||
|
state.source === null &&
|
||||||
|
state.importBatches.length === 0 &&
|
||||||
|
state.roomMappings.length === 0 &&
|
||||||
|
state.objects.length === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPopulatedInitialState(state: ExternalModelStateSnapshot) {
|
||||||
|
if (state.source === null || state.importBatches.length !== 1 || state.objects.length === 0) {
|
||||||
|
throw new Error("External initial import requires one source, one batch and at least one object.");
|
||||||
|
}
|
||||||
|
const source = state.source;
|
||||||
|
const batch = state.importBatches[0];
|
||||||
|
if (
|
||||||
|
batch.importKind !== "initial" ||
|
||||||
|
batch.projectId !== source.projectId ||
|
||||||
|
batch.sourceId !== source.id
|
||||||
|
) {
|
||||||
|
throw new Error("External initial import batch identity is invalid.");
|
||||||
|
}
|
||||||
|
const mappingIds = uniqueIds(state.roomMappings, "room mapping");
|
||||||
|
const roomKeys = new Set<string>();
|
||||||
|
for (const mapping of state.roomMappings) {
|
||||||
|
if (mapping.projectId !== source.projectId || mapping.sourceId !== source.id) {
|
||||||
|
throw new Error("External initial room mapping identity is invalid.");
|
||||||
|
}
|
||||||
|
if (roomKeys.has(mapping.normalizedSourceRoomKey)) {
|
||||||
|
throw new Error("External initial import contains duplicate room keys.");
|
||||||
|
}
|
||||||
|
roomKeys.add(mapping.normalizedSourceRoomKey);
|
||||||
|
}
|
||||||
|
uniqueIds(state.objects, "object");
|
||||||
|
const ifcGuids = new Set<string>();
|
||||||
|
for (const object of state.objects) {
|
||||||
|
if (
|
||||||
|
object.projectId !== source.projectId ||
|
||||||
|
object.sourceId !== source.id ||
|
||||||
|
object.lastSeenImportBatchId !== batch.id ||
|
||||||
|
object.lastAcceptedImportBatchId !== batch.id
|
||||||
|
) {
|
||||||
|
throw new Error("External initial object identity is invalid.");
|
||||||
|
}
|
||||||
|
if (ifcGuids.has(object.ifcGuid)) {
|
||||||
|
throw new Error("External initial import contains duplicate IFCGUIDs.");
|
||||||
|
}
|
||||||
|
ifcGuids.add(object.ifcGuid);
|
||||||
|
if (
|
||||||
|
object.externalRoomMappingId !== null &&
|
||||||
|
!mappingIds.has(object.externalRoomMappingId)
|
||||||
|
) {
|
||||||
|
throw new Error("External initial object room mapping is invalid.");
|
||||||
|
}
|
||||||
|
if (object.circuitDeviceRowId !== null) {
|
||||||
|
throw new Error("External initial import must not assign circuit device rows.");
|
||||||
|
}
|
||||||
|
if (object.presenceStatus !== "present") {
|
||||||
|
throw new Error("External initial import objects must be present.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueIds(entries: ReadonlyArray<{ id: string }>, label: string) {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (ids.has(entry.id)) {
|
||||||
|
throw new Error(`External initial import contains duplicate ${label} ids.`);
|
||||||
|
}
|
||||||
|
ids.add(entry.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
externalImportKinds,
|
externalImportKinds,
|
||||||
externalModelSourceTypes,
|
externalModelSourceTypes,
|
||||||
externalObjectPresenceStatuses,
|
externalObjectPresenceStatuses,
|
||||||
|
type ExternalModelStateSnapshot,
|
||||||
} from "../../external-model/domain/external-model-contracts.js";
|
} from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
|
||||||
export const projectStateSnapshotSchemaVersion = 4 as const;
|
export const projectStateSnapshotSchemaVersion = 4 as const;
|
||||||
@@ -333,6 +334,7 @@ const externalImportBatchSchema = z.object({
|
|||||||
fileName: z.string().trim().min(1),
|
fileName: z.string().trim().min(1),
|
||||||
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
||||||
appliedProjectRevision: z.number().int().nonnegative(),
|
appliedProjectRevision: z.number().int().nonnegative(),
|
||||||
|
configurationVersion: z.number().int().positive(),
|
||||||
configurationSnapshot: z.custom<ExternalCsvConfiguration>(
|
configurationSnapshot: z.custom<ExternalCsvConfiguration>(
|
||||||
(value) => validateExternalCsvConfiguration(value).success
|
(value) => validateExternalCsvConfiguration(value).success
|
||||||
),
|
),
|
||||||
@@ -414,6 +416,12 @@ const externalModelStateSchema = z.object({
|
|||||||
objects: z.array(externalModelObjectSchema),
|
objects: z.array(externalModelObjectSchema),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
|
export function parseExternalModelStateSnapshot(
|
||||||
|
value: unknown
|
||||||
|
): ExternalModelStateSnapshot {
|
||||||
|
return externalModelStateSchema.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
const projectStateSnapshotContents = {
|
const projectStateSnapshotContents = {
|
||||||
circuitLists: z.array(circuitListSchema),
|
circuitLists: z.array(circuitListSchema),
|
||||||
circuitSections: z.array(circuitSectionSchema),
|
circuitSections: z.array(circuitSectionSchema),
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ExternalInitialImportProjectCommand } from "../models/external-initial-import-project-command.model.js";
|
||||||
|
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
|
||||||
|
|
||||||
|
export interface ExecuteExternalInitialImportCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: ExternalInitialImportProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalInitialImportProjectCommandStore {
|
||||||
|
execute(input: ExecuteExternalInitialImportCommandInput): {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: ExternalInitialImportProjectCommand;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -166,6 +166,11 @@ import {
|
|||||||
externalCsvConfigurationUpdateCommandType,
|
externalCsvConfigurationUpdateCommandType,
|
||||||
} from "../models/external-csv-configuration-project-command.model.js";
|
} from "../models/external-csv-configuration-project-command.model.js";
|
||||||
import type { ExternalCsvConfigurationProjectCommandStore } from "../ports/external-csv-configuration-project-command.store.js";
|
import type { ExternalCsvConfigurationProjectCommandStore } from "../ports/external-csv-configuration-project-command.store.js";
|
||||||
|
import {
|
||||||
|
assertExternalInitialImportProjectCommand,
|
||||||
|
externalInitialImportCommandType,
|
||||||
|
} from "../models/external-initial-import-project-command.model.js";
|
||||||
|
import type { ExternalInitialImportProjectCommandStore } from "../ports/external-initial-import-project-command.store.js";
|
||||||
|
|
||||||
interface DispatchProjectCommandInput {
|
interface DispatchProjectCommandInput {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -201,7 +206,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
|
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
|
||||||
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
|
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
|
||||||
private readonly historyStore: ProjectHistoryStore,
|
private readonly historyStore: ProjectHistoryStore,
|
||||||
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore
|
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore,
|
||||||
|
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
executeUser(
|
executeUser(
|
||||||
@@ -568,6 +574,16 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
|
case externalInitialImportCommandType: {
|
||||||
|
assertExternalInitialImportProjectCommand(input.command);
|
||||||
|
if (!this.externalInitialImportStore) {
|
||||||
|
throw new Error("External initial import store is not available.");
|
||||||
|
}
|
||||||
|
return this.externalInitialImportStore.execute({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
case projectStateRestoreCommandType: {
|
case projectStateRestoreCommandType: {
|
||||||
assertProjectStateRestoreCommand(input.command);
|
assertProjectStateRestoreCommand(input.command);
|
||||||
return this.projectStateRestoreStore.execute({
|
return this.projectStateRestoreStore.execute({
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface ExternalImportBatchSnapshot {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
appliedProjectRevision: number;
|
appliedProjectRevision: number;
|
||||||
|
configurationVersion: number;
|
||||||
configurationSnapshot: ExternalCsvConfiguration;
|
configurationSnapshot: ExternalCsvConfiguration;
|
||||||
originalContentBase64: string;
|
originalContentBase64: string;
|
||||||
document: ExternalCsvDocument;
|
document: ExternalCsvDocument;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/p
|
|||||||
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
|
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
|
||||||
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
||||||
import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-configuration-project-command.repository.js";
|
import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-configuration-project-command.repository.js";
|
||||||
|
import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js";
|
||||||
|
|
||||||
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||||
export const circuitDeviceRowProjectCommandStore =
|
export const circuitDeviceRowProjectCommandStore =
|
||||||
@@ -68,6 +69,8 @@ export const projectStateRestoreCommandStore =
|
|||||||
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
||||||
export const externalCsvConfigurationProjectCommandStore =
|
export const externalCsvConfigurationProjectCommandStore =
|
||||||
new ExternalCsvConfigurationProjectCommandRepository(db);
|
new ExternalCsvConfigurationProjectCommandRepository(db);
|
||||||
|
export const externalInitialImportProjectCommandStore =
|
||||||
|
new ExternalInitialImportProjectCommandRepository(db);
|
||||||
export const projectCommandService = new ProjectCommandService(
|
export const projectCommandService = new ProjectCommandService(
|
||||||
circuitProjectCommandStore,
|
circuitProjectCommandStore,
|
||||||
circuitDeviceRowProjectCommandStore,
|
circuitDeviceRowProjectCommandStore,
|
||||||
@@ -91,5 +94,6 @@ export const projectCommandService = new ProjectCommandService(
|
|||||||
circuitGroupSubtreeProjectCommandStore,
|
circuitGroupSubtreeProjectCommandStore,
|
||||||
circuitProtectionProjectCommandStore,
|
circuitProtectionProjectCommandStore,
|
||||||
projectHistoryStore,
|
projectHistoryStore,
|
||||||
externalCsvConfigurationProjectCommandStore
|
externalCsvConfigurationProjectCommandStore,
|
||||||
|
externalInitialImportProjectCommandStore
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||||
|
import { ExternalInitialImportProjectCommandRepository } from "../src/db/repositories/external-initial-import-project-command.repository.js";
|
||||||
|
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
|
||||||
|
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
|
||||||
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
|
import {
|
||||||
|
createEmptyExternalModelState,
|
||||||
|
createExternalInitialImportProjectCommand,
|
||||||
|
} from "../src/domain/models/external-initial-import-project-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 type { ExternalModelStateSnapshot } from "../src/external-model/domain/external-model-contracts.js";
|
||||||
|
import {
|
||||||
|
createExternalRoomKey,
|
||||||
|
projectInitialExternalObject,
|
||||||
|
} from "../src/external-model/domain/external-model-matching.js";
|
||||||
|
import {
|
||||||
|
externalCsvTestConfiguration,
|
||||||
|
quotedRevitCsv,
|
||||||
|
utf8Bytes,
|
||||||
|
} from "./fixtures/revit-csv-fixtures.js";
|
||||||
|
|
||||||
|
function createTestContext() {
|
||||||
|
const context = createDatabaseContext(":memory:");
|
||||||
|
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||||
|
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
||||||
|
const configuration = configuredCsv();
|
||||||
|
context.db.insert(externalCsvConfigurations).values({
|
||||||
|
id: "config-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
configurationVersion: 1,
|
||||||
|
configuration,
|
||||||
|
}).run();
|
||||||
|
return { context, configuration };
|
||||||
|
}
|
||||||
|
|
||||||
|
function configuredCsv() {
|
||||||
|
const configuration = structuredClone(externalCsvTestConfiguration);
|
||||||
|
configuration.familyTypeRules.push(
|
||||||
|
{
|
||||||
|
exactFamilyAndType: "Steckdose: Doppelsteckdose",
|
||||||
|
internalDeviceType: "Steckdose",
|
||||||
|
connectionKind: "socket",
|
||||||
|
category: "single_phase",
|
||||||
|
quantityRule: { kind: "mapped-column" },
|
||||||
|
displayNameSuggestion: { kind: "selection-marker" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exactFamilyAndType: "Steckdose: Standard",
|
||||||
|
internalDeviceType: "Steckdose",
|
||||||
|
connectionKind: "socket",
|
||||||
|
category: "single_phase",
|
||||||
|
quantityRule: { kind: "fixed", quantity: 1 },
|
||||||
|
displayNameSuggestion: { kind: "selection-marker" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialState(
|
||||||
|
configuration: ReturnType<typeof configuredCsv>
|
||||||
|
): ExternalModelStateSnapshot {
|
||||||
|
const bytes = utf8Bytes(quotedRevitCsv, true);
|
||||||
|
const preview = createExternalCsvPreview({
|
||||||
|
fileName: "revit.csv",
|
||||||
|
bytes,
|
||||||
|
configuration,
|
||||||
|
});
|
||||||
|
const roomKeys = [...new Set(preview.objects.map((object) =>
|
||||||
|
createExternalRoomKey(object.roomNumber, object.roomName)
|
||||||
|
).filter((key): key is string => key !== null))];
|
||||||
|
const roomMappings = roomKeys.map((key, index) => {
|
||||||
|
const object = preview.objects.find(
|
||||||
|
(candidate) => createExternalRoomKey(candidate.roomNumber, candidate.roomName) === key
|
||||||
|
)!;
|
||||||
|
return {
|
||||||
|
id: `mapping-${index + 1}`,
|
||||||
|
projectId: "project-1",
|
||||||
|
sourceId: "source-1",
|
||||||
|
normalizedSourceRoomKey: key,
|
||||||
|
sourceFloorName: null,
|
||||||
|
sourceRoomNumber: object.roomNumber,
|
||||||
|
sourceRoomName: object.roomName,
|
||||||
|
roomId: null,
|
||||||
|
defaultDistributionBoardId: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const mappingByKey = new Map(
|
||||||
|
roomMappings.map((mapping) => [mapping.normalizedSourceRoomKey, mapping.id])
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
source: {
|
||||||
|
id: "source-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "Revit-Gesamtmodell",
|
||||||
|
sourceType: "revit_csv",
|
||||||
|
},
|
||||||
|
importBatches: [{
|
||||||
|
id: "batch-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
sourceId: "source-1",
|
||||||
|
importKind: "initial",
|
||||||
|
importedAtIso: "2026-08-02T16:00:00.000Z",
|
||||||
|
fileName: "revit.csv",
|
||||||
|
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
||||||
|
appliedProjectRevision: 1,
|
||||||
|
configurationVersion: 1,
|
||||||
|
configurationSnapshot: configuration,
|
||||||
|
originalContentBase64: Buffer.from(bytes).toString("base64"),
|
||||||
|
document: parseExternalCsv(bytes, configuration),
|
||||||
|
}],
|
||||||
|
roomMappings,
|
||||||
|
objects: preview.objects.map((candidate, index) => {
|
||||||
|
const projected = projectInitialExternalObject(candidate, configuration);
|
||||||
|
const roomKey = createExternalRoomKey(candidate.roomNumber, candidate.roomName);
|
||||||
|
return {
|
||||||
|
id: `object-${index + 1}`,
|
||||||
|
projectId: "project-1",
|
||||||
|
sourceId: "source-1",
|
||||||
|
ifcGuid: projected.ifcGuid,
|
||||||
|
lastSeenImportBatchId: "batch-1",
|
||||||
|
lastAcceptedImportBatchId: "batch-1",
|
||||||
|
acceptedSourceValues: projected.sourceValues,
|
||||||
|
planningValues: projected.planningValues,
|
||||||
|
overriddenFields: [],
|
||||||
|
externalRoomMappingId: roomKey === null ? null : mappingByKey.get(roomKey)!,
|
||||||
|
distributionBoardId: null,
|
||||||
|
linkedProjectDeviceId: null,
|
||||||
|
circuitDeviceRowId: null,
|
||||||
|
presenceStatus: "present",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("external initial import project command", () => {
|
||||||
|
it("commits the complete import and supports persisted undo and redo", () => {
|
||||||
|
const { context, configuration } = createTestContext();
|
||||||
|
try {
|
||||||
|
const target = initialState(configuration);
|
||||||
|
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
|
||||||
|
const inserted = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
description: "Revit-Erstimport übernehmen",
|
||||||
|
command: createExternalInitialImportProjectCommand(
|
||||||
|
createEmptyExternalModelState(),
|
||||||
|
target
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.equal(inserted.revision.revisionNumber, 1);
|
||||||
|
assert.equal(
|
||||||
|
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||||
|
.state.objects.length,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||||
|
.state.objects.every((object) => object.circuitDeviceRowId === null)
|
||||||
|
);
|
||||||
|
|
||||||
|
const undone = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||||
|
command: inserted.inverse,
|
||||||
|
});
|
||||||
|
assert.equal(undone.revision.revisionNumber, 2);
|
||||||
|
assert.equal(
|
||||||
|
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||||
|
.state.source,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 2,
|
||||||
|
source: "redo",
|
||||||
|
historyTargetChangeSetId: inserted.revision.changeSetId,
|
||||||
|
command: undone.inverse,
|
||||||
|
});
|
||||||
|
const redone = new ExternalModelStateRepository(context.db)
|
||||||
|
.getByProject("project-1").state;
|
||||||
|
assert.deepEqual(redone.objects.map((object) => object.id), ["object-1", "object-2"]);
|
||||||
|
assert.equal(redone.importBatches[0].configurationVersion, 1);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects changed state, checksum drift and row assignment", () => {
|
||||||
|
const { context, configuration } = createTestContext();
|
||||||
|
try {
|
||||||
|
const target = initialState(configuration);
|
||||||
|
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
|
||||||
|
const assigned = structuredClone(target);
|
||||||
|
assigned.objects[0].circuitDeviceRowId = "row-1";
|
||||||
|
assert.throws(
|
||||||
|
() => createExternalInitialImportProjectCommand(
|
||||||
|
createEmptyExternalModelState(),
|
||||||
|
assigned
|
||||||
|
),
|
||||||
|
/must not assign circuit device rows/
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidHash = structuredClone(target);
|
||||||
|
invalidHash.importBatches[0].sha256 = "b".repeat(64);
|
||||||
|
assert.throws(
|
||||||
|
() => repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createExternalInitialImportProjectCommand(
|
||||||
|
createEmptyExternalModelState(),
|
||||||
|
invalidHash
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/checksum is invalid/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||||
|
.state.source,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back all external rows when late history persistence fails", () => {
|
||||||
|
const { context, configuration } = createTestContext();
|
||||||
|
try {
|
||||||
|
context.sqlite.exec(`
|
||||||
|
CREATE TRIGGER fail_external_import_history
|
||||||
|
BEFORE INSERT ON project_change_sets
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'late history failure');
|
||||||
|
END;
|
||||||
|
`);
|
||||||
|
const repository = new ExternalInitialImportProjectCommandRepository(context.db);
|
||||||
|
assert.throws(
|
||||||
|
() => repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createExternalInitialImportProjectCommand(
|
||||||
|
createEmptyExternalModelState(),
|
||||||
|
initialState(configuration)
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/late history failure/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||||
|
.state.source,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -126,6 +126,7 @@ describe("external model state repository", () => {
|
|||||||
fileName: "revit.csv",
|
fileName: "revit.csv",
|
||||||
sha256: "hash",
|
sha256: "hash",
|
||||||
appliedProjectRevision: 1,
|
appliedProjectRevision: 1,
|
||||||
|
configurationVersion: 1,
|
||||||
configurationSnapshot: configuration,
|
configurationSnapshot: configuration,
|
||||||
originalBytes: Buffer.from(bytes),
|
originalBytes: Buffer.from(bytes),
|
||||||
document,
|
document,
|
||||||
@@ -388,6 +389,7 @@ function insertLinkedExternalFixture(database: ReturnType<typeof createDatabaseC
|
|||||||
fileName: "revit.csv",
|
fileName: "revit.csv",
|
||||||
sha256: "a".repeat(64),
|
sha256: "a".repeat(64),
|
||||||
appliedProjectRevision: 0,
|
appliedProjectRevision: 0,
|
||||||
|
configurationVersion: 1,
|
||||||
configurationSnapshot: configuration,
|
configurationSnapshot: configuration,
|
||||||
originalBytes: Buffer.from(bytes),
|
originalBytes: Buffer.from(bytes),
|
||||||
document: parseExternalCsv(bytes, configuration),
|
document: parseExternalCsv(bytes, configuration),
|
||||||
|
|||||||
Reference in New Issue
Block a user