Add external object row assignment command
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import type { ExternalModelObjectSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
||||
import {
|
||||
assertCircuitDeviceRowInsertProjectCommand,
|
||||
circuitDeviceRowInsertCommandType,
|
||||
circuitDeviceRowStructureCommandSchemaVersion,
|
||||
type CircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure-project-command.model.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
||||
|
||||
export const externalObjectRowAssignmentCommandType =
|
||||
"external-object.update-row-assignment" as const;
|
||||
export const externalObjectRowAssignmentCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface ExternalObjectRowAssignmentPayload {
|
||||
rows: Array<{ expected: CircuitDeviceRowSnapshot; target: CircuitDeviceRowSnapshot }>;
|
||||
objects: Array<{ expected: ExternalModelObjectSnapshot; target: ExternalModelObjectSnapshot }>;
|
||||
confirmedConflictObjectIds: string[];
|
||||
}
|
||||
|
||||
export interface ExternalObjectRowAssignmentProjectCommand
|
||||
extends SerializedProjectCommand<ExternalObjectRowAssignmentPayload> {
|
||||
schemaVersion: typeof externalObjectRowAssignmentCommandSchemaVersion;
|
||||
type: typeof externalObjectRowAssignmentCommandType;
|
||||
}
|
||||
|
||||
export function createExternalObjectRowAssignmentProjectCommand(
|
||||
payload: ExternalObjectRowAssignmentPayload
|
||||
): ExternalObjectRowAssignmentProjectCommand {
|
||||
const normalizedPayload: ExternalObjectRowAssignmentPayload = {
|
||||
...payload,
|
||||
rows: payload.rows.map(({ expected, target }) => ({
|
||||
expected: normalizeRow(expected),
|
||||
target: normalizeRow(target),
|
||||
})),
|
||||
confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds],
|
||||
};
|
||||
const command: ExternalObjectRowAssignmentProjectCommand = {
|
||||
schemaVersion: externalObjectRowAssignmentCommandSchemaVersion,
|
||||
type: externalObjectRowAssignmentCommandType,
|
||||
payload: normalizedPayload,
|
||||
};
|
||||
assertExternalObjectRowAssignmentProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function invertExternalObjectRowAssignmentProjectCommand(
|
||||
command: ExternalObjectRowAssignmentProjectCommand
|
||||
) {
|
||||
return createExternalObjectRowAssignmentProjectCommand({
|
||||
rows: command.payload.rows.map(({ expected, target }) => ({ expected: target, target: expected })),
|
||||
objects: command.payload.objects.map(({ expected, target }) => ({ expected: target, target: expected })),
|
||||
confirmedConflictObjectIds: [...command.payload.confirmedConflictObjectIds],
|
||||
});
|
||||
}
|
||||
|
||||
export function assertExternalObjectRowAssignmentProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is ExternalObjectRowAssignmentProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== externalObjectRowAssignmentCommandSchemaVersion ||
|
||||
command.type !== externalObjectRowAssignmentCommandType ||
|
||||
!isRecord(command.payload) ||
|
||||
!Array.isArray(command.payload.rows) ||
|
||||
!Array.isArray(command.payload.objects) ||
|
||||
!Array.isArray(command.payload.confirmedConflictObjectIds) ||
|
||||
command.payload.rows.length === 0 ||
|
||||
command.payload.objects.length === 0
|
||||
) {
|
||||
throw new Error("Unsupported external object row-assignment command.");
|
||||
}
|
||||
const rowIds = new Set<string>();
|
||||
for (const transition of command.payload.rows) {
|
||||
if (!isRecord(transition)) throw new Error("Invalid row assignment transition.");
|
||||
const expected = parseRow(transition.expected);
|
||||
const target = parseRow(transition.target);
|
||||
if (expected.manualQuantity === undefined || target.manualQuantity === undefined) {
|
||||
throw new Error("Row assignment requires an explicit manual quantity.");
|
||||
}
|
||||
if (expected.id !== target.id || rowIds.has(expected.id)) {
|
||||
throw new Error("Row assignment contains mismatched or duplicate rows.");
|
||||
}
|
||||
rowIds.add(expected.id);
|
||||
if (expected.manualQuantity !== target.manualQuantity) {
|
||||
throw new Error("Row assignment must preserve the manual quantity.");
|
||||
}
|
||||
assertOnlyFieldChanged(
|
||||
expected as unknown as Record<string, unknown>,
|
||||
target as unknown as Record<string, unknown>,
|
||||
"quantity",
|
||||
"row"
|
||||
);
|
||||
}
|
||||
const objectIds = new Set<string>();
|
||||
const affectedRowIds = new Set<string>();
|
||||
for (const transition of command.payload.objects) {
|
||||
if (!isRecord(transition)) throw new Error("Invalid object assignment transition.");
|
||||
const expected = parseObject(transition.expected);
|
||||
const target = parseObject(transition.target);
|
||||
if (expected.id !== target.id || objectIds.has(expected.id)) {
|
||||
throw new Error("Row assignment contains mismatched or duplicate objects.");
|
||||
}
|
||||
objectIds.add(expected.id);
|
||||
if (expected.circuitDeviceRowId === target.circuitDeviceRowId) {
|
||||
throw new Error("External object row assignment must change its row link.");
|
||||
}
|
||||
assertOnlyFieldChanged(
|
||||
expected as unknown as Record<string, unknown>,
|
||||
target as unknown as Record<string, unknown>,
|
||||
"circuitDeviceRowId",
|
||||
"external object"
|
||||
);
|
||||
if (expected.circuitDeviceRowId) affectedRowIds.add(expected.circuitDeviceRowId);
|
||||
if (target.circuitDeviceRowId) affectedRowIds.add(target.circuitDeviceRowId);
|
||||
}
|
||||
const confirmedIds = new Set<string>();
|
||||
for (const objectId of command.payload.confirmedConflictObjectIds) {
|
||||
if (
|
||||
typeof objectId !== "string" ||
|
||||
!objectIds.has(objectId) ||
|
||||
confirmedIds.has(objectId)
|
||||
) {
|
||||
throw new Error("Row assignment contains an invalid conflict confirmation.");
|
||||
}
|
||||
confirmedIds.add(objectId);
|
||||
}
|
||||
if (
|
||||
affectedRowIds.size !== rowIds.size ||
|
||||
[...affectedRowIds].some((rowId) => !rowIds.has(rowId))
|
||||
) {
|
||||
throw new Error("Row assignment must include every and only affected device row.");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRow(row: CircuitDeviceRowSnapshot): CircuitDeviceRowSnapshot {
|
||||
return { ...row, manualQuantity: row.manualQuantity ?? row.quantity };
|
||||
}
|
||||
|
||||
function parseRow(value: unknown): CircuitDeviceRowSnapshot {
|
||||
const envelope = {
|
||||
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
|
||||
type: circuitDeviceRowInsertCommandType,
|
||||
payload: { row: value },
|
||||
};
|
||||
assertCircuitDeviceRowInsertProjectCommand(envelope);
|
||||
return envelope.payload.row;
|
||||
}
|
||||
|
||||
function parseObject(value: unknown): ExternalModelObjectSnapshot {
|
||||
const state = parseExternalModelStateSnapshot({
|
||||
source: null,
|
||||
importBatches: [],
|
||||
roomMappings: [],
|
||||
objects: [value],
|
||||
});
|
||||
return state.objects[0]!;
|
||||
}
|
||||
|
||||
function assertOnlyFieldChanged(
|
||||
expected: Record<string, unknown>,
|
||||
target: Record<string, unknown>,
|
||||
allowedField: string,
|
||||
label: string
|
||||
) {
|
||||
const expectedRest = { ...expected };
|
||||
const targetRest = { ...target };
|
||||
delete expectedRest[allowedField];
|
||||
delete targetRest[allowedField];
|
||||
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
|
||||
throw new Error(`Row assignment may only change the ${label} assignment state.`);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (isRecord(value)) {
|
||||
return `{${Object.keys(value).sort().map((key) =>
|
||||
`${JSON.stringify(key)}:${canonicalJson(value[key])}`
|
||||
).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
Reference in New Issue
Block a user