import { assertCircuitDeviceRowUpdateProjectCommand, circuitDeviceRowUpdateCommandSchemaVersion, circuitDeviceRowUpdateCommandType, type CircuitDeviceRowUpdateField, type CircuitDeviceRowUpdateValues, } from "./circuit-device-row-project-command.model.js"; import type { SerializedProjectCommand } from "./project-command.model.js"; export const projectDeviceRowSyncCommandType = "project-device.sync-rows" as const; export const projectDeviceRowSyncCommandSchemaVersion = 1 as const; export const projectDeviceSyncRowSnapshotFields = [ "linkedProjectDeviceId", "name", "displayName", "phaseType", "connectionKind", "costGroup", "category", "quantity", "powerPerUnit", "simultaneityFactor", "cosPhi", "remark", "overriddenFields", ] as const satisfies readonly CircuitDeviceRowUpdateField[]; export type ProjectDeviceSyncRowSnapshot = Pick< CircuitDeviceRowUpdateValues, (typeof projectDeviceSyncRowSnapshotFields)[number] >; export type ProjectDeviceRowSyncOperation = | "synchronize" | "disconnect" | "reconnect"; export interface ProjectDeviceRowSyncAssignment { rowId: string; expected: ProjectDeviceSyncRowSnapshot; target: ProjectDeviceSyncRowSnapshot; } export interface ProjectDeviceRowSyncCommandPayload { projectDeviceId: string; operation: ProjectDeviceRowSyncOperation; rows: ProjectDeviceRowSyncAssignment[]; } export interface ProjectDeviceRowSyncProjectCommand extends SerializedProjectCommand { schemaVersion: typeof projectDeviceRowSyncCommandSchemaVersion; type: typeof projectDeviceRowSyncCommandType; } export function createProjectDeviceRowSyncProjectCommand( projectDeviceId: string, operation: ProjectDeviceRowSyncOperation, rows: ProjectDeviceRowSyncAssignment[] ): ProjectDeviceRowSyncProjectCommand { const command: ProjectDeviceRowSyncProjectCommand = { schemaVersion: projectDeviceRowSyncCommandSchemaVersion, type: projectDeviceRowSyncCommandType, payload: { projectDeviceId, operation, rows }, }; assertProjectDeviceRowSyncProjectCommand(command); return command; } export function assertProjectDeviceRowSyncProjectCommand( command: SerializedProjectCommand ): asserts command is ProjectDeviceRowSyncProjectCommand { if ( command.schemaVersion !== projectDeviceRowSyncCommandSchemaVersion || command.type !== projectDeviceRowSyncCommandType ) { throw new Error("Unsupported project-device row sync command."); } if (!isPlainObject(command.payload)) { throw new Error( "Project-device row sync payload must be an object." ); } const { projectDeviceId, operation, rows } = command.payload; assertNonEmptyString(projectDeviceId, "projectDeviceId"); if ( operation !== "synchronize" && operation !== "disconnect" && operation !== "reconnect" ) { throw new Error( "Project-device row sync operation is invalid." ); } if (!Array.isArray(rows) || rows.length === 0) { throw new Error( "Project-device row sync command requires at least one row." ); } const rowIds = new Set(); for (const row of rows) { if (!isPlainObject(row)) { throw new Error( "Project-device row sync command contains an invalid row." ); } assertNonEmptyString(row.rowId, "rowId"); if (rowIds.has(row.rowId)) { throw new Error( "Project-device row sync command contains duplicate row ids." ); } assertProjectDeviceSyncRowSnapshot(row.expected); assertProjectDeviceSyncRowSnapshot(row.target); const expected = row.expected as ProjectDeviceSyncRowSnapshot; const target = row.target as ProjectDeviceSyncRowSnapshot; if (snapshotsEqual(expected, target)) { throw new Error( "Project-device row sync command contains a no-op row." ); } assertOperationTransition( operation, projectDeviceId, expected, target ); rowIds.add(row.rowId); } } export function invertProjectDeviceRowSyncOperation( operation: ProjectDeviceRowSyncOperation ): ProjectDeviceRowSyncOperation { if (operation === "disconnect") { return "reconnect"; } if (operation === "reconnect") { return "disconnect"; } return "synchronize"; } function assertProjectDeviceSyncRowSnapshot(snapshot: unknown) { if (!isPlainObject(snapshot)) { throw new Error( "Project-device sync row snapshot must be an object." ); } const snapshotKeys = Object.keys(snapshot); if ( snapshotKeys.length !== projectDeviceSyncRowSnapshotFields.length || projectDeviceSyncRowSnapshotFields.some( (field) => !Object.prototype.hasOwnProperty.call(snapshot, field) ) ) { throw new Error( "Project-device sync row snapshot must contain every sync field." ); } assertCircuitDeviceRowUpdateProjectCommand({ schemaVersion: circuitDeviceRowUpdateCommandSchemaVersion, type: circuitDeviceRowUpdateCommandType, payload: { rowId: "__sync_snapshot_validation__", changes: projectDeviceSyncRowSnapshotFields.map((field) => ({ field, value: snapshot[field], })), }, }); } function assertOperationTransition( operation: ProjectDeviceRowSyncOperation, projectDeviceId: string, expected: ProjectDeviceSyncRowSnapshot, target: ProjectDeviceSyncRowSnapshot ) { if (operation === "synchronize") { if ( expected.linkedProjectDeviceId !== projectDeviceId || target.linkedProjectDeviceId !== projectDeviceId ) { throw new Error( "Synchronized rows must remain linked to the selected project device." ); } return; } const expectedLink = operation === "disconnect" ? projectDeviceId : null; const targetLink = operation === "disconnect" ? null : projectDeviceId; if ( expected.linkedProjectDeviceId !== expectedLink || target.linkedProjectDeviceId !== targetLink ) { throw new Error( "Project-device link transition does not match the operation." ); } if (!snapshotsEqualWithoutLink(expected, target)) { throw new Error( "Disconnect and reconnect must not change local row values." ); } } function snapshotsEqual( left: ProjectDeviceSyncRowSnapshot, right: ProjectDeviceSyncRowSnapshot ) { return projectDeviceSyncRowSnapshotFields.every( (field) => left[field] === right[field] ); } function snapshotsEqualWithoutLink( left: ProjectDeviceSyncRowSnapshot, right: ProjectDeviceSyncRowSnapshot ) { return projectDeviceSyncRowSnapshotFields .filter((field) => field !== "linkedProjectDeviceId") .every((field) => left[field] === right[field]); } function assertNonEmptyString( value: unknown, field: string ): asserts value is string { if (typeof value !== "string" || !value.trim()) { throw new Error(`${field} must be a non-empty string.`); } } function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); }