Persist Revit CSV configuration

This commit is contained in:
2026-08-02 16:33:18 +02:00
parent da689af518
commit 219da5ec3b
22 changed files with 2508 additions and 27 deletions
+11 -4
View File
@@ -250,13 +250,20 @@ 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 2 additionally stores whether the project is a
public building. Supported baseline version 1 snapshots are upgraded with
`isPublicBuilding = false`; pre-baseline snapshots remain unsupported. Both
versions contain circuit-group identity, distribution-board components and the
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
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
remap component ids and protection-owner references together with the existing
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
project duplication preserve the configuration; duplication remaps its internal
id and project link.
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.
+22 -4
View File
@@ -416,12 +416,14 @@ 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 2 enthält zusätzlich die Gebäudekennzeichnung sowie den Gleichzeitigkeitsfaktor,
Snapshot-Schema 3 enthält zusätzlich 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
Baseline-Version 1 wird mit `isPublicBuilding = false` hochgestuft; Formate vor
Version 2 wird 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,
Bearbeitung und Löschung von Geschossen und Räumen mit stabilen UUIDs und
@@ -473,7 +475,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 2. Persistente Insert/Delete/Update-Commands für
Snapshot-Schema 3. 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
@@ -485,10 +487,26 @@ PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
## Revit-CSV-Grundlage
Der reine Adapter unter `src/external-model/csv` validiert die projektweite
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt-
und Verdachtszeilen, prüft IFCGUIDs und serialisiert unveränderte Revit-Exporte
einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl
`npm run revit:verify-reference` prüft die bereitgestellte Referenzdatei.
`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,
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
werden Konfigurations-UUID und Projektlink remapped. Importvorschau, persistente
externe Objekte und die Revit-GUI sind noch nicht implementiert.
## Noch nicht unterstützt
- Mehrbenutzerbetrieb und Konfliktauflösung
- Revit-/CSV-/IFCGUID-Round-trip
- vollständiger Revit-/CSV-/IFCGUID-Round-trip über die vorhandene Grundlage hinaus
- vollständige elektrische Dimensionierung
- Produktionsdeployment
@@ -4,8 +4,9 @@
Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den
gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 befindet sich in
Umsetzung; ihre reinen Transportverträge, Konfigurationsvalidatoren und die
zustandsfreie CSV-Transportgrundlage sind vorhanden.
Umsetzung; Transportverträge, Konfigurationsvalidatoren, die zustandsfreie
CSV-Transportgrundlage und die persistente projektweite Konfiguration sind
vorhanden.
Fachliche Quelle bleibt
[Revit-CSV-Integration Anforderungen und Implementierungsanweisung](revit-csv-integration-requirements.md).
@@ -247,7 +248,7 @@ keine Projektrevision und laufen über einen eigenen Audit-Store.
Titel-, Leer-, Objekt- und Passthrough-Zeilen implementieren. **Erledigt für
synthetische Testdaten; die Referenzdatei steht noch aus.**
3. Konfiguration über Migration, Snapshot v3, Transfer und den Command
`external-csv-configuration.update` persistieren.
`external-csv-configuration.update` persistieren. **Erledigt.**
4. Vorschau-Endpunkt und deutschen Projekt-Wizard ergänzen. Der Server speichert
keinen Entwurf: Bei Bestätigung wird die Datei erneut übertragen, erneut
geparst und ihr Hash gegen die Vorschau geprüft.
@@ -0,0 +1,9 @@
CREATE TABLE `external_csv_configurations` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`configuration_version` integer NOT NULL,
`configuration` text NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `external_csv_configurations_project_id_unique` ON `external_csv_configurations` (`project_id`);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -15,6 +15,13 @@
"when": 1785511894897,
"tag": "0001_add_public_building_setting",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1785680771603,
"tag": "0002_awesome_madripoor",
"breakpoints": true
}
]
}
@@ -0,0 +1,105 @@
import { eq } from "drizzle-orm";
import {
assertExternalCsvConfigurationUpdateProjectCommand,
createExternalCsvConfigurationUpdateProjectCommand,
type ExternalCsvConfigurationSnapshot,
} from "../../domain/models/external-csv-configuration-project-command.model.js";
import type {
ExecuteExternalCsvConfigurationCommandInput,
ExternalCsvConfigurationProjectCommandStore,
} from "../../domain/ports/external-csv-configuration-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { externalCsvConfigurations } from "../schema/external-csv-configurations.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
export class ExternalCsvConfigurationProjectCommandRepository
implements ExternalCsvConfigurationProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteExternalCsvConfigurationCommandInput) {
assertExternalCsvConfigurationUpdateProjectCommand(input.command);
return executeProjectCommandTransaction(this.database, input, (tx) => {
const project = tx
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, input.projectId))
.get();
if (!project) {
throw new Error("Project not found.");
}
const current = tx
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.get() ?? null;
assertSameSnapshot(current, input.command.payload.expected);
const target = input.command.payload.target;
if (target !== null && target.projectId !== input.projectId) {
throw new Error("External CSV configuration belongs to a different project.");
}
if (sameSnapshot(current, target)) {
throw new Error("External CSV configuration did not change.");
}
if (
input.source === "user" &&
target !== null &&
target.configurationVersion !== (current?.configurationVersion ?? 0) + 1
) {
throw new Error("External CSV configuration version must advance by one.");
}
if (target === null) {
tx.delete(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, input.projectId))
.run();
} else if (current === null) {
tx.insert(externalCsvConfigurations).values(target).run();
} else {
const updated = tx
.update(externalCsvConfigurations)
.set({
configurationVersion: target.configurationVersion,
configuration: target.configuration,
})
.where(eq(externalCsvConfigurations.id, current.id))
.run();
if (updated.changes !== 1) {
throw new Error("External CSV configuration changed before update.");
}
}
return createExternalCsvConfigurationUpdateProjectCommand(target, current);
});
}
}
function assertSameSnapshot(
current: ExternalCsvConfigurationSnapshot | null,
expected: ExternalCsvConfigurationSnapshot | null
) {
if (!sameSnapshot(current, expected)) {
throw new Error("External CSV configuration changed before update.");
}
}
function sameSnapshot(
left: ExternalCsvConfigurationSnapshot | null,
right: ExternalCsvConfigurationSnapshot | null
) {
return canonicalJson(left) === canonicalJson(right);
}
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);
}
@@ -26,6 +26,7 @@ import { projectDevices } from "../schema/project-devices.js";
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 { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
readProjectStateSnapshot,
@@ -161,6 +162,7 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
projectDevices: byId(state.projectDevices),
floors: byId(state.floors),
rooms: byId(state.rooms),
externalCsvConfiguration: state.externalCsvConfiguration,
});
}
@@ -183,6 +185,10 @@ function replaceProjectState(
database: AppDatabase,
state: ProjectStateSnapshot
) {
database
.delete(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, state.project.id))
.run();
database
.delete(distributionBoards)
.where(eq(distributionBoards.projectId, state.project.id))
@@ -218,6 +224,12 @@ function replaceProjectState(
.run();
insertMany(database, floors, state.floors);
if (state.externalCsvConfiguration !== null) {
database
.insert(externalCsvConfigurations)
.values(state.externalCsvConfiguration)
.run();
}
insertMany(database, rooms, state.rooms);
insertMany(database, projectDevices, state.projectDevices);
insertMany(database, distributionBoards, state.distributionBoards);
@@ -19,6 +19,7 @@ import { floors } from "../schema/floors.js";
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";
export interface PersistedProjectStateSnapshot {
currentRevision: number;
@@ -59,6 +60,11 @@ export function readProjectStateSnapshot(
.where(eq(distributionBoards.projectId, projectId))
.orderBy(asc(distributionBoards.name), asc(distributionBoards.id))
.all();
const externalCsvConfiguration = database
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, projectId))
.get() ?? null;
const listRows = database
.select()
.from(circuitLists)
@@ -241,6 +247,7 @@ export function readProjectStateSnapshot(
project.enabledDistributionBoardSupplyTypes,
},
distributionBoards: boardRows,
externalCsvConfiguration,
circuitLists: listRows,
circuitSections: sectionRows,
distributionBoardComponents: componentRows,
@@ -20,6 +20,7 @@ import { floors } from "../schema/floors.js";
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 {
hashProjectStatePayload,
readProjectStateSnapshot,
@@ -116,6 +117,12 @@ function insertProjectState(
.insert(projects)
.values({ ...state.project, currentRevision: 0 })
.run();
if (state.externalCsvConfiguration !== null) {
database
.insert(externalCsvConfigurations)
.values(state.externalCsvConfiguration)
.run();
}
insertMany(database, floors, state.floors);
insertMany(database, rooms, state.rooms);
insertMany(database, projectDevices, state.projectDevices);
@@ -0,0 +1,22 @@
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
import { projects } from "./projects.js";
export const externalCsvConfigurations = sqliteTable(
"external_csv_configurations",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
configurationVersion: integer("configuration_version").notNull(),
configuration: text("configuration", { mode: "json" })
.$type<ExternalCsvConfiguration>()
.notNull(),
},
(table) => [
uniqueIndex("external_csv_configurations_project_id_unique").on(
table.projectId
),
]
);
@@ -0,0 +1,94 @@
import { assertExternalCsvConfiguration } from "../../external-model/csv/external-csv-configuration.js";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const externalCsvConfigurationUpdateCommandType =
"external-csv-configuration.update" as const;
export const externalCsvConfigurationCommandSchemaVersion = 1 as const;
export interface ExternalCsvConfigurationSnapshot {
id: string;
projectId: string;
configurationVersion: number;
configuration: ExternalCsvConfiguration;
}
export interface ExternalCsvConfigurationUpdatePayload {
expected: ExternalCsvConfigurationSnapshot | null;
target: ExternalCsvConfigurationSnapshot | null;
}
export interface ExternalCsvConfigurationUpdateProjectCommand
extends SerializedProjectCommand<ExternalCsvConfigurationUpdatePayload> {
schemaVersion: typeof externalCsvConfigurationCommandSchemaVersion;
type: typeof externalCsvConfigurationUpdateCommandType;
}
export function createExternalCsvConfigurationUpdateProjectCommand(
expected: ExternalCsvConfigurationSnapshot | null,
target: ExternalCsvConfigurationSnapshot | null
): ExternalCsvConfigurationUpdateProjectCommand {
const command: ExternalCsvConfigurationUpdateProjectCommand = {
schemaVersion: externalCsvConfigurationCommandSchemaVersion,
type: externalCsvConfigurationUpdateCommandType,
payload: { expected, target },
};
assertExternalCsvConfigurationUpdateProjectCommand(command);
return command;
}
export function assertExternalCsvConfigurationUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ExternalCsvConfigurationUpdateProjectCommand {
if (
command.schemaVersion !== externalCsvConfigurationCommandSchemaVersion ||
command.type !== externalCsvConfigurationUpdateCommandType ||
!isRecord(command.payload)
) {
throw new Error("Unsupported external CSV configuration update command.");
}
assertSnapshot(command.payload.expected, "expected");
assertSnapshot(command.payload.target, "target");
if (command.payload.expected === null && command.payload.target === null) {
throw new Error("External CSV configuration command must change state.");
}
if (
command.payload.expected !== null &&
command.payload.target !== null &&
(command.payload.expected.id !== command.payload.target.id ||
command.payload.expected.projectId !== command.payload.target.projectId)
) {
throw new Error("External CSV configuration identity must remain stable.");
}
}
function assertSnapshot(
value: unknown,
field: string
): asserts value is ExternalCsvConfigurationSnapshot | null {
if (value === null) {
return;
}
if (!isRecord(value)) {
throw new Error(`${field} external CSV configuration must be an object or null.`);
}
assertId(value.id, `${field}.id`);
assertId(value.projectId, `${field}.projectId`);
if (!Number.isSafeInteger(value.configurationVersion) || (value.configurationVersion as number) < 1) {
throw new Error(`${field}.configurationVersion must be a positive integer.`);
}
assertExternalCsvConfiguration(value.configuration);
if (Object.keys(value).length !== 4) {
throw new Error(`${field} external CSV configuration contains unknown fields.`);
}
}
function assertId(value: unknown, field: string) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -18,9 +18,12 @@ import {
resolveCircuitPhaseType,
resolveProjectVoltage,
} 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";
export const projectStateSnapshotSchemaVersion = 2 as const;
const previousProjectStateSnapshotSchemaVersion = 1 as const;
export const projectStateSnapshotSchemaVersion = 3 as const;
const previousProjectStateSnapshotSchemaVersion = 2 as const;
const baselineProjectStateSnapshotSchemaVersion = 1 as const;
const idSchema = z.string().trim().min(1);
const nullableStringSchema = z.string().nullable();
@@ -249,6 +252,17 @@ const componentProtectionDeviceSchema = z
.strict()
.superRefine(validatePersistedProtectionDevice);
const externalCsvConfigurationSnapshotSchema = z
.object({
id: idSchema,
projectId: idSchema,
configurationVersion: z.number().int().positive(),
configuration: z.custom<ExternalCsvConfiguration>(
(value) => validateExternalCsvConfiguration(value).success
),
})
.strict();
const projectStateSnapshotContents = {
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
@@ -268,6 +282,7 @@ export const projectStateSnapshotSchema = z
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
...projectStateSnapshotContents,
})
.strict();
@@ -275,6 +290,15 @@ export const projectStateSnapshotSchema = z
const previousProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
...projectStateSnapshotContents,
})
.strict();
const baselineProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
project: previousProjectSchema,
distributionBoards: z.array(distributionBoardSchema),
...projectStateSnapshotContents,
@@ -301,20 +325,29 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
value === null ||
typeof value !== "object" ||
Array.isArray(value) ||
(value as { schemaVersion?: unknown }).schemaVersion !==
previousProjectStateSnapshotSchemaVersion
typeof (value as { schemaVersion?: unknown }).schemaVersion !== "number"
) {
return value;
}
const previous = previousProjectStateSnapshotSchema.parse(value);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
project: {
...previous.project,
isPublicBuilding: false,
},
};
const schemaVersion = (value as { schemaVersion: number }).schemaVersion;
if (schemaVersion === previousProjectStateSnapshotSchemaVersion) {
const previous = previousProjectStateSnapshotSchema.parse(value);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
externalCsvConfiguration: null,
};
}
if (schemaVersion === baselineProjectStateSnapshotSchemaVersion) {
const baseline = baselineProjectStateSnapshotSchema.parse(value);
return {
...baseline,
schemaVersion: projectStateSnapshotSchemaVersion,
project: { ...baseline.project, isPublicBuilding: false },
externalCsvConfiguration: null,
};
}
return value;
}
function normalizeSnapshotPhaseTypes(
@@ -377,6 +410,12 @@ function assertProjectStateSnapshotRelations(
snapshot: ProjectStateSnapshot
) {
const projectId = snapshot.project.id;
if (
snapshot.externalCsvConfiguration !== null &&
snapshot.externalCsvConfiguration.projectId !== projectId
) {
throw new Error("Snapshot external CSV configuration belongs to a different project.");
}
const boardIds = uniqueIds(
snapshot.distributionBoards,
"distribution board"
@@ -94,6 +94,14 @@ export function remapProjectState(
return parseProjectStateSnapshot({
...source,
project: { ...source.project, id: targetProjectId, name },
externalCsvConfiguration:
source.externalCsvConfiguration === null
? null
: {
...source.externalCsvConfiguration,
id: createId(),
projectId: targetProjectId,
},
distributionBoards: source.distributionBoards.map((board) => ({
...board,
id: requiredId(boardIds, board.id),
@@ -0,0 +1,19 @@
import type { ExternalCsvConfigurationUpdateProjectCommand } from "../models/external-csv-configuration-project-command.model.js";
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
export interface ExecuteExternalCsvConfigurationCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ExternalCsvConfigurationUpdateProjectCommand;
}
export interface ExternalCsvConfigurationProjectCommandStore {
execute(input: ExecuteExternalCsvConfigurationCommandInput): {
revision: AppendedProjectRevision;
inverse: ExternalCsvConfigurationUpdateProjectCommand;
};
}
+17 -1
View File
@@ -161,6 +161,11 @@ import type { ProjectDeviceStructureProjectCommandStore } from "../ports/project
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
import type { ProjectSettingsProjectCommandStore } from "../ports/project-settings-project-command.store.js";
import type { ProjectStateRestoreCommandStore } from "../ports/project-state-restore-command.store.js";
import {
assertExternalCsvConfigurationUpdateProjectCommand,
externalCsvConfigurationUpdateCommandType,
} from "../models/external-csv-configuration-project-command.model.js";
import type { ExternalCsvConfigurationProjectCommandStore } from "../ports/external-csv-configuration-project-command.store.js";
interface DispatchProjectCommandInput {
projectId: string;
@@ -195,7 +200,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitGroupMoveStore: CircuitGroupMoveProjectCommandStore,
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
private readonly historyStore: ProjectHistoryStore,
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore
) {}
executeUser(
@@ -552,6 +558,16 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case externalCsvConfigurationUpdateCommandType: {
assertExternalCsvConfigurationUpdateProjectCommand(input.command);
if (!this.externalCsvConfigurationStore) {
throw new Error("External CSV configuration store is not available.");
}
return this.externalCsvConfigurationStore.execute({
...input,
command: input.command,
}).revision;
}
case projectStateRestoreCommandType: {
assertProjectStateRestoreCommand(input.command);
return this.projectStateRestoreStore.execute({
@@ -27,6 +27,7 @@ export function assertExternalCsvConfiguration(
if (!isRecord(value)) {
throw new Error("External CSV configuration must be an object.");
}
assertExactKeyCount(value, 9, "External CSV configuration");
if (value.schemaVersion !== externalCsvConfigurationSchemaVersion) {
throw new Error("Unsupported external CSV configuration schema version.");
}
@@ -47,6 +48,7 @@ export function assertExternalCsvConfiguration(
if (!isRecord(value.columns)) {
throw new Error("External CSV columns must be an object.");
}
assertExactKeyCount(value.columns, 8, "External CSV columns");
const mappedColumns = new Set<string>();
for (const key of requiredColumnKeys) {
const columnName = assertTrimmedString(value.columns[key], `columns.${key}`);
@@ -74,6 +76,7 @@ export function assertExternalCsvConfiguration(
if (!isRecord(mapping)) {
throw new Error(`additionalSourceMappings.${index} must be an object.`);
}
assertExactKeyCount(mapping, 2, `additionalSourceMappings.${index}`);
const sourceColumn = assertTrimmedString(
mapping.sourceColumn,
`additionalSourceMappings.${index}.sourceColumn`
@@ -100,6 +103,7 @@ export function assertExternalCsvConfiguration(
if (!isRecord(rule)) {
throw new Error(`familyTypeRules.${index} must be an object.`);
}
assertExactKeyCount(rule, 6, `familyTypeRules.${index}`);
const exactFamilyAndType = assertTrimmedString(
rule.exactFamilyAndType,
`familyTypeRules.${index}.exactFamilyAndType`
@@ -152,9 +156,11 @@ function assertQuantityRule(value: unknown, index: number): asserts value is Ext
throw new Error(`familyTypeRules.${index}.quantityRule must be an object.`);
}
if (value.kind === "mapped-column") {
assertExactKeyCount(value, 1, `familyTypeRules.${index}.quantityRule`);
return;
}
if (value.kind === "fixed") {
assertExactKeyCount(value, 2, `familyTypeRules.${index}.quantityRule`);
assertPositiveFiniteNumber(value.quantity, `familyTypeRules.${index}.quantityRule.quantity`);
return;
}
@@ -172,9 +178,11 @@ function assertDisplayNameSuggestion(
throw new Error(`familyTypeRules.${index}.displayNameSuggestion must be an object or null.`);
}
if (value.kind === "selection-marker" || value.kind === "family-and-type") {
assertExactKeyCount(value, 1, `familyTypeRules.${index}.displayNameSuggestion`);
return;
}
if (value.kind === "fixed") {
assertExactKeyCount(value, 2, `familyTypeRules.${index}.displayNameSuggestion`);
assertTrimmedString(value.value, `familyTypeRules.${index}.displayNameSuggestion.value`);
return;
}
@@ -197,3 +205,9 @@ function assertTrimmedString(value: unknown, field: string) {
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function assertExactKeyCount(value: Record<string, unknown>, count: number, field: string) {
if (Object.keys(value).length !== count) {
throw new Error(`${field} contains unknown or missing fields.`);
}
}
@@ -22,6 +22,7 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../../db/reposit
import { ProjectSettingsProjectCommandRepository } from "../../db/repositories/project-settings-project-command.repository.js";
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";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
export const circuitDeviceRowProjectCommandStore =
@@ -65,6 +66,8 @@ export const projectSettingsProjectCommandStore =
export const projectStateRestoreCommandStore =
new ProjectStateRestoreCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db);
export const externalCsvConfigurationProjectCommandStore =
new ExternalCsvConfigurationProjectCommandRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore,
@@ -87,5 +90,6 @@ export const projectCommandService = new ProjectCommandService(
circuitGroupMoveProjectCommandStore,
circuitGroupSubtreeProjectCommandStore,
circuitProtectionProjectCommandStore,
projectHistoryStore
projectHistoryStore,
externalCsvConfigurationProjectCommandStore
);
@@ -0,0 +1,226 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js";
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.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 { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
import { projects } from "../src/db/schema/projects.js";
import { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js";
import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
return context;
}
function snapshot(version = 1) {
const configuration = createDefaultExternalCsvConfiguration(externalCsvTestColumns);
configuration.familyTypeRules.push({
exactFamilyAndType: "Steckdose: Standard",
internalDeviceType: "Steckdose",
connectionKind: null,
category: "single_phase",
quantityRule: { kind: "fixed", quantity: 1 },
displayNameSuggestion: { kind: "selection-marker" },
});
return {
id: "external-config-1",
projectId: "project-1",
configurationVersion: version,
configuration,
};
}
describe("external CSV configuration project command", () => {
it("persists insert and update with exact inverse commands", () => {
const context = createTestDatabase();
try {
const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
description: "Revit-CSV-Konfiguration anlegen",
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
});
assert.equal(inserted.revision.revisionNumber, 1);
assert.deepEqual(readConfiguration(context), initial);
assert.deepEqual(inserted.inverse.payload, { expected: initial, target: null });
const target = structuredClone(initial);
target.configurationVersion = 2;
target.configuration.decimalSeparator = ".";
const updated = repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, target),
});
assert.equal(updated.revision.revisionNumber, 2);
assert.deepEqual(readConfiguration(context), target);
assert.deepEqual(updated.inverse.payload, { expected: target, target: initial });
} finally {
context.close();
}
});
it("rejects stale state and cross-project targets without a partial revision", () => {
const context = createTestDatabase();
try {
const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
});
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(null, snapshot(2)),
}),
/changed before update/
);
const foreign = snapshot(2);
foreign.projectId = "project-2";
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, foreign),
}),
/identity must remain stable|different project/
);
assert.equal(context.db.select({ revision: projects.currentRevision }).from(projects).get()?.revision, 1);
const skippedVersion = snapshot(3);
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, skippedVersion),
}),
/version must advance by one/
);
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, initial),
}),
/did not change/
);
} finally {
context.close();
}
});
it("includes configuration in snapshots and remaps it for project duplicates", () => {
const context = createTestDatabase();
try {
const repository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
});
const state = readProjectStateSnapshot(context.db, "project-1")?.state;
assert.deepEqual(state?.externalCsvConfiguration, initial);
const transferRepository = new ProjectTransferRepository(context.db);
const transfer = transferRepository.exportProject("project-1");
assert.ok(transfer);
const duplicate = transferRepository.importDuplicate(transfer);
const duplicatedConfiguration = context.db
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, duplicate.projectId))
.get();
assert.ok(duplicatedConfiguration);
assert.notEqual(duplicatedConfiguration.id, initial.id);
assert.equal(duplicatedConfiguration.projectId, duplicate.projectId);
assert.deepEqual(duplicatedConfiguration.configuration, initial.configuration);
} finally {
context.close();
}
});
it("restores and undoes the complete configuration through snapshot history", () => {
const context = createTestDatabase();
try {
const configurationRepository = new ExternalCsvConfigurationProjectCommandRepository(context.db);
const initial = snapshot();
configurationRepository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(null, initial),
});
const configuredState = readProjectStateSnapshot(context.db, "project-1");
assert.ok(configuredState);
configurationRepository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createExternalCsvConfigurationUpdateProjectCommand(initial, null),
});
const emptyState = readProjectStateSnapshot(context.db, "project-1");
assert.ok(emptyState);
const restoreRepository = new ProjectStateRestoreCommandRepository(context.db);
const restored = restoreRepository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "user",
command: createProjectStateRestoreCommand(
emptyState.payloadSha256,
configuredState.state
),
});
assert.deepEqual(readConfiguration(context), initial);
restoreRepository.execute({
projectId: "project-1",
expectedRevision: 3,
source: "undo",
historyTargetChangeSetId: restored.revision.changeSetId,
command: restored.inverse,
});
assert.equal(readConfiguration(context), null);
} finally {
context.close();
}
});
});
function readConfiguration(context: DatabaseContext) {
return context.db
.select()
.from(externalCsvConfigurations)
.where(eq(externalCsvConfigurations.projectId, "project-1"))
.get() ?? null;
}
+8
View File
@@ -122,6 +122,14 @@ describe("external CSV configuration", () => {
configuration.familyTypeRules[0].quantityRule = { kind: "mapped-column" };
assert.match(validateFailure(configuration), /requires a configured quantity column/);
});
it("rejects unknown configuration fields", () => {
const configuration = {
...createDefaultExternalCsvConfiguration(columns),
legacyMapping: true,
};
assert.match(validateFailure(configuration), /unknown or missing fields/);
});
});
function validateFailure(value: unknown) {
+37 -1
View File
@@ -30,6 +30,7 @@ import { ProjectDeviceRowSyncProjectCommandRepository } from "../src/db/reposito
import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/repositories/project-device-structure-project-command.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
import { ExternalCsvConfigurationProjectCommandRepository } from "../src/db/repositories/external-csv-configuration-project-command.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
@@ -42,6 +43,7 @@ import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { floors } from "../src/db/schema/floors.js";
import { rooms } from "../src/db/schema/rooms.js";
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
import { createCircuitProtectionUpdateProjectCommand } from "../src/domain/models/circuit-protection-project-command.model.js";
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
@@ -85,6 +87,9 @@ import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/p
import { createProjectDeviceUpdateProjectCommand } from "../src/domain/models/project-device-project-command.model.js";
import { createProjectDeviceInsertProjectCommand } from "../src/domain/models/project-device-structure-project-command.model.js";
import { createProjectSettingsUpdateProjectCommand } from "../src/domain/models/project-settings-project-command.model.js";
import { createExternalCsvConfigurationUpdateProjectCommand } from "../src/domain/models/external-csv-configuration-project-command.model.js";
import { createDefaultExternalCsvConfiguration } from "../src/external-model/csv/external-csv-contracts.js";
import { externalCsvTestColumns } from "./fixtures/revit-csv-fixtures.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext {
@@ -157,7 +162,8 @@ function createService(context: DatabaseContext) {
new CircuitGroupMoveProjectCommandRepository(context.db),
new CircuitGroupSubtreeProjectCommandRepository(context.db),
new CircuitProtectionProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db)
new ProjectHistoryRepository(context.db),
new ExternalCsvConfigurationProjectCommandRepository(context.db)
);
}
@@ -1114,6 +1120,36 @@ describe("project command service", () => {
}
});
it("dispatches external CSV configuration updates through persistent history", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const target = {
id: "external-config-1",
projectId: "project-1",
configurationVersion: 1,
configuration: createDefaultExternalCsvConfiguration(externalCsvTestColumns),
};
const updated = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createExternalCsvConfigurationUpdateProjectCommand(null, target),
});
assert.equal(updated.history.currentRevision, 1);
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1);
const undone = service.undo({ projectId: "project-1", expectedRevision: 1 });
assert.equal(undone.history.currentRevision, 2);
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 0);
const redone = service.redo({ projectId: "project-1", expectedRevision: 2 });
assert.equal(redone.history.currentRevision, 3);
assert.equal(context.db.select().from(externalCsvConfigurations).all().length, 1);
} finally {
context.close();
}
});
it("dispatches distribution-board setup and its persisted inverse", () => {
const context = createTestDatabase();
try {
+14
View File
@@ -31,6 +31,7 @@ function minimalSnapshot() {
],
},
distributionBoards: [],
externalCsvConfiguration: null,
circuitLists: [],
circuitSections: [],
distributionBoardComponents: [],
@@ -147,11 +148,24 @@ describe("project state snapshot model", () => {
const baseline = structuredClone(minimalSnapshot());
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
const upgraded = parseProjectStateSnapshot(baseline);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.project.isPublicBuilding, false);
assert.equal(upgraded.externalCsvConfiguration, null);
});
it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
const previous = structuredClone(minimalSnapshot());
previous.schemaVersion = 2 as typeof previous.schemaVersion;
delete (previous as Record<string, unknown>).externalCsvConfiguration;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.equal(upgraded.externalCsvConfiguration, null);
});
it("rejects malformed baseline and unknown snapshot shapes", () => {