Persist project device synchronization

This commit is contained in:
2026-07-24 11:47:03 +02:00
parent e930cb75b8
commit 3977a6e6e1
20 changed files with 300 additions and 747 deletions
+2 -1
View File
@@ -240,7 +240,8 @@ ProjectDevice insertion/deletion preserves stable device ids. Deletion captures
complete disconnected snapshots of linked rows so undo can restore only rows complete disconnected snapshots of linked rows so undo can restore only rows
that have remained unchanged. ProjectDevice create, update, delete and that have remained unchanged. ProjectDevice create, update, delete and
global-to-project copy API/UI paths use these persistent commands and track the global-to-project copy API/UI paths use these persistent commands and track the
returned project revision. returned project revision. ProjectDevice synchronization/disconnect API and UI
paths do the same; their undo action uses the project-wide history endpoint.
Required operations: Required operations:
+8 -4
View File
@@ -277,12 +277,16 @@ endpoints above.
- returns all linked circuit device rows with distribution-board/circuit context and field differences - returns all linked circuit device rows with distribution-board/circuit context and field differences
- `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize` - `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize`
- applies only explicitly selected fields to explicitly selected linked rows - applies only explicitly selected fields to explicitly selected linked rows
- `POST /project-devices/projects/:projectId/:projectDeviceId/restore` - request: selected `rowIds`, selected `fields` and `expectedRevision`
- restores the captured pre-sync values for session-local undo - response: updated preview, revision and project history state
- `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect` - `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect`
- disconnects explicitly selected rows without changing their local values - disconnects explicitly selected rows without changing their local values
- `POST /project-devices/projects/:projectId/:projectDeviceId/reconnect` - request: selected `rowIds` and `expectedRevision`
- restores a disconnected link if the row has not been linked elsewhere meanwhile - response: updated preview, revision and project history state
Both writes execute `project-device.sync-rows`. Undo uses the project-wide
`POST /projects/:projectId/history/undo` endpoint; reconnect is the validated
inverse command. The former direct restore/reconnect endpoints are removed.
`displayName` is included in the comparison but is not selected by default in the UI. Updating a `displayName` is included in the comparison but is not selected by default in the UI. Updating a
project device never triggers synchronization implicitly. project device never triggers synchronization implicitly.
+5 -3
View File
@@ -108,9 +108,11 @@ Verknüpfen als atomaren Mehrzeilen-Command. Jede betroffene Zeile enthält den
vollständigen erwarteten und neuen Stand aller synchronisierbaren Felder, vollständigen erwarteten und neuen Stand aller synchronisierbaren Felder,
einschließlich ProjectDevice-Verknüpfung und lokaler Override-Metadaten. Damit einschließlich ProjectDevice-Verknüpfung und lokaler Override-Metadaten. Damit
werden stille Überschreibungen veralteter Zeilen verhindert und Undo/Redo stellt werden stille Überschreibungen veralteter Zeilen verhindert und Undo/Redo stellt
exakt die vorherigen lokalen Werte wieder her. Die bestehenden exakt die vorherigen lokalen Werte wieder her. Die Synchronisieren- und
Projektgeräte-Endpunkte verwenden diesen Command bis zur Frontend-Umstellung Trennen-Endpunkte sowie die Projektseite verwenden diesen Command mit
noch nicht. optimistischer Revisionsprüfung. Das dortige Rückgängig löst die persistente
projektweite Historie aus; separate direkte Restore-/Reconnect-Schreibwege
existieren nicht mehr.
`project-device.update` versioniert Änderungen an den kanonischen `project-device.update` versioniert Änderungen an den kanonischen
Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem
gespeicherten Gerät und schreibt Geräteänderung, Revision und Historienstapel gespeicherten Gerät und schreibt Geräteänderung, Revision und Historienstapel
@@ -211,8 +211,9 @@ Remaining constraints before completing persistent history:
- route the remaining structural and destructive domain - route the remaining structural and destructive domain
writes through the shared command, revision and stack transaction boundary writes through the shared command, revision and stack transaction boundary
- replace the session-local frontend command stack only after server-side - replace the session-local frontend command stack only after server-side
command coverage is complete for structural and destructive operations; then command coverage is complete for structural and destructive circuit-editor
route the existing synchronization UI through its persistent command operations; the ProjectDevice synchronization UI already uses its persistent
command and project-wide undo endpoint
- do not model IFCGUID as an overloaded circuit equipment identifier - do not model IFCGUID as an overloaded circuit equipment identifier
- keep database backup/restore checks separate from project history tests - keep database backup/restore checks separate from project history tests
+7 -4
View File
@@ -415,16 +415,19 @@ Implemented foundation:
previously linked rows only from complete unchanged disconnected snapshots previously linked rows only from complete unchanged disconnected snapshots
- ProjectDevice create, update, delete and global-to-project copy endpoints use - ProjectDevice create, update, delete and global-to-project copy endpoints use
these commands, require `expectedRevision` and return the new history state these commands, require `expectedRevision` and return the new history state
- ProjectDevice synchronization and disconnect endpoints also require
`expectedRevision`; their UI undo uses project-wide persistent history and
the obsolete direct restore/reconnect write paths are removed
- revision metadata, change-set payloads and the project counter are committed - revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction in one SQLite transaction
- a stale expected revision produces no history writes - a stale expected revision produces no history writes
- integration coverage verifies sequential revisions, stale-command rejection - integration coverage verifies sequential revisions, stale-command rejection
and rollback after a forced late persistence failure and rollback after a forced late persistence failure
ProjectDevice CRUD on the project page is connected to this history boundary. ProjectDevice CRUD, synchronization and disconnect on the project page are
The circuit-list editor and the remaining domain writes are not fully connected; connected to this history boundary. The circuit-list editor and the remaining
its visible undo/redo therefore remains session-local until the remaining tasks domain writes are not fully connected; its visible undo/redo therefore remains
are implemented. session-local until the remaining tasks are implemented.
Acceptance criteria: Acceptance criteria:
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"start": "node dist/server/index.js", "start": "node dist/server/index.js",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
+51 -28
View File
@@ -20,9 +20,8 @@ import {
listProjectDevices, listProjectDevices,
listProjects, listProjects,
listRooms, listRooms,
reconnectProjectDeviceRows,
restoreProjectDeviceRows,
synchronizeProjectDeviceRows, synchronizeProjectDeviceRows,
undoProjectCommand,
updateProjectDevice, updateProjectDevice,
updateProjectSettings, updateProjectSettings,
} from "../../../frontend/utils/api"; } from "../../../frontend/utils/api";
@@ -34,7 +33,6 @@ import type {
GlobalDeviceDto, GlobalDeviceDto,
ProjectDeviceDto, ProjectDeviceDto,
ProjectDeviceSyncPreviewDto, ProjectDeviceSyncPreviewDto,
ProjectDeviceSyncRestoreRowDto,
ProjectDto, ProjectDto,
RoomDto, RoomDto,
} from "../../../frontend/types"; } from "../../../frontend/types";
@@ -57,9 +55,10 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
remark: "Bemerkung", remark: "Bemerkung",
}; };
type ProjectDeviceUndoState = type ProjectDeviceUndoState = {
| { kind: "synchronize"; projectDeviceId: string; rows: ProjectDeviceSyncRestoreRowDto[] } projectDeviceId: string;
| { kind: "disconnect"; projectDeviceId: string; rowIds: string[] }; revisionNumber: number;
};
const emptyProjectDevice: CreateProjectDeviceInput = { const emptyProjectDevice: CreateProjectDeviceInput = {
name: "", name: "",
@@ -180,6 +179,7 @@ export default function ProjectDetailPage() {
setProject((current) => setProject((current) =>
current ? { ...current, currentRevision } : current current ? { ...current, currentRevision } : current
); );
setProjectDeviceUndo(null);
} }
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) { async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
@@ -409,7 +409,13 @@ export default function ProjectDetailPage() {
} }
async function handleSynchronizeProjectDeviceRows() { async function handleSynchronizeProjectDeviceRows() {
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0) { if (
!projectId ||
!project ||
!syncPreview ||
selectedSyncRowIds.length === 0 ||
selectedSyncFields.length === 0
) {
return; return;
} }
setIsSaving(true); setIsSaving(true);
@@ -419,16 +425,17 @@ export default function ProjectDetailPage() {
projectId, projectId,
syncPreview.projectDevice.id, syncPreview.projectDevice.id,
selectedSyncRowIds, selectedSyncRowIds,
selectedSyncFields selectedSyncFields,
project.currentRevision
); );
setSyncPreview(result.preview); setSyncPreview(result.preview);
applyProjectRevision(result.history.currentRevision);
setSelectedSyncRowIds( setSelectedSyncRowIds(
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId) result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
); );
setProjectDeviceUndo({ setProjectDeviceUndo({
kind: "synchronize",
projectDeviceId: syncPreview.projectDevice.id, projectDeviceId: syncPreview.projectDevice.id,
rows: result.undo.rows, revisionNumber: result.revision.revisionNumber,
}); });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden."); setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
@@ -438,7 +445,12 @@ export default function ProjectDetailPage() {
} }
async function handleDisconnectProjectDeviceRows() { async function handleDisconnectProjectDeviceRows() {
if (!projectId || !syncPreview || selectedSyncRowIds.length === 0) { if (
!projectId ||
!project ||
!syncPreview ||
selectedSyncRowIds.length === 0
) {
return; return;
} }
setIsSaving(true); setIsSaving(true);
@@ -447,13 +459,19 @@ export default function ProjectDetailPage() {
const result = await disconnectProjectDeviceRows( const result = await disconnectProjectDeviceRows(
projectId, projectId,
syncPreview.projectDevice.id, syncPreview.projectDevice.id,
selectedSyncRowIds selectedSyncRowIds,
project.currentRevision
); );
await openProjectDeviceSyncPreview(syncPreview.projectDevice.id); setSyncPreview(result.preview);
setSelectedSyncRowIds(
result.preview.rows
.filter((row) => row.differences.length > 0)
.map((row) => row.rowId)
);
applyProjectRevision(result.history.currentRevision);
setProjectDeviceUndo({ setProjectDeviceUndo({
kind: "disconnect",
projectDeviceId: syncPreview.projectDevice.id, projectDeviceId: syncPreview.projectDevice.id,
rowIds: result.undo.rowIds, revisionNumber: result.revision.revisionNumber,
}); });
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden."); setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
@@ -463,24 +481,29 @@ export default function ProjectDetailPage() {
} }
async function handleUndoProjectDeviceOperation() { async function handleUndoProjectDeviceOperation() {
if (!projectId || !projectDeviceUndo) { if (!projectId || !project || !projectDeviceUndo) {
return; return;
} }
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
try { try {
const preview = if (
projectDeviceUndo.kind === "synchronize" project.currentRevision !==
? await restoreProjectDeviceRows( projectDeviceUndo.revisionNumber
projectId, ) {
projectDeviceUndo.projectDeviceId, throw new Error(
projectDeviceUndo.rows "Eine neuere Projektänderung verhindert dieses Rückgängig."
) );
: await reconnectProjectDeviceRows( }
projectId, const result = await undoProjectCommand(
projectDeviceUndo.projectDeviceId, projectId,
projectDeviceUndo.rowIds project.currentRevision
); );
const preview = await getProjectDeviceSyncPreview(
projectId,
projectDeviceUndo.projectDeviceId
);
applyProjectRevision(result.history.currentRevision);
setSyncPreview(preview); setSyncPreview(preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)); setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
setProjectDeviceUndo(null); setProjectDeviceUndo(null);
@@ -86,21 +86,6 @@ export class CircuitDeviceRowRepository {
.orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder)); .orderBy(asc(distributionBoards.name), asc(circuits.equipmentIdentifier), asc(circuitDeviceRows.sortOrder));
} }
async listLinkStatesByIds(projectId: string, rowIds: string[]) {
if (rowIds.length === 0) {
return [];
}
return db
.select({
id: circuitDeviceRows.id,
linkedProjectDeviceId: circuitDeviceRows.linkedProjectDeviceId,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuitDeviceRows.circuitId, circuits.id))
.innerJoin(circuitLists, eq(circuits.circuitListId, circuitLists.id))
.where(and(eq(circuitLists.projectId, projectId), inArray(circuitDeviceRows.id, rowIds)));
}
async create(input: CircuitDeviceRowCreateInput) { async create(input: CircuitDeviceRowCreateInput) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
await db.insert(circuitDeviceRows).values(toCircuitDeviceRowCreateValues(id, input)); await db.insert(circuitDeviceRows).values(toCircuitDeviceRowCreateValues(id, input));
@@ -1,81 +0,0 @@
import { and, eq, isNull } from "drizzle-orm";
import type {
ProjectDeviceLinkedRowValues,
ProjectDeviceRowSyncStore,
} from "../../domain/ports/project-device-row-sync.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { toCircuitDeviceRowUpdateValues } from "./circuit-device-row.persistence.js";
export class ProjectDeviceRowSyncRepository implements ProjectDeviceRowSyncStore {
constructor(private readonly database: AppDatabase) {}
updateLinkedRows(
projectDeviceId: string,
changes: Array<{ rowId: string; input: ProjectDeviceLinkedRowValues }>
) {
this.database.transaction((tx) => {
for (const change of changes) {
const result = tx
.update(circuitDeviceRows)
.set(toCircuitDeviceRowUpdateValues(change.input))
.where(
and(
eq(circuitDeviceRows.id, change.rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"A linked row changed before the operation could be completed."
);
}
}
});
}
disconnectLinkedRows(projectDeviceId: string, rowIds: string[]) {
this.database.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: null })
.where(
and(
eq(circuitDeviceRows.id, rowId),
eq(circuitDeviceRows.linkedProjectDeviceId, projectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"A linked row changed before the operation could be completed."
);
}
}
});
}
reconnectRows(projectDeviceId: string, rowIds: string[]) {
this.database.transaction((tx) => {
for (const rowId of rowIds) {
const result = tx
.update(circuitDeviceRows)
.set({ linkedProjectDeviceId: projectDeviceId })
.where(
and(
eq(circuitDeviceRows.id, rowId),
isNull(circuitDeviceRows.linkedProjectDeviceId)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"A disconnected row changed before the operation could be undone."
);
}
}
});
}
}
@@ -1,28 +0,0 @@
export interface ProjectDeviceLinkedRowValues {
linkedProjectDeviceId?: string;
name: string;
displayName: string;
phaseType?: string;
connectionKind?: string;
costGroup?: string;
category?: string;
level?: string;
roomId?: string;
roomNumberSnapshot?: string;
roomNameSnapshot?: string;
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
remark?: string;
overriddenFields?: string;
}
export interface ProjectDeviceRowSyncStore {
updateLinkedRows(
projectDeviceId: string,
changes: Array<{ rowId: string; input: ProjectDeviceLinkedRowValues }>
): void;
disconnectLinkedRows(projectDeviceId: string, rowIds: string[]): void;
reconnectRows(projectDeviceId: string, rowIds: string[]): void;
}
@@ -1,6 +1,11 @@
import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js"; import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
import type { ProjectDeviceRowSyncStore } from "../ports/project-device-row-sync.store.js"; import {
createProjectDeviceRowSyncProjectCommand,
projectDeviceSyncRowSnapshotFields,
type ProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot,
} from "../models/project-device-row-sync-project-command.model.js";
import { import {
projectDeviceSyncFields, projectDeviceSyncFields,
type ProjectDeviceSyncField, type ProjectDeviceSyncField,
@@ -20,20 +25,9 @@ type LinkedRow = Awaited<ReturnType<CircuitDeviceRowRepository["listLinkedByProj
type SyncDependencies = { type SyncDependencies = {
projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">; projectDeviceRepository: Pick<ProjectDeviceRepository, "findById">;
deviceRowRepository: Pick< deviceRowRepository: Pick<CircuitDeviceRowRepository, "listLinkedByProjectDevice">;
CircuitDeviceRowRepository,
| "listLinkedByProjectDevice"
| "listLinkStatesByIds"
>;
deviceRowSyncStore: ProjectDeviceRowSyncStore;
}; };
export interface ProjectDeviceSyncRestoreRow {
rowId: string;
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
overriddenFields: ProjectDeviceSyncField[];
}
function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) { function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) {
return projectDevice[field]; return projectDevice[field];
} }
@@ -56,19 +50,10 @@ function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) {
export class ProjectDeviceSyncService { export class ProjectDeviceSyncService {
private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"]; private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"];
private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"]; private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"];
private readonly deviceRowSyncStore?: SyncDependencies["deviceRowSyncStore"];
constructor(deps?: Partial<SyncDependencies>) { constructor(deps?: Partial<SyncDependencies>) {
this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository(); this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository();
this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository(); this.deviceRowRepository = deps?.deviceRowRepository ?? new CircuitDeviceRowRepository();
this.deviceRowSyncStore = deps?.deviceRowSyncStore;
}
private getDeviceRowSyncStore() {
if (!this.deviceRowSyncStore) {
throw new Error("Project-device row synchronization is not configured.");
}
return this.deviceRowSyncStore;
} }
async getPreview(projectId: string, projectDeviceId: string) { async getPreview(projectId: string, projectDeviceId: string) {
@@ -100,12 +85,12 @@ export class ProjectDeviceSyncService {
}; };
} }
async synchronize( async createSynchronizeCommand(
projectId: string, projectId: string,
projectDeviceId: string, projectDeviceId: string,
rowIds: string[], rowIds: string[],
fields: ProjectDeviceSyncField[] fields: ProjectDeviceSyncField[]
) { ): Promise<ProjectDeviceRowSyncProjectCommand> {
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId); const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId);
if (!projectDevice) { if (!projectDevice) {
throw new Error("Project device not found."); throw new Error("Project device not found.");
@@ -113,81 +98,60 @@ export class ProjectDeviceSyncService {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId); const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds); const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
const selectedFields = [...new Set(fields)]; const selectedFields = [...new Set(fields)];
const undoRows: ProjectDeviceSyncRestoreRow[] = selectedRows.map((row) => ({ const assignments = selectedRows.flatMap((row) => {
rowId: row.id, const expected = toSyncSnapshot(row);
values: Object.fromEntries(selectedFields.map((field) => [field, row[field] ?? null])), const target = { ...expected };
overriddenFields: parseOverriddenFields(row.overriddenFields),
}));
const changes = [];
for (const row of selectedRows) {
const next = this.copyRow(row);
for (const field of selectedFields) { for (const field of selectedFields) {
Object.assign(next, { [field]: sourceValue(projectDevice, field) ?? undefined }); Object.assign(target, {
[field]: sourceValue(projectDevice, field) ?? null,
});
} }
const remainingOverrides = parseOverriddenFields(row.overriddenFields).filter( const remainingOverrides = parseOverriddenFields(row.overriddenFields).filter(
(field) => !selectedFields.includes(field) (field) => !selectedFields.includes(field)
); );
next.overriddenFields = serializeOverriddenFields(remainingOverrides); target.overriddenFields =
changes.push({ rowId: row.id, input: next }); serializeOverriddenFields(remainingOverrides) ?? null;
} return snapshotsEqual(expected, target)
this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes); ? []
: [{ rowId: row.id, expected, target }];
return {
preview: await this.getPreview(projectId, projectDeviceId),
undo: { rows: undoRows },
};
}
async disconnect(projectId: string, projectDeviceId: string, rowIds: string[]) {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
const disconnectedRowIds = selectedRows.map((row) => row.id);
this.getDeviceRowSyncStore().disconnectLinkedRows(
projectDeviceId,
disconnectedRowIds
);
return { disconnectedRowIds, undo: { rowIds: disconnectedRowIds } };
}
async restore(
projectId: string,
projectDeviceId: string,
restoreRows: ProjectDeviceSyncRestoreRow[]
) {
const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const selectedRows = this.resolveSelectedRows(linkedRows, restoreRows.map((row) => row.rowId));
const restoreById = new Map(restoreRows.map((row) => [row.rowId, row]));
const changes = selectedRows.map((row) => {
const restore = restoreById.get(row.id)!;
const next = this.copyRow(row);
for (const [field, value] of Object.entries(restore.values) as Array<
[ProjectDeviceSyncField, string | number | null]
>) {
Object.assign(next, { [field]: value ?? undefined });
}
next.overriddenFields = serializeOverriddenFields(restore.overriddenFields);
return { rowId: row.id, input: next };
}); });
this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes); return createProjectDeviceRowSyncProjectCommand(
return this.getPreview(projectId, projectDeviceId); projectDeviceId,
"synchronize",
assignments
);
} }
async reconnect(projectId: string, projectDeviceId: string, rowIds: string[]) { async createDisconnectCommand(
const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId); projectId: string,
projectDeviceId: string,
rowIds: string[]
): Promise<ProjectDeviceRowSyncProjectCommand> {
const projectDevice = await this.projectDeviceRepository.findById(
projectId,
projectDeviceId
);
if (!projectDevice) { if (!projectDevice) {
throw new Error("Project device not found."); throw new Error("Project device not found.");
} }
const uniqueRowIds = [...new Set(rowIds)]; const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId);
const linkStates = await this.deviceRowRepository.listLinkStatesByIds(projectId, uniqueRowIds); const selectedRows = this.resolveSelectedRows(linkedRows, rowIds);
if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) { return createProjectDeviceRowSyncProjectCommand(
throw new Error("One or more rows cannot be reconnected."); projectDeviceId,
} "disconnect",
this.getDeviceRowSyncStore().reconnectRows(projectDeviceId, uniqueRowIds); selectedRows.map((row) => {
return this.getPreview(projectId, projectDeviceId); const expected = toSyncSnapshot(row);
return {
rowId: row.id,
expected,
target: {
...expected,
linkedProjectDeviceId: null,
},
};
})
);
} }
private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) { private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) {
@@ -200,25 +164,31 @@ export class ProjectDeviceSyncService {
return selectedRows; return selectedRows;
} }
private copyRow(row: LinkedRow) { }
return {
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined, function toSyncSnapshot(row: LinkedRow): ProjectDeviceSyncRowSnapshot {
name: row.name, return {
displayName: row.displayName, linkedProjectDeviceId: row.linkedProjectDeviceId,
phaseType: row.phaseType ?? undefined, name: row.name,
connectionKind: row.connectionKind ?? undefined, displayName: row.displayName,
costGroup: row.costGroup ?? undefined, phaseType: row.phaseType,
category: row.category ?? undefined, connectionKind: row.connectionKind,
level: row.level ?? undefined, costGroup: row.costGroup,
roomId: row.roomId ?? undefined, category: row.category,
roomNumberSnapshot: row.roomNumberSnapshot ?? undefined, quantity: row.quantity,
roomNameSnapshot: row.roomNameSnapshot ?? undefined, powerPerUnit: row.powerPerUnit,
quantity: row.quantity, simultaneityFactor: row.simultaneityFactor,
powerPerUnit: row.powerPerUnit, cosPhi: row.cosPhi,
simultaneityFactor: row.simultaneityFactor, remark: row.remark,
cosPhi: row.cosPhi ?? undefined, overriddenFields: row.overriddenFields,
remark: row.remark ?? undefined, };
overriddenFields: row.overriddenFields ?? undefined, }
};
} function snapshotsEqual(
left: ProjectDeviceSyncRowSnapshot,
right: ProjectDeviceSyncRowSnapshot
) {
return projectDeviceSyncRowSnapshotFields.every(
(field) => left[field] === right[field]
);
} }
+2 -17
View File
@@ -165,24 +165,9 @@ export interface ProjectDeviceSyncPreviewDto {
rows: ProjectDeviceSyncRowDto[]; rows: ProjectDeviceSyncRowDto[];
} }
export interface ProjectDeviceSyncRestoreRowDto { export interface ProjectDeviceSyncCommandResultDto
rowId: string; extends ProjectCommandResultDto {
values: Partial<Record<ProjectDeviceSyncField, string | number | null>>;
overriddenFields: ProjectDeviceSyncField[];
}
export interface ProjectDeviceSyncResultDto {
preview: ProjectDeviceSyncPreviewDto; preview: ProjectDeviceSyncPreviewDto;
undo: {
rows: ProjectDeviceSyncRestoreRowDto[];
};
}
export interface ProjectDeviceDisconnectResultDto {
disconnectedRowIds: string[];
undo: {
rowIds: string[];
};
} }
export interface CircuitTreeDeviceRowDto { export interface CircuitTreeDeviceRowDto {
+9 -37
View File
@@ -12,10 +12,8 @@ import type {
ProjectDeviceCommandResultDto, ProjectDeviceCommandResultDto,
ProjectDeviceDto, ProjectDeviceDto,
ProjectHistoryStateDto, ProjectHistoryStateDto,
ProjectDeviceDisconnectResultDto, ProjectDeviceSyncCommandResultDto,
ProjectDeviceSyncPreviewDto, ProjectDeviceSyncPreviewDto,
ProjectDeviceSyncRestoreRowDto,
ProjectDeviceSyncResultDto,
ProjectDto, ProjectDto,
RoomDto, RoomDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
@@ -387,13 +385,14 @@ export function synchronizeProjectDeviceRows(
projectId: string, projectId: string,
projectDeviceId: string, projectDeviceId: string,
rowIds: string[], rowIds: string[],
fields: ProjectDeviceSyncField[] fields: ProjectDeviceSyncField[],
expectedRevision: number
) { ) {
return request<ProjectDeviceSyncResultDto>( return request<ProjectDeviceSyncCommandResultDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`, `/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`,
{ {
method: "POST", method: "POST",
body: JSON.stringify({ rowIds, fields }), body: JSON.stringify({ rowIds, fields, expectedRevision }),
} }
); );
} }
@@ -401,41 +400,14 @@ export function synchronizeProjectDeviceRows(
export function disconnectProjectDeviceRows( export function disconnectProjectDeviceRows(
projectId: string, projectId: string,
projectDeviceId: string, projectDeviceId: string,
rowIds: string[] rowIds: string[],
expectedRevision: number
) { ) {
return request<ProjectDeviceDisconnectResultDto>( return request<ProjectDeviceSyncCommandResultDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`, `/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`,
{ {
method: "POST", method: "POST",
body: JSON.stringify({ rowIds }), body: JSON.stringify({ rowIds, expectedRevision }),
}
);
}
export function restoreProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rows: ProjectDeviceSyncRestoreRowDto[]
) {
return request<ProjectDeviceSyncPreviewDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/restore`,
{
method: "POST",
body: JSON.stringify({ rows }),
}
);
}
export function reconnectProjectDeviceRows(
projectId: string,
projectDeviceId: string,
rowIds: string[]
) {
return request<ProjectDeviceSyncPreviewDto>(
`/api/project-devices/projects/${projectId}/${projectDeviceId}/reconnect`,
{
method: "POST",
body: JSON.stringify({ rowIds }),
} }
); );
} }
@@ -1,7 +1,3 @@
import { db } from "../../db/client.js";
import { ProjectDeviceRowSyncRepository } from "../../db/repositories/project-device-row-sync.repository.js";
import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js"; import { ProjectDeviceSyncService } from "../../domain/services/project-device-sync.service.js";
export const projectDeviceSyncService = new ProjectDeviceSyncService({ export const projectDeviceSyncService = new ProjectDeviceSyncService();
deviceRowSyncStore: new ProjectDeviceRowSyncRepository(db),
});
@@ -14,8 +14,6 @@ import {
createProjectDeviceCommandSchema, createProjectDeviceCommandSchema,
disconnectProjectDeviceRowsSchema, disconnectProjectDeviceRowsSchema,
projectDeviceStructureCommandSchema, projectDeviceStructureCommandSchema,
reconnectProjectDeviceRowsSchema,
restoreProjectDeviceRowsSchema,
synchronizeProjectDeviceRowsSchema, synchronizeProjectDeviceRowsSchema,
updateProjectDeviceCommandSchema, updateProjectDeviceCommandSchema,
} from "../../shared/validation/project-device.schemas.js"; } from "../../shared/validation/project-device.schemas.js";
@@ -218,32 +216,25 @@ export async function synchronizeProjectDeviceRows(req: Request, res: Response)
return res.status(400).json({ error: parsed.error.flatten() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
try { try {
const result = await projectDeviceSyncService.synchronize( const command = await projectDeviceSyncService.createSynchronizeCommand(
projectId, projectId,
projectDeviceId, projectDeviceId,
parsed.data.rowIds, parsed.data.rowIds,
parsed.data.fields parsed.data.fields
); );
return res.json(result); const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Projektgerät in Stromkreiszeilen synchronisieren",
command,
});
const preview = await projectDeviceSyncService.getPreview(
projectId,
projectDeviceId
);
return res.json({ ...result, preview });
} catch (error) { } catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to synchronize rows." }); return respondWithProjectCommandError(error, res);
}
}
export async function restoreProjectDeviceRows(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = restoreProjectDeviceRowsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const preview = await projectDeviceSyncService.restore(projectId, projectDeviceId, parsed.data.rows);
return res.json(preview);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to restore rows." });
} }
} }
@@ -257,26 +248,23 @@ export async function disconnectProjectDeviceRows(req: Request, res: Response) {
return res.status(400).json({ error: parsed.error.flatten() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
try { try {
const result = await projectDeviceSyncService.disconnect(projectId, projectDeviceId, parsed.data.rowIds); const command = await projectDeviceSyncService.createDisconnectCommand(
return res.json(result); projectId,
projectDeviceId,
parsed.data.rowIds
);
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Projektgerät-Verknüpfungen trennen",
command,
});
const preview = await projectDeviceSyncService.getPreview(
projectId,
projectDeviceId
);
return res.json({ ...result, preview });
} catch (error) { } catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to disconnect rows." }); return respondWithProjectCommandError(error, res);
}
}
export async function reconnectProjectDeviceRows(req: Request, res: Response) {
const { projectId, projectDeviceId } = req.params;
if (typeof projectId !== "string" || typeof projectDeviceId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = reconnectProjectDeviceRowsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const preview = await projectDeviceSyncService.reconnect(projectId, projectDeviceId, parsed.data.rowIds);
return res.json(preview);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to reconnect rows." });
} }
} }
@@ -6,8 +6,6 @@ import {
disconnectProjectDeviceRows, disconnectProjectDeviceRows,
getProjectDeviceSyncPreview, getProjectDeviceSyncPreview,
listProjectDevicesByProject, listProjectDevicesByProject,
reconnectProjectDeviceRows,
restoreProjectDeviceRows,
synchronizeProjectDeviceRows, synchronizeProjectDeviceRows,
updateProjectDevice, updateProjectDevice,
} from "../controllers/project-device.controller.js"; } from "../controllers/project-device.controller.js";
@@ -19,8 +17,6 @@ projectDeviceRouter.post("/projects/:projectId", createProjectDevice);
projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject); projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject);
projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview); projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/synchronize", synchronizeProjectDeviceRows); projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/synchronize", synchronizeProjectDeviceRows);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/restore", restoreProjectDeviceRows);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/disconnect", disconnectProjectDeviceRows); projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/disconnect", disconnectProjectDeviceRows);
projectDeviceRouter.post("/projects/:projectId/:projectDeviceId/reconnect", reconnectProjectDeviceRows);
projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice); projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice);
projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice); projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice);
@@ -37,39 +37,13 @@ export const projectDeviceStructureCommandSchema = z.object({
export const synchronizeProjectDeviceRowsSchema = z.object({ export const synchronizeProjectDeviceRowsSchema = z.object({
rowIds: z.array(z.string().min(1)).min(1), rowIds: z.array(z.string().min(1)).min(1),
fields: z.array(z.enum(projectDeviceSyncFields)).min(1), fields: z.array(z.enum(projectDeviceSyncFields)).min(1),
expectedRevision: expectedProjectRevisionSchema,
}); });
export const disconnectProjectDeviceRowsSchema = z.object({ export const disconnectProjectDeviceRowsSchema = z.object({
rowIds: z.array(z.string().min(1)).min(1), rowIds: z.array(z.string().min(1)).min(1),
expectedRevision: expectedProjectRevisionSchema,
}); });
const projectDeviceRestoreValuesSchema = z.object({
name: z.string().optional(),
displayName: z.string().optional(),
phaseType: z.enum(["single_phase", "three_phase"]).optional(),
connectionKind: z.string().nullable().optional(),
costGroup: z.string().nullable().optional(),
category: z.string().nullable().optional(),
quantity: z.number().min(0).optional(),
powerPerUnit: z.number().min(0).optional(),
simultaneityFactor: z.number().min(0).max(1).optional(),
cosPhi: z.number().min(0).max(1).nullable().optional(),
remark: z.string().nullable().optional(),
});
export const restoreProjectDeviceRowsSchema = z.object({
rows: z
.array(
z.object({
rowId: z.string().min(1),
values: projectDeviceRestoreValuesSchema,
overriddenFields: z.array(z.enum(projectDeviceSyncFields)),
})
)
.min(1),
});
export const reconnectProjectDeviceRowsSchema = disconnectProjectDeviceRowsSchema;
export type CreateProjectDeviceInput = z.infer<typeof createProjectDeviceSchema>; export type CreateProjectDeviceInput = z.infer<typeof createProjectDeviceSchema>;
export type UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>; export type UpdateProjectDeviceInput = z.infer<typeof updateProjectDeviceSchema>;
@@ -1,269 +0,0 @@
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 { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectDeviceRowSyncRepository } from "../src/db/repositories/project-device-row-sync.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projectDevices } from "../src/db/schema/project-devices.js";
import { projects } from "../src/db/schema/projects.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: "Test project" }).run();
const board = new DistributionBoardRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const [section] = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.limit(1)
.all();
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Beleuchtung",
sortOrder: 10,
isReserve: 0,
})
.run();
context.db
.insert(projectDevices)
.values({
id: "project-device-1",
projectId: "project-1",
name: "Luminaire",
displayName: "Office lighting",
phaseType: "single_phase",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
installedPowerPerUnitKw: 0.04,
demandFactor: 0.8,
})
.run();
context.db
.insert(circuitDeviceRows)
.values([
{
id: "row-1",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 10,
name: "Old luminaire",
displayName: "Local row 1",
quantity: 1,
powerPerUnit: 0.03,
simultaneityFactor: 1,
},
{
id: "row-2",
circuitId: "circuit-1",
linkedProjectDeviceId: "project-device-1",
sortOrder: 20,
name: "Old luminaire",
displayName: "Local row 2",
quantity: 2,
powerPerUnit: 0.03,
simultaneityFactor: 1,
},
])
.run();
return context;
}
function synchronizedValues(displayName: string) {
return {
linkedProjectDeviceId: "project-device-1",
name: "Luminaire",
displayName,
phaseType: "single_phase",
quantity: 4,
powerPerUnit: 0.04,
simultaneityFactor: 0.8,
cosPhi: 0.95,
remark: "DALI",
};
}
function listRows(context: DatabaseContext) {
return context.db
.select()
.from(circuitDeviceRows)
.all()
.sort((left, right) => left.id.localeCompare(right.id));
}
describe("project-device row sync repository", () => {
it("commits synchronized values for all selected linked rows", () => {
const context = createTestDatabase();
try {
const repository = new ProjectDeviceRowSyncRepository(context.db);
repository.updateLinkedRows("project-device-1", [
{ rowId: "row-1", input: synchronizedValues("Synced row 1") },
{ rowId: "row-2", input: synchronizedValues("Synced row 2") },
]);
assert.deepEqual(
listRows(context).map((row) => [
row.displayName,
row.quantity,
row.powerPerUnit,
row.simultaneityFactor,
]),
[
["Synced row 1", 4, 0.04, 0.8],
["Synced row 2", 4, 0.04, 0.8],
]
);
} finally {
context.close();
}
});
it("rolls back earlier synchronized values when a later row update fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_second_sync_update
BEFORE UPDATE ON circuit_device_rows
WHEN OLD.id = 'row-2' AND NEW.display_name = 'Synced'
BEGIN
SELECT RAISE(ABORT, 'forced sync failure');
END;
`);
const repository = new ProjectDeviceRowSyncRepository(context.db);
assert.throws(
() =>
repository.updateLinkedRows("project-device-1", [
{ rowId: "row-1", input: synchronizedValues("Synced") },
{ rowId: "row-2", input: synchronizedValues("Synced") },
]),
/forced sync failure/
);
assert.deepEqual(
listRows(context).map((row) => [row.displayName, row.quantity]),
[
["Local row 1", 1],
["Local row 2", 2],
]
);
} finally {
context.close();
}
});
it("disconnects all selected rows without changing local values", () => {
const context = createTestDatabase();
try {
const repository = new ProjectDeviceRowSyncRepository(context.db);
repository.disconnectLinkedRows("project-device-1", ["row-1", "row-2"]);
assert.deepEqual(
listRows(context).map((row) => [
row.linkedProjectDeviceId,
row.displayName,
row.quantity,
]),
[
[null, "Local row 1", 1],
[null, "Local row 2", 2],
]
);
} finally {
context.close();
}
});
it("rolls back earlier disconnects when a later row update fails", () => {
const context = createTestDatabase();
try {
context.sqlite.exec(`
CREATE TRIGGER fail_second_disconnect
BEFORE UPDATE ON circuit_device_rows
WHEN OLD.id = 'row-2' AND NEW.linked_project_device_id IS NULL
BEGIN
SELECT RAISE(ABORT, 'forced disconnect failure');
END;
`);
const repository = new ProjectDeviceRowSyncRepository(context.db);
assert.throws(
() =>
repository.disconnectLinkedRows("project-device-1", ["row-1", "row-2"]),
/forced disconnect failure/
);
assert.deepEqual(
listRows(context).map((row) => row.linkedProjectDeviceId),
["project-device-1", "project-device-1"]
);
} finally {
context.close();
}
});
it("reconnects all selected disconnected rows", () => {
const context = createTestDatabase();
try {
context.db.update(circuitDeviceRows).set({ linkedProjectDeviceId: null }).run();
const repository = new ProjectDeviceRowSyncRepository(context.db);
repository.reconnectRows("project-device-1", ["row-1", "row-2"]);
assert.deepEqual(
listRows(context).map((row) => row.linkedProjectDeviceId),
["project-device-1", "project-device-1"]
);
} finally {
context.close();
}
});
it("rolls back earlier reconnects when a later row update fails", () => {
const context = createTestDatabase();
try {
context.db.update(circuitDeviceRows).set({ linkedProjectDeviceId: null }).run();
context.sqlite.exec(`
CREATE TRIGGER fail_second_reconnect
BEFORE UPDATE ON circuit_device_rows
WHEN OLD.id = 'row-2' AND NEW.linked_project_device_id = 'project-device-1'
BEGIN
SELECT RAISE(ABORT, 'forced reconnect failure');
END;
`);
const repository = new ProjectDeviceRowSyncRepository(context.db);
assert.throws(
() => repository.reconnectRows("project-device-1", ["row-1", "row-2"]),
/forced reconnect failure/
);
assert.deepEqual(
listRows(context).map((row) => row.linkedProjectDeviceId),
[null, null]
);
} finally {
context.close();
}
});
});
+27
View File
@@ -3,7 +3,9 @@ import { describe, it } from "node:test";
import { import {
createProjectDeviceCommandSchema, createProjectDeviceCommandSchema,
createProjectDeviceSchema, createProjectDeviceSchema,
disconnectProjectDeviceRowsSchema,
projectDeviceStructureCommandSchema, projectDeviceStructureCommandSchema,
synchronizeProjectDeviceRowsSchema,
updateProjectDeviceCommandSchema, updateProjectDeviceCommandSchema,
} from "../src/shared/validation/project-device.schemas.js"; } from "../src/shared/validation/project-device.schemas.js";
@@ -87,4 +89,29 @@ describe("project device circuit-first schema", () => {
false false
); );
}); });
it("requires the current revision for synchronization writes", () => {
assert.equal(
synchronizeProjectDeviceRowsSchema.safeParse({
rowIds: ["row-1"],
fields: ["quantity"],
}).success,
false
);
assert.equal(
synchronizeProjectDeviceRowsSchema.safeParse({
rowIds: ["row-1"],
fields: ["quantity"],
expectedRevision: 7,
}).success,
true
);
assert.equal(
disconnectProjectDeviceRowsSchema.safeParse({
rowIds: ["row-1"],
expectedRevision: 7,
}).success,
true
);
});
}); });
+73 -69
View File
@@ -18,6 +18,7 @@ function projectDevice() {
totalPower: 0.192, totalPower: 0.192,
cosPhi: 0.95, cosPhi: 0.95,
remark: "DALI", remark: "DALI",
voltageV: 230,
}; };
} }
@@ -55,8 +56,6 @@ function linkedRow() {
function createService() { function createService() {
const rows = [linkedRow()]; const rows = [linkedRow()];
const updates: Array<Record<string, unknown>> = [];
let transactionCalls = 0;
const service = new ProjectDeviceSyncService({ const service = new ProjectDeviceSyncService({
projectDeviceRepository: { projectDeviceRepository: {
async findById() { async findById() {
@@ -65,99 +64,104 @@ function createService() {
}, },
deviceRowRepository: { deviceRowRepository: {
async listLinkedByProjectDevice() { async listLinkedByProjectDevice() {
return rows.filter((row) => row.linkedProjectDeviceId === "pd1") as never; return rows as never;
}, },
async listLinkStatesByIds() { },
return rows.map((row) => ({ id: row.id, linkedProjectDeviceId: row.linkedProjectDeviceId })) as never;
},
} as never,
deviceRowSyncStore: {
updateLinkedRows(_projectDeviceId, changes) {
transactionCalls += 1;
for (const change of changes) {
updates.push({ rowId: change.rowId, ...change.input });
Object.assign(rows.find((row) => row.id === change.rowId)!, change.input);
}
},
disconnectLinkedRows(_projectDeviceId, rowIds) {
transactionCalls += 1;
for (const rowId of rowIds) {
const row = rows.find((entry) => entry.id === rowId)!;
updates.push({ rowId, linkedProjectDeviceId: undefined, displayName: row.displayName, quantity: row.quantity });
Object.assign(row, { linkedProjectDeviceId: null });
}
},
reconnectRows(projectDeviceId, rowIds) {
transactionCalls += 1;
for (const rowId of rowIds) {
Object.assign(rows.find((row) => row.id === rowId)!, { linkedProjectDeviceId: projectDeviceId });
}
},
} as never,
}); });
return { service, rows, updates, transactionCalls: () => transactionCalls }; return { service, rows };
} }
describe("project device synchronization", () => { describe("project device synchronization", () => {
it("shows field differences and local overrides without changing rows", async () => { it("shows field differences and local overrides without changing rows", async () => {
const { service, updates } = createService(); const { service, rows } = createService();
const before = structuredClone(rows);
const preview = await service.getPreview("p1", "pd1"); const preview = await service.getPreview("p1", "pd1");
assert.equal(updates.length, 0); assert.deepEqual(rows, before);
assert.equal(preview.rows[0].equipmentIdentifier, "-1F1"); assert.equal(preview.rows[0].equipmentIdentifier, "-1F1");
assert.equal(preview.rows[0].differences.some((difference) => difference.field === "quantity"), true);
assert.equal( assert.equal(
preview.rows[0].differences.find((difference) => difference.field === "displayName")?.isOverridden, preview.rows[0].differences.some(
(difference) => difference.field === "quantity"
),
true
);
assert.equal(
preview.rows[0].differences.find(
(difference) => difference.field === "displayName"
)?.isOverridden,
true true
); );
}); });
it("synchronizes only explicitly selected fields", async () => { it("creates a deterministic command for selected synchronization fields", async () => {
const { service, rows, transactionCalls } = createService(); const { service, rows } = createService();
await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]); const before = structuredClone(rows);
const command = await service.createSynchronizeCommand(
"p1",
"pd1",
["row1"],
["quantity", "powerPerUnit"]
);
assert.equal(rows[0].quantity, 6); assert.deepEqual(rows, before);
assert.equal(rows[0].powerPerUnit, 0.04); assert.equal(command.payload.operation, "synchronize");
assert.equal(rows[0].displayName, "Local display name"); assert.equal(command.payload.rows[0].expected.quantity, 2);
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName"]); assert.equal(command.payload.rows[0].target.quantity, 6);
assert.equal(transactionCalls(), 1); assert.equal(command.payload.rows[0].target.powerPerUnit, 0.04);
assert.equal(
command.payload.rows[0].target.displayName,
"Local display name"
);
assert.deepEqual(
JSON.parse(command.payload.rows[0].target.overriddenFields ?? "[]"),
["displayName"]
);
}); });
it("rejects rows that are not linked to the selected project device", async () => { it("rejects rows that are not linked to the selected project device", async () => {
const { service } = createService(); const { service } = createService();
await assert.rejects( await assert.rejects(
() => service.synchronize("p1", "pd1", ["other-row"], ["quantity"]), () =>
service.createSynchronizeCommand(
"p1",
"pd1",
["other-row"],
["quantity"]
),
/not linked/ /not linked/
); );
}); });
it("disconnects selected rows without changing local values", async () => { it("creates a link-only disconnect command", async () => {
const { service, updates } = createService(); const { service } = createService();
await service.disconnect("p1", "pd1", ["row1"]); const command = await service.createDisconnectCommand(
"p1",
"pd1",
["row1"]
);
assert.equal(updates[0].linkedProjectDeviceId, undefined); const assignment = command.payload.rows[0];
assert.equal(updates[0].displayName, "Local display name"); assert.equal(command.payload.operation, "disconnect");
assert.equal(updates[0].quantity, 2); assert.equal(assignment.expected.linkedProjectDeviceId, "pd1");
assert.equal(assignment.target.linkedProjectDeviceId, null);
assert.equal(
assignment.target.displayName,
assignment.expected.displayName
);
assert.equal(assignment.target.quantity, assignment.expected.quantity);
}); });
it("restores synchronized values and override markers", async () => { it("does not create no-op synchronization commands", async () => {
const { service, rows } = createService(); const { service } = createService();
const result = await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]); await assert.rejects(
() =>
await service.restore("p1", "pd1", result.undo.rows); service.createSynchronizeCommand(
"p1",
assert.equal(rows[0].quantity, 2); "pd1",
assert.equal(rows[0].powerPerUnit, 0.03); ["row1"],
assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName", "quantity"]); ["category"]
),
/at least one row/
);
}); });
it("reconnects rows only while they remain disconnected", async () => {
const { service, rows } = createService();
const result = await service.disconnect("p1", "pd1", ["row1"]);
await service.reconnect("p1", "pd1", result.undo.rowIds);
assert.equal(rows[0].linkedProjectDeviceId, "pd1");
});
}); });