Persist project device synchronization
This commit is contained in:
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
|
||||||
projectDeviceUndo.projectDeviceId,
|
|
||||||
projectDeviceUndo.rowIds
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
const result = await undoProjectCommand(
|
||||||
|
projectId,
|
||||||
|
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) {
|
}
|
||||||
|
|
||||||
|
function toSyncSnapshot(row: LinkedRow): ProjectDeviceSyncRowSnapshot {
|
||||||
return {
|
return {
|
||||||
linkedProjectDeviceId: row.linkedProjectDeviceId ?? undefined,
|
linkedProjectDeviceId: row.linkedProjectDeviceId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
phaseType: row.phaseType ?? undefined,
|
phaseType: row.phaseType,
|
||||||
connectionKind: row.connectionKind ?? undefined,
|
connectionKind: row.connectionKind,
|
||||||
costGroup: row.costGroup ?? undefined,
|
costGroup: row.costGroup,
|
||||||
category: row.category ?? undefined,
|
category: row.category,
|
||||||
level: row.level ?? undefined,
|
|
||||||
roomId: row.roomId ?? undefined,
|
|
||||||
roomNumberSnapshot: row.roomNumberSnapshot ?? undefined,
|
|
||||||
roomNameSnapshot: row.roomNameSnapshot ?? undefined,
|
|
||||||
quantity: row.quantity,
|
quantity: row.quantity,
|
||||||
powerPerUnit: row.powerPerUnit,
|
powerPerUnit: row.powerPerUnit,
|
||||||
simultaneityFactor: row.simultaneityFactor,
|
simultaneityFactor: row.simultaneityFactor,
|
||||||
cosPhi: row.cosPhi ?? undefined,
|
cosPhi: row.cosPhi,
|
||||||
remark: row.remark ?? undefined,
|
remark: row.remark,
|
||||||
overriddenFields: row.overriddenFields ?? undefined,
|
overriddenFields: row.overriddenFields,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function snapshotsEqual(
|
||||||
|
left: ProjectDeviceSyncRowSnapshot,
|
||||||
|
right: ProjectDeviceSyncRowSnapshot
|
||||||
|
) {
|
||||||
|
return projectDeviceSyncRowSnapshotFields.every(
|
||||||
|
(field) => left[field] === right[field]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-17
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user