Add device row move history

This commit is contained in:
2026-07-23 22:29:44 +02:00
parent ca13efbfcb
commit 0a51703315
14 changed files with 940 additions and 13 deletions
@@ -0,0 +1,181 @@
import { and, eq, inArray } from "drizzle-orm";
import {
assertCircuitDeviceRowMoveProjectCommand,
createCircuitDeviceRowMoveProjectCommand,
} from "../../domain/models/circuit-device-row-move-project-command.model.js";
import type {
CircuitDeviceRowMoveProjectCommandStore,
ExecuteCircuitDeviceRowMoveCommandInput,
} from "../../domain/ports/circuit-device-row-move-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 { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class CircuitDeviceRowMoveProjectCommandRepository
implements CircuitDeviceRowMoveProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitDeviceRowMoveCommandInput) {
assertCircuitDeviceRowMoveProjectCommand(input.command);
return this.database.transaction((tx) => {
const rowIds = input.command.payload.moves.map(
(move) => move.rowId
);
const persistedRows = tx
.select({
id: circuitDeviceRows.id,
circuitId: circuitDeviceRows.circuitId,
sortOrder: circuitDeviceRows.sortOrder,
})
.from(circuitDeviceRows)
.where(inArray(circuitDeviceRows.id, rowIds))
.all();
if (persistedRows.length !== rowIds.length) {
throw new Error(
"One or more circuit device rows do not exist."
);
}
const rowsById = new Map(
persistedRows.map((row) => [row.id, row])
);
for (const move of input.command.payload.moves) {
const row = rowsById.get(move.rowId);
if (
!row ||
row.circuitId !== move.expectedCircuitId ||
row.sortOrder !== move.expectedSortOrder
) {
throw new Error(
"Circuit device row changed before move execution."
);
}
}
const circuitIds = [
...new Set(
input.command.payload.moves.flatMap((move) => [
move.expectedCircuitId,
move.targetCircuitId,
])
),
];
const participatingCircuits = tx
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
projectId: circuitLists.projectId,
})
.from(circuits)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuits.circuitListId)
)
.where(inArray(circuits.id, circuitIds))
.all();
if (participatingCircuits.length !== circuitIds.length) {
throw new Error(
"One or more move circuits do not exist."
);
}
if (
participatingCircuits.some(
(circuit) => circuit.projectId !== input.projectId
)
) {
throw new Error("Move circuit does not belong to project.");
}
const circuitListIds = new Set(
participatingCircuits.map(
(circuit) => circuit.circuitListId
)
);
if (circuitListIds.size !== 1) {
throw new Error(
"All moved rows and targets must belong to one circuit list."
);
}
const inverse = createCircuitDeviceRowMoveProjectCommand(
input.command.payload.moves.map((move) => {
const current = rowsById.get(move.rowId)!;
return {
rowId: move.rowId,
expectedCircuitId: move.targetCircuitId,
expectedSortOrder: move.targetSortOrder,
targetCircuitId: current.circuitId,
targetSortOrder: current.sortOrder,
};
})
);
for (const move of input.command.payload.moves) {
const result = tx
.update(circuitDeviceRows)
.set({
circuitId: move.targetCircuitId,
sortOrder: move.targetSortOrder,
})
.where(
and(
eq(circuitDeviceRows.id, move.rowId),
eq(
circuitDeviceRows.circuitId,
move.expectedCircuitId
),
eq(
circuitDeviceRows.sortOrder,
move.expectedSortOrder
)
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"Circuit device row changed during move execution."
);
}
}
for (const circuitId of circuitIds) {
const remainingRow = tx
.select({ id: circuitDeviceRows.id })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.circuitId, circuitId))
.limit(1)
.get();
const updated = tx
.update(circuits)
.set({ isReserve: remainingRow ? 0 : 1 })
.where(eq(circuits.id, circuitId))
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during device-row move execution."
);
}
}
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 };
});
}
}
@@ -0,0 +1,114 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitDeviceRowMoveCommandType =
"circuit-device-row.move" as const;
export const circuitDeviceRowMoveCommandSchemaVersion = 1 as const;
export interface CircuitDeviceRowMoveAssignment {
rowId: string;
expectedCircuitId: string;
expectedSortOrder: number;
targetCircuitId: string;
targetSortOrder: number;
}
export interface CircuitDeviceRowMoveCommandPayload {
moves: CircuitDeviceRowMoveAssignment[];
}
export interface CircuitDeviceRowMoveProjectCommand
extends SerializedProjectCommand<CircuitDeviceRowMoveCommandPayload> {
schemaVersion: typeof circuitDeviceRowMoveCommandSchemaVersion;
type: typeof circuitDeviceRowMoveCommandType;
}
export function createCircuitDeviceRowMoveProjectCommand(
moves: CircuitDeviceRowMoveAssignment[]
): CircuitDeviceRowMoveProjectCommand {
const command: CircuitDeviceRowMoveProjectCommand = {
schemaVersion: circuitDeviceRowMoveCommandSchemaVersion,
type: circuitDeviceRowMoveCommandType,
payload: { moves },
};
assertCircuitDeviceRowMoveProjectCommand(command);
return command;
}
export function assertCircuitDeviceRowMoveProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitDeviceRowMoveProjectCommand {
if (
command.schemaVersion !== circuitDeviceRowMoveCommandSchemaVersion ||
command.type !== circuitDeviceRowMoveCommandType
) {
throw new Error("Unsupported circuit device-row move command.");
}
if (!isPlainObject(command.payload)) {
throw new Error(
"Circuit device-row move payload must be an object."
);
}
const moves = command.payload.moves;
if (!Array.isArray(moves) || moves.length === 0) {
throw new Error(
"Circuit device-row move command requires at least one move."
);
}
const rowIds = new Set<string>();
for (const move of moves) {
if (!isPlainObject(move)) {
throw new Error(
"Circuit device-row move command contains an invalid move."
);
}
assertNonEmptyString(move.rowId, "rowId");
assertNonEmptyString(
move.expectedCircuitId,
"expectedCircuitId"
);
assertFiniteNumber(
move.expectedSortOrder,
"expectedSortOrder"
);
assertNonEmptyString(move.targetCircuitId, "targetCircuitId");
assertFiniteNumber(move.targetSortOrder, "targetSortOrder");
if (rowIds.has(move.rowId)) {
throw new Error(
"Circuit device-row move command contains duplicate row ids."
);
}
if (
move.expectedCircuitId === move.targetCircuitId &&
move.expectedSortOrder === move.targetSortOrder
) {
throw new Error(
"Circuit device-row move command contains a no-op move."
);
}
rowIds.add(move.rowId);
}
}
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 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 isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,26 @@
import type { CircuitDeviceRowMoveProjectCommand } from "../models/circuit-device-row-move-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitDeviceRowMoveCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitDeviceRowMoveProjectCommand;
}
export interface ExecutedCircuitDeviceRowMoveCommand {
revision: AppendedProjectRevision;
inverse: CircuitDeviceRowMoveProjectCommand;
}
export interface CircuitDeviceRowMoveProjectCommandStore {
execute(
input: ExecuteCircuitDeviceRowMoveCommandInput
): ExecutedCircuitDeviceRowMoveCommand;
}
@@ -1,4 +1,8 @@
import { ProjectHistoryOperationUnavailableError } from "../errors/project-history-operation-unavailable.error.js";
import {
assertCircuitDeviceRowMoveProjectCommand,
circuitDeviceRowMoveCommandType,
} from "../models/circuit-device-row-move-project-command.model.js";
import {
assertCircuitDeviceRowUpdateProjectCommand,
circuitDeviceRowUpdateCommandType,
@@ -24,6 +28,7 @@ import {
type SerializedProjectCommand,
} from "../models/project-command.model.js";
import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js";
import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-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 { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
@@ -54,6 +59,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly circuitStore: CircuitProjectCommandStore,
private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore,
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
@@ -148,6 +154,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case circuitDeviceRowMoveCommandType: {
assertCircuitDeviceRowMoveProjectCommand(input.command);
return this.deviceRowMoveStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitInsertCommandType: {
assertCircuitInsertProjectCommand(input.command);
return this.circuitStructureStore.execute({
@@ -1,5 +1,6 @@
import { db } from "../../db/client.js";
import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js";
import { CircuitDeviceRowMoveProjectCommandRepository } from "../../db/repositories/circuit-device-row-move-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 { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
@@ -11,6 +12,8 @@ export const circuitDeviceRowProjectCommandStore =
new CircuitDeviceRowProjectCommandRepository(db);
export const circuitDeviceRowStructureProjectCommandStore =
new CircuitDeviceRowStructureProjectCommandRepository(db);
export const circuitDeviceRowMoveProjectCommandStore =
new CircuitDeviceRowMoveProjectCommandRepository(db);
export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db);
@@ -18,6 +21,7 @@ export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore,
circuitDeviceRowStructureProjectCommandStore,
circuitDeviceRowMoveProjectCommandStore,
circuitStructureProjectCommandStore,
projectHistoryStore
);