From 3977a6e6e10596024bbbfbc13d287f437b521cbf Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 24 Jul 2026 11:47:03 +0200 Subject: [PATCH] Persist project device synchronization --- AGENTS.md | 3 +- docs/circuit-list-editor-api.md | 12 +- docs/current-architecture.md | 8 +- ...history-and-external-model-architecture.md | 5 +- docs/spec/07-implementation-phases-todo.md | 11 +- package.json | 4 +- src/app/projects/[projectId]/page.tsx | 79 +++-- .../circuit-device-row.repository.ts | 15 - .../project-device-row-sync.repository.ts | 81 ------ .../ports/project-device-row-sync.store.ts | 28 -- .../services/project-device-sync.service.ts | 186 +++++------- src/frontend/types.ts | 19 +- src/frontend/utils/api.ts | 46 +-- .../project-device-sync-service.ts | 6 +- .../controllers/project-device.controller.ts | 72 ++--- src/server/routes/project-device.routes.ts | 4 - .../validation/project-device.schemas.ts | 30 +- ...project-device-row-sync.repository.test.ts | 269 ------------------ tests/project-device-schema.test.ts | 27 ++ tests/project-device-sync.service.test.ts | 142 ++++----- 20 files changed, 300 insertions(+), 747 deletions(-) delete mode 100644 src/db/repositories/project-device-row-sync.repository.ts delete mode 100644 src/domain/ports/project-device-row-sync.store.ts delete mode 100644 tests/project-device-row-sync.repository.test.ts diff --git a/AGENTS.md b/AGENTS.md index 9ad7943..5d155b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 that have remained unchanged. ProjectDevice create, update, delete and 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: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 3efc4fa..7339d9a 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -277,12 +277,16 @@ endpoints above. - returns all linked circuit device rows with distribution-board/circuit context and field differences - `POST /project-devices/projects/:projectId/:projectDeviceId/synchronize` - applies only explicitly selected fields to explicitly selected linked rows -- `POST /project-devices/projects/:projectId/:projectDeviceId/restore` - - restores the captured pre-sync values for session-local undo + - request: selected `rowIds`, selected `fields` and `expectedRevision` + - response: updated preview, revision and project history state - `POST /project-devices/projects/:projectId/:projectDeviceId/disconnect` - disconnects explicitly selected rows without changing their local values -- `POST /project-devices/projects/:projectId/:projectDeviceId/reconnect` - - restores a disconnected link if the row has not been linked elsewhere meanwhile + - request: selected `rowIds` and `expectedRevision` + - 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 project device never triggers synchronization implicitly. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 18fc1b6..9139299 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -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, einschließlich ProjectDevice-Verknüpfung und lokaler Override-Metadaten. Damit werden stille Überschreibungen veralteter Zeilen verhindert und Undo/Redo stellt -exakt die vorherigen lokalen Werte wieder her. Die bestehenden -Projektgeräte-Endpunkte verwenden diesen Command bis zur Frontend-Umstellung -noch nicht. +exakt die vorherigen lokalen Werte wieder her. Die Synchronisieren- und +Trennen-Endpunkte sowie die Projektseite verwenden diesen Command mit +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 Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem gespeicherten Gerät und schreibt Geräteänderung, Revision und Historienstapel diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 8b678ec..bd4e825 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -211,8 +211,9 @@ Remaining constraints before completing persistent history: - route the remaining structural and destructive domain writes through the shared command, revision and stack transaction boundary - replace the session-local frontend command stack only after server-side - command coverage is complete for structural and destructive operations; then - route the existing synchronization UI through its persistent command + command coverage is complete for structural and destructive circuit-editor + 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 - keep database backup/restore checks separate from project history tests diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 7d9d93b..bb11e14 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -415,16 +415,19 @@ Implemented foundation: previously linked rows only from complete unchanged disconnected snapshots - ProjectDevice create, update, delete and global-to-project copy endpoints use 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 in one SQLite transaction - a stale expected revision produces no history writes - integration coverage verifies sequential revisions, stale-command rejection and rollback after a forced late persistence failure -ProjectDevice CRUD on the project page is connected to this history boundary. -The circuit-list editor and the remaining domain writes are not fully connected; -its visible undo/redo therefore remains session-local until the remaining tasks -are implemented. +ProjectDevice CRUD, synchronization and disconnect on the project page are +connected to this history boundary. The circuit-list editor and the remaining +domain writes are not fully connected; its visible undo/redo therefore remains +session-local until the remaining tasks are implemented. Acceptance criteria: diff --git a/package.json b/package.json index d08f005..49202e9 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "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: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": "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-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:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index ee0afe6..aa410ae 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -20,9 +20,8 @@ import { listProjectDevices, listProjects, listRooms, - reconnectProjectDeviceRows, - restoreProjectDeviceRows, synchronizeProjectDeviceRows, + undoProjectCommand, updateProjectDevice, updateProjectSettings, } from "../../../frontend/utils/api"; @@ -34,7 +33,6 @@ import type { GlobalDeviceDto, ProjectDeviceDto, ProjectDeviceSyncPreviewDto, - ProjectDeviceSyncRestoreRowDto, ProjectDto, RoomDto, } from "../../../frontend/types"; @@ -57,9 +55,10 @@ const projectDeviceSyncFieldLabels: Record = { remark: "Bemerkung", }; -type ProjectDeviceUndoState = - | { kind: "synchronize"; projectDeviceId: string; rows: ProjectDeviceSyncRestoreRowDto[] } - | { kind: "disconnect"; projectDeviceId: string; rowIds: string[] }; +type ProjectDeviceUndoState = { + projectDeviceId: string; + revisionNumber: number; +}; const emptyProjectDevice: CreateProjectDeviceInput = { name: "", @@ -180,6 +179,7 @@ export default function ProjectDetailPage() { setProject((current) => current ? { ...current, currentRevision } : current ); + setProjectDeviceUndo(null); } async function handleCreateBoard(event: FormEvent) { @@ -409,7 +409,13 @@ export default function ProjectDetailPage() { } async function handleSynchronizeProjectDeviceRows() { - if (!projectId || !syncPreview || selectedSyncRowIds.length === 0 || selectedSyncFields.length === 0) { + if ( + !projectId || + !project || + !syncPreview || + selectedSyncRowIds.length === 0 || + selectedSyncFields.length === 0 + ) { return; } setIsSaving(true); @@ -419,16 +425,17 @@ export default function ProjectDetailPage() { projectId, syncPreview.projectDevice.id, selectedSyncRowIds, - selectedSyncFields + selectedSyncFields, + project.currentRevision ); setSyncPreview(result.preview); + applyProjectRevision(result.history.currentRevision); setSelectedSyncRowIds( result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId) ); setProjectDeviceUndo({ - kind: "synchronize", projectDeviceId: syncPreview.projectDevice.id, - rows: result.undo.rows, + revisionNumber: result.revision.revisionNumber, }); } catch (err) { setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden."); @@ -438,7 +445,12 @@ export default function ProjectDetailPage() { } async function handleDisconnectProjectDeviceRows() { - if (!projectId || !syncPreview || selectedSyncRowIds.length === 0) { + if ( + !projectId || + !project || + !syncPreview || + selectedSyncRowIds.length === 0 + ) { return; } setIsSaving(true); @@ -447,13 +459,19 @@ export default function ProjectDetailPage() { const result = await disconnectProjectDeviceRows( projectId, 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({ - kind: "disconnect", projectDeviceId: syncPreview.projectDevice.id, - rowIds: result.undo.rowIds, + revisionNumber: result.revision.revisionNumber, }); } catch (err) { setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden."); @@ -463,24 +481,29 @@ export default function ProjectDetailPage() { } async function handleUndoProjectDeviceOperation() { - if (!projectId || !projectDeviceUndo) { + if (!projectId || !project || !projectDeviceUndo) { return; } setIsSaving(true); setError(null); try { - const preview = - projectDeviceUndo.kind === "synchronize" - ? await restoreProjectDeviceRows( - projectId, - projectDeviceUndo.projectDeviceId, - projectDeviceUndo.rows - ) - : await reconnectProjectDeviceRows( - projectId, - projectDeviceUndo.projectDeviceId, - projectDeviceUndo.rowIds - ); + if ( + project.currentRevision !== + projectDeviceUndo.revisionNumber + ) { + throw new Error( + "Eine neuere Projektänderung verhindert dieses Rückgängig." + ); + } + const result = await undoProjectCommand( + projectId, + project.currentRevision + ); + const preview = await getProjectDeviceSyncPreview( + projectId, + projectDeviceUndo.projectDeviceId + ); + applyProjectRevision(result.history.currentRevision); setSyncPreview(preview); setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)); setProjectDeviceUndo(null); diff --git a/src/db/repositories/circuit-device-row.repository.ts b/src/db/repositories/circuit-device-row.repository.ts index e2cdafa..4cf21a4 100644 --- a/src/db/repositories/circuit-device-row.repository.ts +++ b/src/db/repositories/circuit-device-row.repository.ts @@ -86,21 +86,6 @@ export class CircuitDeviceRowRepository { .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) { const id = crypto.randomUUID(); await db.insert(circuitDeviceRows).values(toCircuitDeviceRowCreateValues(id, input)); diff --git a/src/db/repositories/project-device-row-sync.repository.ts b/src/db/repositories/project-device-row-sync.repository.ts deleted file mode 100644 index 752af25..0000000 --- a/src/db/repositories/project-device-row-sync.repository.ts +++ /dev/null @@ -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." - ); - } - } - }); - } -} diff --git a/src/domain/ports/project-device-row-sync.store.ts b/src/domain/ports/project-device-row-sync.store.ts deleted file mode 100644 index 717069d..0000000 --- a/src/domain/ports/project-device-row-sync.store.ts +++ /dev/null @@ -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; -} diff --git a/src/domain/services/project-device-sync.service.ts b/src/domain/services/project-device-sync.service.ts index 095386e..aac9db1 100644 --- a/src/domain/services/project-device-sync.service.ts +++ b/src/domain/services/project-device-sync.service.ts @@ -1,6 +1,11 @@ import { CircuitDeviceRowRepository } from "../../db/repositories/circuit-device-row.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 { projectDeviceSyncFields, type ProjectDeviceSyncField, @@ -20,20 +25,9 @@ type LinkedRow = Awaited; - deviceRowRepository: Pick< - CircuitDeviceRowRepository, - | "listLinkedByProjectDevice" - | "listLinkStatesByIds" - >; - deviceRowSyncStore: ProjectDeviceRowSyncStore; + deviceRowRepository: Pick; }; -export interface ProjectDeviceSyncRestoreRow { - rowId: string; - values: Partial>; - overriddenFields: ProjectDeviceSyncField[]; -} - function sourceValue(projectDevice: ProjectDevice, field: ProjectDeviceSyncField) { return projectDevice[field]; } @@ -56,19 +50,10 @@ function buildDifferences(projectDevice: ProjectDevice, row: LinkedRow) { export class ProjectDeviceSyncService { private readonly projectDeviceRepository: SyncDependencies["projectDeviceRepository"]; private readonly deviceRowRepository: SyncDependencies["deviceRowRepository"]; - private readonly deviceRowSyncStore?: SyncDependencies["deviceRowSyncStore"]; constructor(deps?: Partial) { this.projectDeviceRepository = deps?.projectDeviceRepository ?? new ProjectDeviceRepository(); 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) { @@ -100,12 +85,12 @@ export class ProjectDeviceSyncService { }; } - async synchronize( + async createSynchronizeCommand( projectId: string, projectDeviceId: string, rowIds: string[], fields: ProjectDeviceSyncField[] - ) { + ): Promise { const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId); if (!projectDevice) { throw new Error("Project device not found."); @@ -113,81 +98,60 @@ export class ProjectDeviceSyncService { const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId); const selectedRows = this.resolveSelectedRows(linkedRows, rowIds); const selectedFields = [...new Set(fields)]; - const undoRows: ProjectDeviceSyncRestoreRow[] = selectedRows.map((row) => ({ - rowId: row.id, - values: Object.fromEntries(selectedFields.map((field) => [field, row[field] ?? null])), - overriddenFields: parseOverriddenFields(row.overriddenFields), - })); - - const changes = []; - for (const row of selectedRows) { - const next = this.copyRow(row); + const assignments = selectedRows.flatMap((row) => { + const expected = toSyncSnapshot(row); + const target = { ...expected }; 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( (field) => !selectedFields.includes(field) ); - next.overriddenFields = serializeOverriddenFields(remainingOverrides); - changes.push({ rowId: row.id, input: next }); - } - this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes); - - 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 }; + target.overriddenFields = + serializeOverriddenFields(remainingOverrides) ?? null; + return snapshotsEqual(expected, target) + ? [] + : [{ rowId: row.id, expected, target }]; }); - this.getDeviceRowSyncStore().updateLinkedRows(projectDeviceId, changes); - return this.getPreview(projectId, projectDeviceId); + return createProjectDeviceRowSyncProjectCommand( + projectDeviceId, + "synchronize", + assignments + ); } - async reconnect(projectId: string, projectDeviceId: string, rowIds: string[]) { - const projectDevice = await this.projectDeviceRepository.findById(projectId, projectDeviceId); + async createDisconnectCommand( + projectId: string, + projectDeviceId: string, + rowIds: string[] + ): Promise { + const projectDevice = await this.projectDeviceRepository.findById( + projectId, + projectDeviceId + ); if (!projectDevice) { throw new Error("Project device not found."); } - const uniqueRowIds = [...new Set(rowIds)]; - const linkStates = await this.deviceRowRepository.listLinkStatesByIds(projectId, uniqueRowIds); - if (linkStates.length !== uniqueRowIds.length || linkStates.some((row) => row.linkedProjectDeviceId !== null)) { - throw new Error("One or more rows cannot be reconnected."); - } - this.getDeviceRowSyncStore().reconnectRows(projectDeviceId, uniqueRowIds); - return this.getPreview(projectId, projectDeviceId); + const linkedRows = await this.deviceRowRepository.listLinkedByProjectDevice(projectId, projectDeviceId); + const selectedRows = this.resolveSelectedRows(linkedRows, rowIds); + return createProjectDeviceRowSyncProjectCommand( + projectDeviceId, + "disconnect", + selectedRows.map((row) => { + const expected = toSyncSnapshot(row); + return { + rowId: row.id, + expected, + target: { + ...expected, + linkedProjectDeviceId: null, + }, + }; + }) + ); } private resolveSelectedRows(linkedRows: LinkedRow[], rowIds: string[]) { @@ -200,25 +164,31 @@ export class ProjectDeviceSyncService { return selectedRows; } - private copyRow(row: LinkedRow) { - return { - linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined, - name: row.name, - displayName: row.displayName, - phaseType: row.phaseType ?? undefined, - connectionKind: row.connectionKind ?? undefined, - costGroup: row.costGroup ?? undefined, - category: row.category ?? undefined, - level: row.level ?? undefined, - roomId: row.roomId ?? undefined, - roomNumberSnapshot: row.roomNumberSnapshot ?? undefined, - roomNameSnapshot: row.roomNameSnapshot ?? undefined, - quantity: row.quantity, - powerPerUnit: row.powerPerUnit, - simultaneityFactor: row.simultaneityFactor, - cosPhi: row.cosPhi ?? undefined, - remark: row.remark ?? undefined, - overriddenFields: row.overriddenFields ?? undefined, - }; - } +} + +function toSyncSnapshot(row: LinkedRow): ProjectDeviceSyncRowSnapshot { + return { + linkedProjectDeviceId: row.linkedProjectDeviceId, + name: row.name, + displayName: row.displayName, + phaseType: row.phaseType, + connectionKind: row.connectionKind, + costGroup: row.costGroup, + category: row.category, + quantity: row.quantity, + powerPerUnit: row.powerPerUnit, + simultaneityFactor: row.simultaneityFactor, + cosPhi: row.cosPhi, + remark: row.remark, + overriddenFields: row.overriddenFields, + }; +} + +function snapshotsEqual( + left: ProjectDeviceSyncRowSnapshot, + right: ProjectDeviceSyncRowSnapshot +) { + return projectDeviceSyncRowSnapshotFields.every( + (field) => left[field] === right[field] + ); } diff --git a/src/frontend/types.ts b/src/frontend/types.ts index e4d1112..fc44e9b 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -165,24 +165,9 @@ export interface ProjectDeviceSyncPreviewDto { rows: ProjectDeviceSyncRowDto[]; } -export interface ProjectDeviceSyncRestoreRowDto { - rowId: string; - values: Partial>; - overriddenFields: ProjectDeviceSyncField[]; -} - -export interface ProjectDeviceSyncResultDto { +export interface ProjectDeviceSyncCommandResultDto + extends ProjectCommandResultDto { preview: ProjectDeviceSyncPreviewDto; - undo: { - rows: ProjectDeviceSyncRestoreRowDto[]; - }; -} - -export interface ProjectDeviceDisconnectResultDto { - disconnectedRowIds: string[]; - undo: { - rowIds: string[]; - }; } export interface CircuitTreeDeviceRowDto { diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index d489877..32b4ef3 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -12,10 +12,8 @@ import type { ProjectDeviceCommandResultDto, ProjectDeviceDto, ProjectHistoryStateDto, - ProjectDeviceDisconnectResultDto, + ProjectDeviceSyncCommandResultDto, ProjectDeviceSyncPreviewDto, - ProjectDeviceSyncRestoreRowDto, - ProjectDeviceSyncResultDto, ProjectDto, RoomDto, CircuitTreeResponseDto, @@ -387,13 +385,14 @@ export function synchronizeProjectDeviceRows( projectId: string, projectDeviceId: string, rowIds: string[], - fields: ProjectDeviceSyncField[] + fields: ProjectDeviceSyncField[], + expectedRevision: number ) { - return request( + return request( `/api/project-devices/projects/${projectId}/${projectDeviceId}/synchronize`, { method: "POST", - body: JSON.stringify({ rowIds, fields }), + body: JSON.stringify({ rowIds, fields, expectedRevision }), } ); } @@ -401,41 +400,14 @@ export function synchronizeProjectDeviceRows( export function disconnectProjectDeviceRows( projectId: string, projectDeviceId: string, - rowIds: string[] + rowIds: string[], + expectedRevision: number ) { - return request( + return request( `/api/project-devices/projects/${projectId}/${projectDeviceId}/disconnect`, { method: "POST", - body: JSON.stringify({ rowIds }), - } - ); -} - -export function restoreProjectDeviceRows( - projectId: string, - projectDeviceId: string, - rows: ProjectDeviceSyncRestoreRowDto[] -) { - return request( - `/api/project-devices/projects/${projectId}/${projectDeviceId}/restore`, - { - method: "POST", - body: JSON.stringify({ rows }), - } - ); -} - -export function reconnectProjectDeviceRows( - projectId: string, - projectDeviceId: string, - rowIds: string[] -) { - return request( - `/api/project-devices/projects/${projectId}/${projectDeviceId}/reconnect`, - { - method: "POST", - body: JSON.stringify({ rowIds }), + body: JSON.stringify({ rowIds, expectedRevision }), } ); } diff --git a/src/server/composition/project-device-sync-service.ts b/src/server/composition/project-device-sync-service.ts index 63ce51f..b5c53a9 100644 --- a/src/server/composition/project-device-sync-service.ts +++ b/src/server/composition/project-device-sync-service.ts @@ -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"; -export const projectDeviceSyncService = new ProjectDeviceSyncService({ - deviceRowSyncStore: new ProjectDeviceRowSyncRepository(db), -}); +export const projectDeviceSyncService = new ProjectDeviceSyncService(); diff --git a/src/server/controllers/project-device.controller.ts b/src/server/controllers/project-device.controller.ts index 297b578..32100d9 100644 --- a/src/server/controllers/project-device.controller.ts +++ b/src/server/controllers/project-device.controller.ts @@ -14,8 +14,6 @@ import { createProjectDeviceCommandSchema, disconnectProjectDeviceRowsSchema, projectDeviceStructureCommandSchema, - reconnectProjectDeviceRowsSchema, - restoreProjectDeviceRowsSchema, synchronizeProjectDeviceRowsSchema, updateProjectDeviceCommandSchema, } 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() }); } try { - const result = await projectDeviceSyncService.synchronize( + const command = await projectDeviceSyncService.createSynchronizeCommand( projectId, projectDeviceId, parsed.data.rowIds, 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) { - return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to synchronize rows." }); - } -} - -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." }); + return respondWithProjectCommandError(error, res); } } @@ -257,26 +248,23 @@ export async function disconnectProjectDeviceRows(req: Request, res: Response) { return res.status(400).json({ error: parsed.error.flatten() }); } try { - const result = await projectDeviceSyncService.disconnect(projectId, projectDeviceId, parsed.data.rowIds); - return res.json(result); + const command = await projectDeviceSyncService.createDisconnectCommand( + 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) { - return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to disconnect rows." }); - } -} - -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." }); + return respondWithProjectCommandError(error, res); } } diff --git a/src/server/routes/project-device.routes.ts b/src/server/routes/project-device.routes.ts index aa06112..97f84f2 100644 --- a/src/server/routes/project-device.routes.ts +++ b/src/server/routes/project-device.routes.ts @@ -6,8 +6,6 @@ import { disconnectProjectDeviceRows, getProjectDeviceSyncPreview, listProjectDevicesByProject, - reconnectProjectDeviceRows, - restoreProjectDeviceRows, synchronizeProjectDeviceRows, updateProjectDevice, } from "../controllers/project-device.controller.js"; @@ -19,8 +17,6 @@ projectDeviceRouter.post("/projects/:projectId", createProjectDevice); projectDeviceRouter.post("/projects/:projectId/import-global/:globalDeviceId", copyGlobalDeviceToProject); projectDeviceRouter.get("/projects/:projectId/:projectDeviceId/links", getProjectDeviceSyncPreview); 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/reconnect", reconnectProjectDeviceRows); projectDeviceRouter.put("/projects/:projectId/:projectDeviceId", updateProjectDevice); projectDeviceRouter.delete("/projects/:projectId/:projectDeviceId", deleteProjectDevice); diff --git a/src/shared/validation/project-device.schemas.ts b/src/shared/validation/project-device.schemas.ts index f9836b9..098cfe4 100644 --- a/src/shared/validation/project-device.schemas.ts +++ b/src/shared/validation/project-device.schemas.ts @@ -37,39 +37,13 @@ export const projectDeviceStructureCommandSchema = z.object({ export const synchronizeProjectDeviceRowsSchema = z.object({ rowIds: z.array(z.string().min(1)).min(1), fields: z.array(z.enum(projectDeviceSyncFields)).min(1), + expectedRevision: expectedProjectRevisionSchema, }); export const disconnectProjectDeviceRowsSchema = z.object({ 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; export type UpdateProjectDeviceInput = z.infer; diff --git a/tests/project-device-row-sync.repository.test.ts b/tests/project-device-row-sync.repository.test.ts deleted file mode 100644 index 861238a..0000000 --- a/tests/project-device-row-sync.repository.test.ts +++ /dev/null @@ -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(); - } - }); -}); diff --git a/tests/project-device-schema.test.ts b/tests/project-device-schema.test.ts index 1080fb4..173a593 100644 --- a/tests/project-device-schema.test.ts +++ b/tests/project-device-schema.test.ts @@ -3,7 +3,9 @@ import { describe, it } from "node:test"; import { createProjectDeviceCommandSchema, createProjectDeviceSchema, + disconnectProjectDeviceRowsSchema, projectDeviceStructureCommandSchema, + synchronizeProjectDeviceRowsSchema, updateProjectDeviceCommandSchema, } from "../src/shared/validation/project-device.schemas.js"; @@ -87,4 +89,29 @@ describe("project device circuit-first schema", () => { 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 + ); + }); }); diff --git a/tests/project-device-sync.service.test.ts b/tests/project-device-sync.service.test.ts index 54c0c95..9ca6703 100644 --- a/tests/project-device-sync.service.test.ts +++ b/tests/project-device-sync.service.test.ts @@ -18,6 +18,7 @@ function projectDevice() { totalPower: 0.192, cosPhi: 0.95, remark: "DALI", + voltageV: 230, }; } @@ -55,8 +56,6 @@ function linkedRow() { function createService() { const rows = [linkedRow()]; - const updates: Array> = []; - let transactionCalls = 0; const service = new ProjectDeviceSyncService({ projectDeviceRepository: { async findById() { @@ -65,99 +64,104 @@ function createService() { }, deviceRowRepository: { 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", () => { 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"); - assert.equal(updates.length, 0); + assert.deepEqual(rows, before); assert.equal(preview.rows[0].equipmentIdentifier, "-1F1"); - assert.equal(preview.rows[0].differences.some((difference) => difference.field === "quantity"), true); 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 ); }); - it("synchronizes only explicitly selected fields", async () => { - const { service, rows, transactionCalls } = createService(); - await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]); + it("creates a deterministic command for selected synchronization fields", async () => { + const { service, rows } = createService(); + const before = structuredClone(rows); + const command = await service.createSynchronizeCommand( + "p1", + "pd1", + ["row1"], + ["quantity", "powerPerUnit"] + ); - assert.equal(rows[0].quantity, 6); - assert.equal(rows[0].powerPerUnit, 0.04); - assert.equal(rows[0].displayName, "Local display name"); - assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName"]); - assert.equal(transactionCalls(), 1); + assert.deepEqual(rows, before); + assert.equal(command.payload.operation, "synchronize"); + assert.equal(command.payload.rows[0].expected.quantity, 2); + assert.equal(command.payload.rows[0].target.quantity, 6); + 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 () => { const { service } = createService(); await assert.rejects( - () => service.synchronize("p1", "pd1", ["other-row"], ["quantity"]), + () => + service.createSynchronizeCommand( + "p1", + "pd1", + ["other-row"], + ["quantity"] + ), /not linked/ ); }); - it("disconnects selected rows without changing local values", async () => { - const { service, updates } = createService(); - await service.disconnect("p1", "pd1", ["row1"]); + it("creates a link-only disconnect command", async () => { + const { service } = createService(); + const command = await service.createDisconnectCommand( + "p1", + "pd1", + ["row1"] + ); - assert.equal(updates[0].linkedProjectDeviceId, undefined); - assert.equal(updates[0].displayName, "Local display name"); - assert.equal(updates[0].quantity, 2); + const assignment = command.payload.rows[0]; + assert.equal(command.payload.operation, "disconnect"); + 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 () => { - const { service, rows } = createService(); - const result = await service.synchronize("p1", "pd1", ["row1"], ["quantity", "powerPerUnit"]); - - await service.restore("p1", "pd1", result.undo.rows); - - assert.equal(rows[0].quantity, 2); - assert.equal(rows[0].powerPerUnit, 0.03); - assert.deepEqual(JSON.parse(rows[0].overriddenFields ?? "[]"), ["displayName", "quantity"]); + it("does not create no-op synchronization commands", async () => { + const { service } = createService(); + await assert.rejects( + () => + service.createSynchronizeCommand( + "p1", + "pd1", + ["row1"], + ["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"); - }); - });