Add device row history commands
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitDeviceRowDeleteProjectCommand,
|
||||
assertCircuitDeviceRowInsertProjectCommand,
|
||||
circuitDeviceRowDeleteCommandType,
|
||||
circuitDeviceRowInsertCommandType,
|
||||
createCircuitDeviceRowDeleteProjectCommand,
|
||||
createCircuitDeviceRowInsertProjectCommand,
|
||||
type CircuitDeviceRowSnapshot,
|
||||
type CircuitDeviceRowStructureProjectCommand,
|
||||
} from "../../domain/models/circuit-device-row-structure-project-command.model.js";
|
||||
import type {
|
||||
CircuitDeviceRowStructureProjectCommandStore,
|
||||
ExecuteCircuitDeviceRowStructureCommandInput,
|
||||
} from "../../domain/ports/circuit-device-row-structure-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { projectDevices } from "../schema/project-devices.js";
|
||||
import { rooms } from "../schema/rooms.js";
|
||||
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
|
||||
import { appendProjectRevision } from "./project-revision.persistence.js";
|
||||
|
||||
export class CircuitDeviceRowStructureProjectCommandRepository
|
||||
implements CircuitDeviceRowStructureProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitDeviceRowStructureCommandInput) {
|
||||
return this.database.transaction((tx) => {
|
||||
const inverse = this.applyCommand(
|
||||
tx,
|
||||
input.projectId,
|
||||
input.source,
|
||||
input.command
|
||||
);
|
||||
const revision = appendProjectRevision(tx, {
|
||||
projectId: input.projectId,
|
||||
expectedRevision: input.expectedRevision,
|
||||
source: input.source,
|
||||
description: input.description,
|
||||
actorId: input.actorId,
|
||||
forward: input.command,
|
||||
inverse,
|
||||
});
|
||||
applyProjectHistoryTransition(tx, {
|
||||
projectId: input.projectId,
|
||||
source: input.source,
|
||||
recordedChangeSetId: revision.changeSetId,
|
||||
targetChangeSetId: input.historyTargetChangeSetId,
|
||||
});
|
||||
return { revision, inverse };
|
||||
});
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
|
||||
command: CircuitDeviceRowStructureProjectCommand
|
||||
): CircuitDeviceRowStructureProjectCommand {
|
||||
if (command.type === circuitDeviceRowInsertCommandType) {
|
||||
assertCircuitDeviceRowInsertProjectCommand(command);
|
||||
return this.insert(
|
||||
database,
|
||||
projectId,
|
||||
source,
|
||||
command.payload.row
|
||||
);
|
||||
}
|
||||
if (command.type === circuitDeviceRowDeleteCommandType) {
|
||||
assertCircuitDeviceRowDeleteProjectCommand(command);
|
||||
return this.delete(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.rowId,
|
||||
command.payload.expectedCircuitId
|
||||
);
|
||||
}
|
||||
throw new Error("Unsupported circuit device-row structure command.");
|
||||
}
|
||||
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
source: ExecuteCircuitDeviceRowStructureCommandInput["source"],
|
||||
row: CircuitDeviceRowSnapshot
|
||||
) {
|
||||
if (source === "user" && row.legacyConsumerId !== null) {
|
||||
throw new Error(
|
||||
"User commands cannot assign legacy consumer ids."
|
||||
);
|
||||
}
|
||||
this.assertCircuitInProject(database, projectId, row.circuitId);
|
||||
this.assertReferencesInProject(database, projectId, row);
|
||||
|
||||
const existing = database
|
||||
.select({ id: circuitDeviceRows.id })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, row.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Circuit device-row id already exists.");
|
||||
}
|
||||
|
||||
database.insert(circuitDeviceRows).values(row).run();
|
||||
const circuitUpdate = database
|
||||
.update(circuits)
|
||||
.set({ isReserve: 0 })
|
||||
.where(eq(circuits.id, row.circuitId))
|
||||
.run();
|
||||
if (circuitUpdate.changes !== 1) {
|
||||
throw new Error("Circuit changed before device-row insertion.");
|
||||
}
|
||||
|
||||
return createCircuitDeviceRowDeleteProjectCommand(
|
||||
row.id,
|
||||
row.circuitId
|
||||
);
|
||||
}
|
||||
|
||||
private delete(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
rowId: string,
|
||||
expectedCircuitId: string
|
||||
) {
|
||||
const row = database
|
||||
.select()
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.id, rowId))
|
||||
.get();
|
||||
if (!row || row.circuitId !== expectedCircuitId) {
|
||||
throw new Error(
|
||||
"Circuit device row changed before command execution."
|
||||
);
|
||||
}
|
||||
this.assertCircuitInProject(database, projectId, row.circuitId);
|
||||
|
||||
const result = database
|
||||
.delete(circuitDeviceRows)
|
||||
.where(
|
||||
and(
|
||||
eq(circuitDeviceRows.id, rowId),
|
||||
eq(circuitDeviceRows.circuitId, expectedCircuitId)
|
||||
)
|
||||
)
|
||||
.run();
|
||||
if (result.changes !== 1) {
|
||||
throw new Error("Circuit device row could not be deleted.");
|
||||
}
|
||||
|
||||
const remainingRow = database
|
||||
.select({ id: circuitDeviceRows.id })
|
||||
.from(circuitDeviceRows)
|
||||
.where(eq(circuitDeviceRows.circuitId, expectedCircuitId))
|
||||
.limit(1)
|
||||
.get();
|
||||
const circuitUpdate = database
|
||||
.update(circuits)
|
||||
.set({ isReserve: remainingRow ? 0 : 1 })
|
||||
.where(eq(circuits.id, expectedCircuitId))
|
||||
.run();
|
||||
if (circuitUpdate.changes !== 1) {
|
||||
throw new Error("Circuit changed before device-row deletion.");
|
||||
}
|
||||
|
||||
return createCircuitDeviceRowInsertProjectCommand(
|
||||
toCircuitDeviceRowSnapshot(row)
|
||||
);
|
||||
}
|
||||
|
||||
private assertCircuitInProject(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitId: string
|
||||
) {
|
||||
const circuit = database
|
||||
.select({ id: circuits.id })
|
||||
.from(circuits)
|
||||
.innerJoin(
|
||||
circuitLists,
|
||||
eq(circuitLists.id, circuits.circuitListId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(circuits.id, circuitId),
|
||||
eq(circuitLists.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!circuit) {
|
||||
throw new Error("Circuit does not belong to project.");
|
||||
}
|
||||
}
|
||||
|
||||
private assertReferencesInProject(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
row: CircuitDeviceRowSnapshot
|
||||
) {
|
||||
if (row.linkedProjectDeviceId !== null) {
|
||||
const device = database
|
||||
.select({ id: projectDevices.id })
|
||||
.from(projectDevices)
|
||||
.where(
|
||||
and(
|
||||
eq(projectDevices.id, row.linkedProjectDeviceId),
|
||||
eq(projectDevices.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!device) {
|
||||
throw new Error("Invalid linked project device id.");
|
||||
}
|
||||
}
|
||||
if (row.roomId !== null) {
|
||||
const room = database
|
||||
.select({ id: rooms.id })
|
||||
.from(rooms)
|
||||
.where(
|
||||
and(
|
||||
eq(rooms.id, row.roomId),
|
||||
eq(rooms.projectId, projectId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
if (!room) {
|
||||
throw new Error("Invalid room id.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toCircuitDeviceRowSnapshot(
|
||||
row: typeof circuitDeviceRows.$inferSelect
|
||||
): CircuitDeviceRowSnapshot {
|
||||
return {
|
||||
id: row.id,
|
||||
circuitId: row.circuitId,
|
||||
linkedProjectDeviceId: row.linkedProjectDeviceId,
|
||||
legacyConsumerId: row.legacyConsumerId,
|
||||
sortOrder: row.sortOrder,
|
||||
name: row.name,
|
||||
displayName: row.displayName,
|
||||
phaseType: row.phaseType,
|
||||
connectionKind: row.connectionKind,
|
||||
costGroup: row.costGroup,
|
||||
category: row.category,
|
||||
level: row.level,
|
||||
roomId: row.roomId,
|
||||
roomNumberSnapshot: row.roomNumberSnapshot,
|
||||
roomNameSnapshot: row.roomNameSnapshot,
|
||||
quantity: row.quantity,
|
||||
powerPerUnit: row.powerPerUnit,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi,
|
||||
remark: row.remark,
|
||||
overriddenFields: row.overriddenFields,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitDeviceRowInsertCommandType =
|
||||
"circuit-device-row.insert" as const;
|
||||
export const circuitDeviceRowDeleteCommandType =
|
||||
"circuit-device-row.delete" as const;
|
||||
export const circuitDeviceRowStructureCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface CircuitDeviceRowSnapshot {
|
||||
id: string;
|
||||
circuitId: string;
|
||||
linkedProjectDeviceId: string | null;
|
||||
legacyConsumerId: string | null;
|
||||
sortOrder: number;
|
||||
name: string;
|
||||
displayName: string;
|
||||
phaseType: string | null;
|
||||
connectionKind: string | null;
|
||||
costGroup: string | null;
|
||||
category: string | null;
|
||||
level: string | null;
|
||||
roomId: string | null;
|
||||
roomNumberSnapshot: string | null;
|
||||
roomNameSnapshot: string | null;
|
||||
quantity: number;
|
||||
powerPerUnit: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi: number | null;
|
||||
remark: string | null;
|
||||
overriddenFields: string | null;
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowInsertCommandPayload {
|
||||
row: CircuitDeviceRowSnapshot;
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowDeleteCommandPayload {
|
||||
rowId: string;
|
||||
expectedCircuitId: string;
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowInsertProjectCommand
|
||||
extends SerializedProjectCommand<CircuitDeviceRowInsertCommandPayload> {
|
||||
schemaVersion: typeof circuitDeviceRowStructureCommandSchemaVersion;
|
||||
type: typeof circuitDeviceRowInsertCommandType;
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowDeleteProjectCommand
|
||||
extends SerializedProjectCommand<CircuitDeviceRowDeleteCommandPayload> {
|
||||
schemaVersion: typeof circuitDeviceRowStructureCommandSchemaVersion;
|
||||
type: typeof circuitDeviceRowDeleteCommandType;
|
||||
}
|
||||
|
||||
export type CircuitDeviceRowStructureProjectCommand =
|
||||
| CircuitDeviceRowInsertProjectCommand
|
||||
| CircuitDeviceRowDeleteProjectCommand;
|
||||
|
||||
export function createCircuitDeviceRowInsertProjectCommand(
|
||||
row: CircuitDeviceRowSnapshot
|
||||
): CircuitDeviceRowInsertProjectCommand {
|
||||
const command: CircuitDeviceRowInsertProjectCommand = {
|
||||
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
|
||||
type: circuitDeviceRowInsertCommandType,
|
||||
payload: { row },
|
||||
};
|
||||
assertCircuitDeviceRowInsertProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createCircuitDeviceRowDeleteProjectCommand(
|
||||
rowId: string,
|
||||
expectedCircuitId: string
|
||||
): CircuitDeviceRowDeleteProjectCommand {
|
||||
const command: CircuitDeviceRowDeleteProjectCommand = {
|
||||
schemaVersion: circuitDeviceRowStructureCommandSchemaVersion,
|
||||
type: circuitDeviceRowDeleteCommandType,
|
||||
payload: { rowId, expectedCircuitId },
|
||||
};
|
||||
assertCircuitDeviceRowDeleteProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitDeviceRowInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitDeviceRowInsertProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !==
|
||||
circuitDeviceRowStructureCommandSchemaVersion ||
|
||||
command.type !== circuitDeviceRowInsertCommandType
|
||||
) {
|
||||
throw new Error("Unsupported circuit device-row insert command.");
|
||||
}
|
||||
if (!isPlainObject(command.payload)) {
|
||||
throw new Error("Circuit device-row insert payload must be an object.");
|
||||
}
|
||||
const row = command.payload.row;
|
||||
if (!isPlainObject(row)) {
|
||||
throw new Error("Circuit device-row insert command requires a row.");
|
||||
}
|
||||
|
||||
assertNonEmptyString(row.id, "row.id");
|
||||
assertNonEmptyString(row.circuitId, "row.circuitId");
|
||||
assertNullableString(row.linkedProjectDeviceId, "row.linkedProjectDeviceId");
|
||||
assertNullableString(row.legacyConsumerId, "row.legacyConsumerId");
|
||||
assertFiniteNumber(row.sortOrder, "row.sortOrder");
|
||||
assertNonEmptyString(row.name, "row.name");
|
||||
assertNonEmptyString(row.displayName, "row.displayName");
|
||||
for (const field of [
|
||||
"phaseType",
|
||||
"connectionKind",
|
||||
"costGroup",
|
||||
"category",
|
||||
"level",
|
||||
"roomId",
|
||||
"roomNumberSnapshot",
|
||||
"roomNameSnapshot",
|
||||
"remark",
|
||||
"overriddenFields",
|
||||
] as const) {
|
||||
assertNullableString(row[field], `row.${field}`);
|
||||
}
|
||||
assertNonNegativeNumber(row.quantity, "row.quantity");
|
||||
assertNonNegativeNumber(row.powerPerUnit, "row.powerPerUnit");
|
||||
assertNonNegativeNumber(
|
||||
row.simultaneityFactor,
|
||||
"row.simultaneityFactor"
|
||||
);
|
||||
if (row.cosPhi !== null) {
|
||||
assertFiniteNumber(row.cosPhi, "row.cosPhi");
|
||||
if (row.cosPhi <= 0) {
|
||||
throw new Error("row.cosPhi must be positive or null.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCircuitDeviceRowDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitDeviceRowDeleteProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !==
|
||||
circuitDeviceRowStructureCommandSchemaVersion ||
|
||||
command.type !== circuitDeviceRowDeleteCommandType
|
||||
) {
|
||||
throw new Error("Unsupported circuit device-row delete command.");
|
||||
}
|
||||
if (!isPlainObject(command.payload)) {
|
||||
throw new Error("Circuit device-row delete payload must be an object.");
|
||||
}
|
||||
assertNonEmptyString(command.payload.rowId, "rowId");
|
||||
assertNonEmptyString(
|
||||
command.payload.expectedCircuitId,
|
||||
"expectedCircuitId"
|
||||
);
|
||||
}
|
||||
|
||||
function assertNonEmptyString(value: unknown, field: string) {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${field} must be a non-empty string.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNullableString(value: unknown, field: string) {
|
||||
if (value !== null && typeof value !== "string") {
|
||||
throw new Error(`${field} must be a string or null.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFiniteNumber(
|
||||
value: unknown,
|
||||
field: string
|
||||
): asserts value is number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new Error(`${field} must be a finite number.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonNegativeNumber(value: unknown, field: string) {
|
||||
assertFiniteNumber(value, field);
|
||||
if (value < 0) {
|
||||
throw new Error(`${field} must not be negative.`);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { CircuitDeviceRowStructureProjectCommand } from "../models/circuit-device-row-structure-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitDeviceRowStructureCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitDeviceRowStructureProjectCommand;
|
||||
}
|
||||
|
||||
export interface ExecutedCircuitDeviceRowStructureCommand {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitDeviceRowStructureProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitDeviceRowStructureProjectCommandStore {
|
||||
execute(
|
||||
input: ExecuteCircuitDeviceRowStructureCommandInput
|
||||
): ExecutedCircuitDeviceRowStructureCommand;
|
||||
}
|
||||
@@ -3,6 +3,12 @@ import {
|
||||
assertCircuitDeviceRowUpdateProjectCommand,
|
||||
circuitDeviceRowUpdateCommandType,
|
||||
} from "../models/circuit-device-row-project-command.model.js";
|
||||
import {
|
||||
assertCircuitDeviceRowDeleteProjectCommand,
|
||||
assertCircuitDeviceRowInsertProjectCommand,
|
||||
circuitDeviceRowDeleteCommandType,
|
||||
circuitDeviceRowInsertCommandType,
|
||||
} from "../models/circuit-device-row-structure-project-command.model.js";
|
||||
import {
|
||||
assertCircuitUpdateProjectCommand,
|
||||
circuitUpdateCommandType,
|
||||
@@ -12,6 +18,7 @@ import {
|
||||
type SerializedProjectCommand,
|
||||
} from "../models/project-command.model.js";
|
||||
import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js";
|
||||
import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
|
||||
import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
|
||||
import type {
|
||||
ExecuteProjectHistoryCommandInput,
|
||||
@@ -39,6 +46,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
constructor(
|
||||
private readonly circuitStore: CircuitProjectCommandStore,
|
||||
private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore,
|
||||
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
|
||||
private readonly historyStore: ProjectHistoryStore
|
||||
) {}
|
||||
|
||||
@@ -118,6 +126,20 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitDeviceRowInsertCommandType: {
|
||||
assertCircuitDeviceRowInsertProjectCommand(input.command);
|
||||
return this.deviceRowStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitDeviceRowDeleteCommandType: {
|
||||
assertCircuitDeviceRowDeleteProjectCommand(input.command);
|
||||
return this.deviceRowStructureStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported project command type: ${input.command.type}.`
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from "../../db/client.js";
|
||||
import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js";
|
||||
import { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-project-command.repository.js";
|
||||
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
|
||||
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
|
||||
@@ -7,9 +8,12 @@ import { ProjectCommandService } from "../../domain/services/project-command.ser
|
||||
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||
export const circuitDeviceRowProjectCommandStore =
|
||||
new CircuitDeviceRowProjectCommandRepository(db);
|
||||
export const circuitDeviceRowStructureProjectCommandStore =
|
||||
new CircuitDeviceRowStructureProjectCommandRepository(db);
|
||||
export const projectHistoryStore = new ProjectHistoryRepository(db);
|
||||
export const projectCommandService = new ProjectCommandService(
|
||||
circuitProjectCommandStore,
|
||||
circuitDeviceRowProjectCommandStore,
|
||||
circuitDeviceRowStructureProjectCommandStore,
|
||||
projectHistoryStore
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user