134 lines
5 KiB
TypeScript
134 lines
5 KiB
TypeScript
import type { ExternalModelStateSnapshot } from "../../external-model/domain/external-model-contracts.js";
|
|
import { parseExternalModelStateSnapshot } from "./project-state-snapshot.model.js";
|
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
|
|
|
export const externalInitialImportCommandType =
|
|
"external-import.apply-initial" as const;
|
|
export const externalInitialImportCommandSchemaVersion = 1 as const;
|
|
|
|
export interface ExternalInitialImportPayload {
|
|
expected: ExternalModelStateSnapshot;
|
|
target: ExternalModelStateSnapshot;
|
|
}
|
|
|
|
export interface ExternalInitialImportProjectCommand
|
|
extends SerializedProjectCommand<ExternalInitialImportPayload> {
|
|
schemaVersion: typeof externalInitialImportCommandSchemaVersion;
|
|
type: typeof externalInitialImportCommandType;
|
|
}
|
|
|
|
export function createExternalInitialImportProjectCommand(
|
|
expected: ExternalModelStateSnapshot,
|
|
target: ExternalModelStateSnapshot
|
|
): ExternalInitialImportProjectCommand {
|
|
const command: ExternalInitialImportProjectCommand = {
|
|
schemaVersion: externalInitialImportCommandSchemaVersion,
|
|
type: externalInitialImportCommandType,
|
|
payload: { expected, target },
|
|
};
|
|
assertExternalInitialImportProjectCommand(command);
|
|
return command;
|
|
}
|
|
|
|
export function assertExternalInitialImportProjectCommand(
|
|
command: SerializedProjectCommand<unknown>
|
|
): asserts command is ExternalInitialImportProjectCommand {
|
|
if (
|
|
command.schemaVersion !== externalInitialImportCommandSchemaVersion ||
|
|
command.type !== externalInitialImportCommandType ||
|
|
!isRecord(command.payload) ||
|
|
Object.keys(command.payload).length !== 2
|
|
) {
|
|
throw new Error("Unsupported external initial import command.");
|
|
}
|
|
const expected = parseExternalModelStateSnapshot(command.payload.expected);
|
|
const target = parseExternalModelStateSnapshot(command.payload.target);
|
|
const expectedIsEmpty = isEmptyExternalModelState(expected);
|
|
const targetIsEmpty = isEmptyExternalModelState(target);
|
|
if (expectedIsEmpty === targetIsEmpty) {
|
|
throw new Error("External initial import must transition between empty and populated state.");
|
|
}
|
|
assertPopulatedInitialState(expectedIsEmpty ? target : expected);
|
|
}
|
|
|
|
export function createEmptyExternalModelState(): ExternalModelStateSnapshot {
|
|
return { source: null, importBatches: [], roomMappings: [], objects: [] };
|
|
}
|
|
|
|
export function isEmptyExternalModelState(state: ExternalModelStateSnapshot) {
|
|
return (
|
|
state.source === null &&
|
|
state.importBatches.length === 0 &&
|
|
state.roomMappings.length === 0 &&
|
|
state.objects.length === 0
|
|
);
|
|
}
|
|
|
|
function assertPopulatedInitialState(state: ExternalModelStateSnapshot) {
|
|
if (state.source === null || state.importBatches.length !== 1 || state.objects.length === 0) {
|
|
throw new Error("External initial import requires one source, one batch and at least one object.");
|
|
}
|
|
const source = state.source;
|
|
const batch = state.importBatches[0];
|
|
if (
|
|
batch.importKind !== "initial" ||
|
|
batch.projectId !== source.projectId ||
|
|
batch.sourceId !== source.id
|
|
) {
|
|
throw new Error("External initial import batch identity is invalid.");
|
|
}
|
|
const mappingIds = uniqueIds(state.roomMappings, "room mapping");
|
|
const roomKeys = new Set<string>();
|
|
for (const mapping of state.roomMappings) {
|
|
if (mapping.projectId !== source.projectId || mapping.sourceId !== source.id) {
|
|
throw new Error("External initial room mapping identity is invalid.");
|
|
}
|
|
if (roomKeys.has(mapping.normalizedSourceRoomKey)) {
|
|
throw new Error("External initial import contains duplicate room keys.");
|
|
}
|
|
roomKeys.add(mapping.normalizedSourceRoomKey);
|
|
}
|
|
uniqueIds(state.objects, "object");
|
|
const ifcGuids = new Set<string>();
|
|
for (const object of state.objects) {
|
|
if (
|
|
object.projectId !== source.projectId ||
|
|
object.sourceId !== source.id ||
|
|
object.lastSeenImportBatchId !== batch.id ||
|
|
object.lastAcceptedImportBatchId !== batch.id
|
|
) {
|
|
throw new Error("External initial object identity is invalid.");
|
|
}
|
|
if (ifcGuids.has(object.ifcGuid)) {
|
|
throw new Error("External initial import contains duplicate IFCGUIDs.");
|
|
}
|
|
ifcGuids.add(object.ifcGuid);
|
|
if (
|
|
object.externalRoomMappingId !== null &&
|
|
!mappingIds.has(object.externalRoomMappingId)
|
|
) {
|
|
throw new Error("External initial object room mapping is invalid.");
|
|
}
|
|
if (object.circuitDeviceRowId !== null) {
|
|
throw new Error("External initial import must not assign circuit device rows.");
|
|
}
|
|
if (object.presenceStatus !== "present") {
|
|
throw new Error("External initial import objects must be present.");
|
|
}
|
|
}
|
|
}
|
|
|
|
function uniqueIds(entries: ReadonlyArray<{ id: string }>, label: string) {
|
|
const ids = new Set<string>();
|
|
for (const entry of entries) {
|
|
if (ids.has(entry.id)) {
|
|
throw new Error(`External initial import contains duplicate ${label} ids.`);
|
|
}
|
|
ids.add(entry.id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|