Add external object new row command

This commit is contained in:
2026-08-02 18:44:46 +02:00
parent 1a7dd7fe03
commit 347b3717c3
10 changed files with 649 additions and 9 deletions
+5
View File
@@ -294,6 +294,11 @@ the manual quantity, validates the materialized total against every target
link, rejects project/board/category/room/selection-marker mixing and requires link, rejects project/board/category/room/selection-marker mixing and requires
explicitly listed confirmations for other planning-value conflicts. Its exact explicitly listed confirmations for other planning-value conflicts. Its exact
inverse supports persistent Undo/Redo, and detach never deletes the row. inverse supports persistent Undo/Redo, and detach never deletes the row.
`external-object.assign-to-new-row` atomically inserts unassigned external
objects as one external-only row (`manualQuantity = 0`) into an existing
circuit. Its history-only inverse removes the row only while its complete
snapshot and exact object-link set remain unchanged. Ordinary detach continues
to preserve the row.
Confirmed initial state is written only through Confirmed initial state is written only through
`external-import.apply-initial`. The command rechecks configuration version, `external-import.apply-initial`. The command rechecks configuration version,
original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit original-byte SHA-256, parsed matrix, complete IFCGUID/source values, explicit
@@ -10,9 +10,10 @@
through the project page with persistent Undo/Redo. Automatic snapshots are through the project page with persistent Undo/Redo. Automatic snapshots are
created every 25 revisions and only their newest 12 entries are retained. created every 25 revisions and only their newest 12 entries are retained.
- The Revit/CSV foundation and confirmed initial import are implemented. - The Revit/CSV foundation and confirmed initial import are implemented.
Existing-row assignment, move and detach have an atomic persistent command; Existing-row assignment, move and detach and creation of an external-only row
their editor UI, new-row/new-circuit paths, follow-up import, conflict review in an existing circuit have atomic persistent commands. Their editor UI, the
and return export are pending. new-circuit path, follow-up import, conflict review and return export are
pending.
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime. - Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
- The global device library supports basic CRUD and copy operations, but has no - The global device library supports basic CRUD and copy operations, but has no
versioning, permissions or controlled synchronization model. versioning, permissions or controlled synchronization model.
+8
View File
@@ -551,6 +551,14 @@ still zusammengeführt. Alle betroffenen materialisierten Mengen werden aus dem
unveränderten manuellen Anteil und sämtlichen Ziel-Objektlinks validiert. Die unveränderten manuellen Anteil und sämtlichen Ziel-Objektlinks validiert. Die
Gegenrichtung enthält dieselben vollständigen Snapshots und funktioniert nach Gegenrichtung enthält dieselben vollständigen Snapshots und funktioniert nach
Neustart; beim Lösen des letzten Objekts bleibt die Row bestehen. Neustart; beim Lösen des letzten Objekts bleibt die Row bestehen.
`external-object.assign-to-new-row` fügt unzugeordnete Objekte atomar als neue
Gerätezeile in einen vorhandenen Stromkreis ein. Der manuelle Anteil dieser Row
ist null; Gesamtmenge, Raum, Verteilung, Kategorie, Selektionskennzeichen und
Planungswerte werden vor dem Schreiben geprüft. Undo löst exakt diese Objekte
und löscht die erzeugte Row nur, wenn sie und ihre Linkmenge unverändert sind.
Der Löschbefehl ist ausschließlich für die Projekthistorie zugelassen; eine
normale spätere Trennung lässt die Row gemäß der allgemeinen Zuordnungsregel
bestehen.
`GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder `GET` und `PUT /api/projects/:projectId/external-csv/configuration` lesen oder
ändern die Konfiguration; der PUT plant Identität und nächsten ändern die Konfiguration; der PUT plant Identität und nächsten
Konfigurationsstand serverseitig und verwendet den typisierten Command. Konfigurationsstand serverseitig und verwendet den typisierten Command.
@@ -325,7 +325,12 @@ Kombination aus Originalbytes und Matrix als Entscheidungen bestätigt.
Planungsabweichungen eine explizite Objektliste und aktualisiert Links und Planungsabweichungen eine explizite Objektliste und aktualisiert Links und
Gesamtmengen in einer SQLite-Transaktion. Seine exakte Gegenrichtung ist Gesamtmengen in einer SQLite-Transaktion. Seine exakte Gegenrichtung ist
dauerhaft Undo/Redo-fähig. Beim Lösen bleibt die Row mit ihrem manuellen dauerhaft Undo/Redo-fähig. Beim Lösen bleibt die Row mit ihrem manuellen
Mengenanteil bestehen. Neue Row und neuer Circuit folgen separat. Mengenanteil bestehen. `external-object.assign-to-new-row` ergänzt die
Zuordnung ungebundener Objekte als neue Row in einem vorhandenen Circuit.
Diese Row erhält eine stabile ID und `manualQuantity = 0`; ihr historischer
Gegenbefehl entfernt sie nur, solange Row und Objektlinks vollständig
unverändert sind. Ein direktes Löschen über diesen internen Gegenbefehl ist
als Nutzeraktion gesperrt. Der neue Circuit folgt separat.
3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen. 3. Verteilungsbezogenen Drawer, Filter und Vorschau der Mengenwirkung ergänzen.
4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload 4. Einzel- und Mehrfach-Drag-and-drop samt Warnungen, Undo/Redo und Reload
testen. testen.
@@ -0,0 +1,266 @@
import { and, eq, inArray, isNull } from "drizzle-orm";
import { assertCircuitDeviceRowQuantity } from "../../domain/calculations/circuit-device-row-quantity.js";
import {
assertExternalObjectNewRowProjectCommand,
externalObjectAssignToNewRowCommandType,
externalObjectDeleteCreatedRowCommandType,
invertExternalObjectNewRowProjectCommand,
type ExternalObjectNewRowProjectCommand,
} from "../../domain/models/external-object-new-row-project-command.model.js";
import type { CircuitDeviceRowSnapshot } from "../../domain/models/circuit-device-row-structure-project-command.model.js";
import type { ExternalObjectNewRowProjectCommandStore } from "../../domain/ports/external-object-new-row-project-command.store.js";
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import {
assertCircuitDeviceRowReferencesInProject,
toCircuitDeviceRowInsertValues,
toCircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
export class ExternalObjectNewRowProjectCommandRepository
implements ExternalObjectNewRowProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: Parameters<ExternalObjectNewRowProjectCommandStore["execute"]>[0]) {
assertExternalObjectNewRowProjectCommand(input.command);
if (
input.source === "user" &&
input.command.type === externalObjectDeleteCreatedRowCommandType
) {
throw new Error("Created external rows may only be removed through project history.");
}
return executeProjectCommandTransaction(this.database, input, (tx) => {
if (input.command.type === externalObjectAssignToNewRowCommandType) {
this.insert(tx, input.projectId, input.command);
} else {
this.remove(tx, input.projectId, input.command);
}
return invertExternalObjectNewRowProjectCommand(input.command);
});
}
private insert(
database: AppDatabase,
projectId: string,
command: ExternalObjectNewRowProjectCommand
) {
const row = command.payload.row;
const context = this.loadCircuitContext(database, projectId, row.circuitId);
if (!isElectricalPhaseType(row.phaseType)) {
throw new Error("External device-row phase type is invalid.");
}
if (row.category !== context.category) {
throw new Error("External device-row category does not match target circuit group.");
}
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
if (database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, row.id)).get()) {
throw new Error("External device-row id already exists.");
}
const objects = this.loadExpectedObjects(database, projectId, command);
this.assertTargetCompatibility(
database,
row,
context.distributionBoardId,
context.category,
objects.map((object) => object.target),
new Set(command.payload.confirmedConflictObjectIds)
);
assertCircuitDeviceRowQuantity({
quantity: row.quantity,
manualQuantity: 0,
externalObjects: objects.map(({ target }) => ({
effectiveQuantity: target.planningValues.effectiveQuantity,
})),
});
database.insert(circuitDeviceRows).values(toCircuitDeviceRowInsertValues(row)).run();
this.updateCircuitState(database, projectId, row.circuitId);
this.applyObjectLinks(database, objects);
}
private remove(
database: AppDatabase,
projectId: string,
command: ExternalObjectNewRowProjectCommand
) {
const expectedRow = command.payload.row;
this.loadCircuitContext(database, projectId, expectedRow.circuitId);
const currentRow = database.select().from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, expectedRow.id)).get();
if (!currentRow || !same(toCircuitDeviceRowSnapshot(currentRow), expectedRow)) {
throw new Error("Created external device row changed before history removal.");
}
const objects = this.loadExpectedObjects(database, projectId, command);
const linkedObjects = database.select({ id: externalModelObjects.id })
.from(externalModelObjects)
.where(eq(externalModelObjects.circuitDeviceRowId, expectedRow.id)).all();
if (
linkedObjects.length !== objects.length ||
linkedObjects.some(({ id }) => !objects.some((object) => object.expected.id === id))
) {
throw new Error("Created external device row has different object links.");
}
this.applyObjectLinks(database, objects);
const deleted = database.delete(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, expectedRow.id)).run();
if (deleted.changes !== 1) {
throw new Error("Created external device row changed during history removal.");
}
this.updateCircuitState(database, projectId, expectedRow.circuitId);
}
private loadCircuitContext(database: AppDatabase, projectId: string, circuitId: string) {
const context = database.select({
projectId: circuitLists.projectId,
distributionBoardId: circuitLists.distributionBoardId,
category: circuitSections.category,
}).from(circuits)
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.innerJoin(circuitSections, eq(circuitSections.id, circuits.sectionId))
.where(eq(circuits.id, circuitId)).get();
if (!context || context.projectId !== projectId || context.category === null) {
throw new Error("Target circuit does not belong to the project.");
}
return {
projectId: context.projectId,
distributionBoardId: context.distributionBoardId,
category: context.category,
};
}
private loadExpectedObjects(
database: AppDatabase,
projectId: string,
command: ExternalObjectNewRowProjectCommand
) {
const transitions = new Map(
command.payload.objects.map((transition) => [transition.expected.id, transition])
);
const current = database.select().from(externalModelObjects)
.where(inArray(externalModelObjects.id, [...transitions.keys()])).all();
if (current.length !== transitions.size) {
throw new Error("One or more external objects no longer exist.");
}
for (const object of current) {
const expected = transitions.get(object.id)!.expected;
if (object.projectId !== projectId || !same(toExternalObjectSnapshot(object), expected)) {
throw new Error("External object changed before new-row assignment.");
}
}
return command.payload.objects;
}
private assertTargetCompatibility(
database: AppDatabase,
row: CircuitDeviceRowSnapshot,
distributionBoardId: string,
category: string,
objects: ExternalModelObjectSnapshot[],
confirmedConflicts: Set<string>
) {
const roomMappings = database.select().from(externalRoomMappings)
.where(eq(externalRoomMappings.projectId, objects[0]!.projectId)).all();
const roomIdByMappingId = new Map(roomMappings.map((mapping) => [mapping.id, mapping.roomId]));
const selectionMarkers = new Set(
objects.map((object) => object.acceptedSourceValues.selectionMarker.trim())
);
if (selectionMarkers.size > 1) {
throw new Error("External objects with different selection markers require separate rows.");
}
for (const object of objects) {
if (object.distributionBoardId !== distributionBoardId) {
throw new Error("External object distribution does not match target circuit.");
}
if (object.planningValues.category !== category) {
throw new Error("External object category does not match target circuit group.");
}
const objectRoomId = object.externalRoomMappingId === null
? null
: roomIdByMappingId.get(object.externalRoomMappingId) ?? null;
if (objectRoomId !== row.roomId) {
throw new Error("External object room does not match the new device row.");
}
if (!matchesRowPlanningValues(row, object) && !confirmedConflicts.has(object.id)) {
throw new Error("External object planning values require explicit conflict confirmation.");
}
}
}
private applyObjectLinks(
database: AppDatabase,
objects: ExternalObjectNewRowProjectCommand["payload"]["objects"]
) {
for (const { expected, target } of objects) {
const result = database.update(externalModelObjects)
.set({ circuitDeviceRowId: target.circuitDeviceRowId })
.where(and(
eq(externalModelObjects.id, expected.id),
expected.circuitDeviceRowId === null
? isNull(externalModelObjects.circuitDeviceRowId)
: eq(externalModelObjects.circuitDeviceRowId, expected.circuitDeviceRowId)
)).run();
if (result.changes !== 1) {
throw new Error("External object changed during new-row assignment.");
}
}
}
private updateCircuitState(database: AppDatabase, projectId: string, circuitId: string) {
const remaining = database.select({ id: circuitDeviceRows.id }).from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuitId)).limit(1).get();
const result = database.update(circuits).set({ isReserve: remaining ? 0 : 1 })
.where(eq(circuits.id, circuitId)).run();
if (result.changes !== 1) throw new Error("Target circuit changed during row assignment.");
updateDerivedCircuitVoltage(database, projectId, circuitId);
}
}
function matchesRowPlanningValues(
row: CircuitDeviceRowSnapshot,
object: ExternalModelObjectSnapshot
) {
const planning = object.planningValues;
return (
row.displayName === (planning.displayName ?? row.displayName) &&
row.linkedProjectDeviceId === object.linkedProjectDeviceId &&
row.category === planning.category &&
row.connectionKind === planning.connectionKind &&
(planning.powerPerUnitW === null || row.powerPerUnit === planning.powerPerUnitW / 1000) &&
row.simultaneityFactor === planning.simultaneityFactor &&
row.cosPhi === planning.cosPhi &&
row.costGroup === planning.costGroup &&
row.remark === planning.remark
);
}
function toExternalObjectSnapshot(
object: typeof externalModelObjects.$inferSelect
): ExternalModelObjectSnapshot {
return { ...object };
}
function same(left: unknown, right: unknown) {
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);
}
@@ -0,0 +1,163 @@
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
import {
assertCircuitDeviceRowInsertProjectCommand,
circuitDeviceRowInsertCommandType,
circuitDeviceRowStructureCommandSchemaVersion,
type CircuitDeviceRowSnapshot,
} from "./circuit-device-row-structure-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
export const externalObjectAssignToNewRowCommandType =
"external-object.assign-to-new-row" as const;
export const externalObjectDeleteCreatedRowCommandType =
"external-object.unassign-and-delete-created-row" as const;
export const externalObjectNewRowCommandSchemaVersion = 1 as const;
export interface ExternalObjectNewRowPayload {
row: CircuitDeviceRowSnapshot;
objects: Array<{
expected: ExternalModelObjectSnapshot;
target: ExternalModelObjectSnapshot;
}>;
confirmedConflictObjectIds: string[];
}
export interface ExternalObjectNewRowProjectCommand
extends SerializedProjectCommand<ExternalObjectNewRowPayload> {
schemaVersion: typeof externalObjectNewRowCommandSchemaVersion;
type:
| typeof externalObjectAssignToNewRowCommandType
| typeof externalObjectDeleteCreatedRowCommandType;
}
export function createExternalObjectNewRowProjectCommand(
type: ExternalObjectNewRowProjectCommand["type"],
payload: ExternalObjectNewRowPayload
): ExternalObjectNewRowProjectCommand {
const command: ExternalObjectNewRowProjectCommand = {
schemaVersion: externalObjectNewRowCommandSchemaVersion,
type,
payload: {
row: {
...payload.row,
manualQuantity: payload.row.manualQuantity ?? payload.row.quantity,
},
objects: payload.objects,
confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds],
},
};
assertExternalObjectNewRowProjectCommand(command);
return command;
}
export function invertExternalObjectNewRowProjectCommand(
command: ExternalObjectNewRowProjectCommand
) {
return createExternalObjectNewRowProjectCommand(
command.type === externalObjectAssignToNewRowCommandType
? externalObjectDeleteCreatedRowCommandType
: externalObjectAssignToNewRowCommandType,
{
row: command.payload.row,
objects: command.payload.objects.map(({ expected, target }) => ({
expected: target,
target: expected,
})),
confirmedConflictObjectIds: command.payload.confirmedConflictObjectIds,
}
);
}
export function assertExternalObjectNewRowProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ExternalObjectNewRowProjectCommand {
if (
command.schemaVersion !== externalObjectNewRowCommandSchemaVersion ||
(command.type !== externalObjectAssignToNewRowCommandType &&
command.type !== externalObjectDeleteCreatedRowCommandType) ||
!isRecord(command.payload) ||
!Array.isArray(command.payload.objects) ||
command.payload.objects.length === 0 ||
!Array.isArray(command.payload.confirmedConflictObjectIds)
) {
throw new Error("Unsupported external object new-row command.");
}
const row = parseRow(command.payload.row);
if (row.manualQuantity !== 0) {
throw new Error("An external-only device row must start with zero manual quantity.");
}
const objectIds = new Set<string>();
for (const transition of command.payload.objects) {
if (!isRecord(transition)) throw new Error("Invalid external object transition.");
const expected = parseObject(transition.expected);
const target = parseObject(transition.target);
if (expected.id !== target.id || objectIds.has(expected.id)) {
throw new Error("New-row assignment contains mismatched or duplicate objects.");
}
objectIds.add(expected.id);
const assigning = command.type === externalObjectAssignToNewRowCommandType;
if (
(assigning && (expected.circuitDeviceRowId !== null || target.circuitDeviceRowId !== row.id)) ||
(!assigning && (expected.circuitDeviceRowId !== row.id || target.circuitDeviceRowId !== null))
) {
throw new Error("External object links do not match the new-row action.");
}
assertOnlyAssignmentChanged(expected, target);
}
const confirmedIds = new Set<string>();
for (const objectId of command.payload.confirmedConflictObjectIds) {
if (typeof objectId !== "string" || !objectIds.has(objectId) || confirmedIds.has(objectId)) {
throw new Error("New-row assignment contains an invalid conflict confirmation.");
}
confirmedIds.add(objectId);
}
}
function parseRow(value: unknown): CircuitDeviceRowSnapshot {
const envelope = {
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
type: circuitDeviceRowInsertCommandType,
payload: { row: value },
};
assertCircuitDeviceRowInsertProjectCommand(envelope);
const row = envelope.payload.row;
if (row.manualQuantity === undefined) {
throw new Error("External object new-row command requires manual quantity.");
}
return row;
}
function parseObject(value: unknown): ExternalModelObjectSnapshot {
return parseExternalModelStateSnapshot({
source: null,
importBatches: [],
roomMappings: [],
objects: [value],
}).objects[0]!;
}
function assertOnlyAssignmentChanged(
expected: ExternalModelObjectSnapshot,
target: ExternalModelObjectSnapshot
) {
const expectedRest = { ...expected, circuitDeviceRowId: null };
const targetRest = { ...target, circuitDeviceRowId: null };
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
throw new Error("New-row assignment may only change the external object row link.");
}
}
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (isRecord(value)) {
return `{${Object.keys(value).sort().map((key) =>
`${JSON.stringify(key)}:${canonicalJson(value[key])}`
).join(",")}}`;
}
return JSON.stringify(value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,19 @@
import type { ExternalObjectNewRowProjectCommand } from "../models/external-object-new-row-project-command.model.js";
import type { AppendedProjectRevision, ProjectRevisionSource } from "./project-revision.store.js";
export interface ExecuteExternalObjectNewRowCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: ExternalObjectNewRowProjectCommand;
}
export interface ExternalObjectNewRowProjectCommandStore {
execute(input: ExecuteExternalObjectNewRowCommandInput): {
revision: AppendedProjectRevision;
inverse: ExternalObjectNewRowProjectCommand;
};
}
+19 -1
View File
@@ -176,6 +176,12 @@ import {
externalObjectRowAssignmentCommandType, externalObjectRowAssignmentCommandType,
} from "../models/external-object-row-assignment-project-command.model.js"; } from "../models/external-object-row-assignment-project-command.model.js";
import type { ExternalObjectRowAssignmentProjectCommandStore } from "../ports/external-object-row-assignment-project-command.store.js"; import type { ExternalObjectRowAssignmentProjectCommandStore } from "../ports/external-object-row-assignment-project-command.store.js";
import {
assertExternalObjectNewRowProjectCommand,
externalObjectAssignToNewRowCommandType,
externalObjectDeleteCreatedRowCommandType,
} from "../models/external-object-new-row-project-command.model.js";
import type { ExternalObjectNewRowProjectCommandStore } from "../ports/external-object-new-row-project-command.store.js";
interface DispatchProjectCommandInput { interface DispatchProjectCommandInput {
projectId: string; projectId: string;
@@ -213,7 +219,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly historyStore: ProjectHistoryStore, private readonly historyStore: ProjectHistoryStore,
private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore, private readonly externalCsvConfigurationStore?: ExternalCsvConfigurationProjectCommandStore,
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore
) {} ) {}
executeUser( executeUser(
@@ -600,6 +607,17 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case externalObjectAssignToNewRowCommandType:
case externalObjectDeleteCreatedRowCommandType: {
assertExternalObjectNewRowProjectCommand(input.command);
if (!this.externalObjectNewRowStore) {
throw new Error("External object new-row store is not available.");
}
return this.externalObjectNewRowStore.execute({
...input,
command: input.command,
}).revision;
}
case projectStateRestoreCommandType: { case projectStateRestoreCommandType: {
assertProjectStateRestoreCommand(input.command); assertProjectStateRestoreCommand(input.command);
return this.projectStateRestoreStore.execute({ return this.projectStateRestoreStore.execute({
@@ -25,6 +25,7 @@ import { ProjectCommandService } from "../../domain/services/project-command.ser
import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-configuration-project-command.repository.js"; import { ExternalCsvConfigurationProjectCommandRepository } from "../../db/repositories/external-csv-configuration-project-command.repository.js";
import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js"; import { ExternalInitialImportProjectCommandRepository } from "../../db/repositories/external-initial-import-project-command.repository.js";
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js"; import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
export const circuitDeviceRowProjectCommandStore = export const circuitDeviceRowProjectCommandStore =
@@ -74,6 +75,8 @@ export const externalInitialImportProjectCommandStore =
new ExternalInitialImportProjectCommandRepository(db); new ExternalInitialImportProjectCommandRepository(db);
export const externalObjectRowAssignmentProjectCommandStore = export const externalObjectRowAssignmentProjectCommandStore =
new ExternalObjectRowAssignmentProjectCommandRepository(db); new ExternalObjectRowAssignmentProjectCommandRepository(db);
export const externalObjectNewRowProjectCommandStore =
new ExternalObjectNewRowProjectCommandRepository(db);
export const projectCommandService = new ProjectCommandService( export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore, circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore, circuitDeviceRowProjectCommandStore,
@@ -99,5 +102,6 @@ export const projectCommandService = new ProjectCommandService(
projectHistoryStore, projectHistoryStore,
externalCsvConfigurationProjectCommandStore, externalCsvConfigurationProjectCommandStore,
externalInitialImportProjectCommandStore, externalInitialImportProjectCommandStore,
externalObjectRowAssignmentProjectCommandStore externalObjectRowAssignmentProjectCommandStore,
externalObjectNewRowProjectCommandStore
); );
@@ -5,6 +5,7 @@ import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext, type AppDatabase } from "../src/db/database-context.js"; import { createDatabaseContext, type AppDatabase } from "../src/db/database-context.js";
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js"; import { ExternalObjectRowAssignmentProjectCommandRepository } from "../src/db/repositories/external-object-row-assignment-project-command.repository.js";
import { ExternalObjectNewRowProjectCommandRepository } from "../src/db/repositories/external-object-new-row-project-command.repository.js";
import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js"; import { toCircuitDeviceRowSnapshot } from "../src/db/repositories/circuit-device-row-structure.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js"; import { circuitLists } from "../src/db/schema/circuit-lists.js";
@@ -19,10 +20,14 @@ import { floors } from "../src/db/schema/floors.js";
import { projects } from "../src/db/schema/projects.js"; import { projects } from "../src/db/schema/projects.js";
import { rooms } from "../src/db/schema/rooms.js"; import { rooms } from "../src/db/schema/rooms.js";
import { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js"; import { createExternalObjectRowAssignmentProjectCommand } from "../src/domain/models/external-object-row-assignment-project-command.model.js";
import {
createExternalObjectNewRowProjectCommand,
externalObjectAssignToNewRowCommandType,
} from "../src/domain/models/external-object-new-row-project-command.model.js";
import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js"; import type { ExternalModelObjectSnapshot } from "../src/external-model/domain/external-model-contracts.js";
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js"; import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
function createFixture() { function createFixture(initialObjectRowId: string | null = "row-1") {
const context = createDatabaseContext(":memory:"); const context = createDatabaseContext(":memory:");
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") }); migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
const database = context.db; const database = context.db;
@@ -46,7 +51,7 @@ function createFixture() {
roomId: "room-1", roomId: "room-1",
roomNumberSnapshot: "101", roomNumberSnapshot: "101",
roomNameSnapshot: "Büro", roomNameSnapshot: "Büro",
quantity: index === 1 ? 3 : 0, quantity: index === 1 ? (initialObjectRowId === "row-1" ? 3 : 1) : 0,
manualQuantity: index === 1 ? 1 : 0, manualQuantity: index === 1 ? 1 : 0,
powerPerUnit: 0.12, powerPerUnit: 0.12,
simultaneityFactor: 1, simultaneityFactor: 1,
@@ -78,7 +83,7 @@ function createFixture() {
roomId: "room-1", roomId: "room-1",
defaultDistributionBoardId: "board-1", defaultDistributionBoardId: "board-1",
}).run(); }).run();
database.insert(externalModelObjects).values(objectSnapshot("row-1")).run(); database.insert(externalModelObjects).values(objectSnapshot(initialObjectRowId)).run();
return context; return context;
} }
@@ -225,3 +230,149 @@ describe("external object row assignment project command", () => {
} }
}); });
}); });
describe("external object new-row project command", () => {
it("creates an external-only row and supports exact persisted undo and redo", () => {
const context = createFixture(null);
try {
const base = row(context.db, "row-2");
const newRow = {
...base,
id: "row-new",
quantity: 2,
manualQuantity: 0,
sortOrder: 20,
};
const object = objectSnapshot(null);
const command = createExternalObjectNewRowProjectCommand(
externalObjectAssignToNewRowCommandType,
{
row: newRow,
objects: [{
expected: object,
target: { ...object, circuitDeviceRowId: "row-new" },
}],
confirmedConflictObjectIds: [],
}
);
const repository = new ExternalObjectNewRowProjectCommandRepository(context.db);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.equal(row(context.db, "row-new").manualQuantity, 0);
assert.equal(row(context.db, "row-new").quantity, 2);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-new");
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: inserted.inverse,
}),
/only be removed through project history/
);
const undone = repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
});
assert.equal(
context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-new")).get(),
undefined
);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: undone.inverse,
});
assert.equal(row(context.db, "row-new").quantity, 2);
} finally {
context.close();
}
});
it("rolls back the row and object link when history persistence fails", () => {
const context = createFixture(null);
try {
context.sqlite.exec(`
CREATE TRIGGER fail_new_row_history
BEFORE INSERT ON project_change_sets
BEGIN SELECT RAISE(ABORT, 'late history failure'); END;
`);
const base = row(context.db, "row-2");
const object = objectSnapshot(null);
const command = createExternalObjectNewRowProjectCommand(
externalObjectAssignToNewRowCommandType,
{
row: { ...base, id: "row-new", quantity: 2, manualQuantity: 0, sortOrder: 20 },
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" } }],
confirmedConflictObjectIds: [],
}
);
assert.throws(
() => new ExternalObjectNewRowProjectCommandRepository(context.db).execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
}),
/late history failure/
);
assert.equal(
context.db.select().from(circuitDeviceRows).where(eq(circuitDeviceRows.id, "row-new")).get(),
undefined
);
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, null);
} finally {
context.close();
}
});
it("keeps the created row when it changed after assignment", () => {
const context = createFixture(null);
try {
const base = row(context.db, "row-2");
const object = objectSnapshot(null);
const repository = new ExternalObjectNewRowProjectCommandRepository(context.db);
const inserted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createExternalObjectNewRowProjectCommand(
externalObjectAssignToNewRowCommandType,
{
row: { ...base, id: "row-new", quantity: 2, manualQuantity: 0, sortOrder: 20 },
objects: [{ expected: object, target: { ...object, circuitDeviceRowId: "row-new" } }],
confirmedConflictObjectIds: [],
}
),
});
context.db.update(circuitDeviceRows).set({ displayName: "Geändert" })
.where(eq(circuitDeviceRows.id, "row-new")).run();
assert.throws(
() => repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: inserted.revision.changeSetId,
command: inserted.inverse,
}),
/changed before history removal/
);
assert.equal(row(context.db, "row-new").displayName, "Geändert");
assert.equal(context.db.select().from(externalModelObjects).get()!.circuitDeviceRowId, "row-new");
} finally {
context.close();
}
});
});