Add atomic Revit initial import

This commit is contained in:
2026-08-02 17:33:32 +02:00
parent e87a3289fa
commit c634570018
16 changed files with 3150 additions and 8 deletions
+12 -2
View File
@@ -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,
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. Snapshot/restore and portable project transfer schema version 4
The general external-model repository is read-only; runtime writes belong only
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
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
25 new revisions and retains only the newest 12 automatic snapshots per
project. Named snapshots are never removed by this retention policy.
+14 -3
View File
@@ -508,7 +508,9 @@ Migration `0003` ergänzt die relationale Grundlage für genau eine implizite
`revit_csv`-Quelle je Projekt, geordnete Importbatches, normalisierte
Quellraummappings und über `(sourceId, ifcGuid)` eindeutige externe Objekte.
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
und CircuitDeviceRow bleiben nullable. `ExternalModelStateRepository` liest den
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,
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.
unverändert. `external-import.apply-initial` ist die atomare Schreibgrenze für
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
ändern die Konfiguration; der PUT plant Identität und nächsten
Konfigurationsstand serverseitig und verwendet den typisierten Command.
+10 -1
View File
@@ -108,7 +108,8 @@ ohne eine zweite veränderbare Historientabelle einzuführen.
- stabile UUID, `projectId`, `sourceId`, Importart und Zeitstempel;
- Dateiname, SHA-256 und zugehörige Projektrevision;
- verwendeter Konfigurationssnapshot;
- verwendete monotone Konfigurationsversion und vollständiger
Konfigurationssnapshot;
- Originaldatei als Bytes;
- 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,
lassen IFCGUID und Quelltransport jedoch unverändert.
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
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
+7
View File
@@ -29,6 +29,13 @@
"when": 1785683710155,
"tag": "0003_outstanding_maddog",
"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);
}
+1
View File
@@ -22,6 +22,7 @@ export const externalImportBatches = sqliteTable(
fileName: text("file_name").notNull(),
sha256: text("sha256").notNull(),
appliedProjectRevision: integer("applied_project_revision").notNull(),
configurationVersion: integer("configuration_version").notNull(),
configurationSnapshot: text("configuration_snapshot", { mode: "json" })
.$type<ExternalCsvConfiguration>()
.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,
externalModelSourceTypes,
externalObjectPresenceStatuses,
type ExternalModelStateSnapshot,
} from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 4 as const;
@@ -333,6 +334,7 @@ const externalImportBatchSchema = z.object({
fileName: z.string().trim().min(1),
sha256: z.string().regex(/^[a-f0-9]{64}$/),
appliedProjectRevision: z.number().int().nonnegative(),
configurationVersion: z.number().int().positive(),
configurationSnapshot: z.custom<ExternalCsvConfiguration>(
(value) => validateExternalCsvConfiguration(value).success
),
@@ -414,6 +416,12 @@ const externalModelStateSchema = z.object({
objects: z.array(externalModelObjectSchema),
}).strict();
export function parseExternalModelStateSnapshot(
value: unknown
): ExternalModelStateSnapshot {
return externalModelStateSchema.parse(value);
}
const projectStateSnapshotContents = {
circuitLists: z.array(circuitListSchema),
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;
};
}
+17 -1
View File
@@ -166,6 +166,11 @@ import {
externalCsvConfigurationUpdateCommandType,
} from "../models/external-csv-configuration-project-command.model.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 {
projectId: string;
@@ -201,7 +206,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
private readonly historyStore: ProjectHistoryStore,
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore,
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore
) {}
executeUser(
@@ -568,6 +574,16 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).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: {
assertProjectStateRestoreCommand(input.command);
return this.projectStateRestoreStore.execute({
@@ -30,6 +30,7 @@ export interface ExternalImportBatchSnapshot {
fileName: string;
sha256: string;
appliedProjectRevision: number;
configurationVersion: number;
configurationSnapshot: ExternalCsvConfiguration;
originalContentBase64: string;
document: ExternalCsvDocument;
@@ -23,6 +23,7 @@ import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/p
import { ProjectStateRestoreCommandRepository } from "../../db/repositories/project-state-restore-command.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.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 circuitDeviceRowProjectCommandStore =
@@ -68,6 +69,8 @@ export const projectStateRestoreCommandStore =
export const projectHistoryStore = new ProjectHistoryRepository(db);
export const externalCsvConfigurationProjectCommandStore =
new ExternalCsvConfigurationProjectCommandRepository(db);
export const externalInitialImportProjectCommandStore =
new ExternalInitialImportProjectCommandRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore,
@@ -91,5 +94,6 @@ export const projectCommandService = new ProjectCommandService(
circuitGroupSubtreeProjectCommandStore,
circuitProtectionProjectCommandStore,
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();
}
});
});
+2
View File
@@ -126,6 +126,7 @@ describe("external model state repository", () => {
fileName: "revit.csv",
sha256: "hash",
appliedProjectRevision: 1,
configurationVersion: 1,
configurationSnapshot: configuration,
originalBytes: Buffer.from(bytes),
document,
@@ -388,6 +389,7 @@ function insertLinkedExternalFixture(database: ReturnType<typeof createDatabaseC
fileName: "revit.csv",
sha256: "a".repeat(64),
appliedProjectRevision: 0,
configurationVersion: 1,
configurationSnapshot: configuration,
originalBytes: Buffer.from(bytes),
document: parseExternalCsv(bytes, configuration),