Persist Revit CSV configuration

This commit is contained in:
Julian Appel 2026-08-02 16:33:18 +02:00
parent da689af518
commit 219da5ec3b
22 changed files with 2508 additions and 27 deletions

View file

@ -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

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
}
]
}

View file

@ -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);
}

View file

@ -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);

View file

@ -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,

View file

@ -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);

View file

@ -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
),
]
);

View file

@ -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);
}

View file

@ -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"

View file

@ -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),

View file

@ -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;
};
}

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({

View file

@ -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.`);
}
}

View file

@ -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
);