Persist Revit CSV configuration
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user