163 lines
6.1 KiB
TypeScript
163 lines
6.1 KiB
TypeScript
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 externalObjectAssignToNewRowCommandType =
|
|
"external-object.assign-to-new-row" as const;
|
|
export const externalObjectDeleteCreatedRowCommandType =
|
|
"external-object.unassign-and-delete-created-row" as const;
|
|
export const externalObjectNewRowCommandSchemaVersion = 1 as const;
|
|
|
|
export interface ExternalObjectNewRowPayload {
|
|
row: CircuitDeviceRowSnapshot;
|
|
objects: Array<{
|
|
expected: ExternalModelObjectSnapshot;
|
|
target: ExternalModelObjectSnapshot;
|
|
}>;
|
|
confirmedConflictObjectIds: string[];
|
|
}
|
|
|
|
export interface ExternalObjectNewRowProjectCommand
|
|
extends SerializedProjectCommand<ExternalObjectNewRowPayload> {
|
|
schemaVersion: typeof externalObjectNewRowCommandSchemaVersion;
|
|
type:
|
|
| typeof externalObjectAssignToNewRowCommandType
|
|
| typeof externalObjectDeleteCreatedRowCommandType;
|
|
}
|
|
|
|
export function createExternalObjectNewRowProjectCommand(
|
|
type: ExternalObjectNewRowProjectCommand["type"],
|
|
payload: ExternalObjectNewRowPayload
|
|
): ExternalObjectNewRowProjectCommand {
|
|
const command: ExternalObjectNewRowProjectCommand = {
|
|
schemaVersion: externalObjectNewRowCommandSchemaVersion,
|
|
type,
|
|
payload: {
|
|
row: {
|
|
...payload.row,
|
|
manualQuantity: payload.row.manualQuantity ?? payload.row.quantity,
|
|
},
|
|
objects: payload.objects,
|
|
confirmedConflictObjectIds: [...payload.confirmedConflictObjectIds],
|
|
},
|
|
};
|
|
assertExternalObjectNewRowProjectCommand(command);
|
|
return command;
|
|
}
|
|
|
|
export function invertExternalObjectNewRowProjectCommand(
|
|
command: ExternalObjectNewRowProjectCommand
|
|
) {
|
|
return createExternalObjectNewRowProjectCommand(
|
|
command.type === externalObjectAssignToNewRowCommandType
|
|
? externalObjectDeleteCreatedRowCommandType
|
|
: externalObjectAssignToNewRowCommandType,
|
|
{
|
|
row: command.payload.row,
|
|
objects: command.payload.objects.map(({ expected, target }) => ({
|
|
expected: target,
|
|
target: expected,
|
|
})),
|
|
confirmedConflictObjectIds: command.payload.confirmedConflictObjectIds,
|
|
}
|
|
);
|
|
}
|
|
|
|
export function assertExternalObjectNewRowProjectCommand(
|
|
command: SerializedProjectCommand<unknown>
|
|
): asserts command is ExternalObjectNewRowProjectCommand {
|
|
if (
|
|
command.schemaVersion !== externalObjectNewRowCommandSchemaVersion ||
|
|
(command.type !== externalObjectAssignToNewRowCommandType &&
|
|
command.type !== externalObjectDeleteCreatedRowCommandType) ||
|
|
!isRecord(command.payload) ||
|
|
!Array.isArray(command.payload.objects) ||
|
|
command.payload.objects.length === 0 ||
|
|
!Array.isArray(command.payload.confirmedConflictObjectIds)
|
|
) {
|
|
throw new Error("Unsupported external object new-row command.");
|
|
}
|
|
const row = parseRow(command.payload.row);
|
|
if (row.manualQuantity !== 0) {
|
|
throw new Error("An external-only device row must start with zero manual quantity.");
|
|
}
|
|
const objectIds = new Set<string>();
|
|
for (const transition of command.payload.objects) {
|
|
if (!isRecord(transition)) throw new Error("Invalid external object transition.");
|
|
const expected = parseObject(transition.expected);
|
|
const target = parseObject(transition.target);
|
|
if (expected.id !== target.id || objectIds.has(expected.id)) {
|
|
throw new Error("New-row assignment contains mismatched or duplicate objects.");
|
|
}
|
|
objectIds.add(expected.id);
|
|
const assigning = command.type === externalObjectAssignToNewRowCommandType;
|
|
if (
|
|
(assigning && (expected.circuitDeviceRowId !== null || target.circuitDeviceRowId !== row.id)) ||
|
|
(!assigning && (expected.circuitDeviceRowId !== row.id || target.circuitDeviceRowId !== null))
|
|
) {
|
|
throw new Error("External object links do not match the new-row action.");
|
|
}
|
|
assertOnlyAssignmentChanged(expected, target);
|
|
}
|
|
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("New-row assignment contains an invalid conflict confirmation.");
|
|
}
|
|
confirmedIds.add(objectId);
|
|
}
|
|
}
|
|
|
|
function parseRow(value: unknown): CircuitDeviceRowSnapshot {
|
|
const envelope = {
|
|
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
|
|
type: circuitDeviceRowInsertCommandType,
|
|
payload: { row: value },
|
|
};
|
|
assertCircuitDeviceRowInsertProjectCommand(envelope);
|
|
const row = envelope.payload.row;
|
|
if (row.manualQuantity === undefined) {
|
|
throw new Error("External object new-row command requires manual quantity.");
|
|
}
|
|
return row;
|
|
}
|
|
|
|
function parseObject(value: unknown): ExternalModelObjectSnapshot {
|
|
return parseExternalModelStateSnapshot({
|
|
source: null,
|
|
importBatches: [],
|
|
roomMappings: [],
|
|
objects: [value],
|
|
}).objects[0]!;
|
|
}
|
|
|
|
function assertOnlyAssignmentChanged(
|
|
expected: ExternalModelObjectSnapshot,
|
|
target: ExternalModelObjectSnapshot
|
|
) {
|
|
const expectedRest = { ...expected, circuitDeviceRowId: null };
|
|
const targetRest = { ...target, circuitDeviceRowId: null };
|
|
if (canonicalJson(expectedRest) !== canonicalJson(targetRest)) {
|
|
throw new Error("New-row assignment may only change the external object row link.");
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|