Add section renumber history

This commit is contained in:
2026-07-24 08:34:31 +02:00
parent 332dfdb5d9
commit 4b4603b71b
14 changed files with 1057 additions and 14 deletions
@@ -0,0 +1,242 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitSectionRenumberProjectCommand,
createCircuitSectionRenumberProjectCommand,
type CircuitSectionRenumberAssignment,
} from "../../domain/models/circuit-section-renumber-project-command.model.js";
import type {
CircuitSectionRenumberProjectCommandStore,
ExecuteCircuitSectionRenumberCommandInput,
} from "../../domain/ports/circuit-section-renumber-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitSections } from "../schema/circuit-sections.js";
import { circuits } from "../schema/circuits.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js";
export class CircuitSectionRenumberProjectCommandRepository
implements CircuitSectionRenumberProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitSectionRenumberCommandInput) {
assertCircuitSectionRenumberProjectCommand(input.command);
return this.database.transaction((tx) => {
const section = tx
.select({
id: circuitSections.id,
circuitListId: circuitSections.circuitListId,
projectId: circuitLists.projectId,
})
.from(circuitSections)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuitSections.circuitListId)
)
.where(eq(circuitSections.id, input.command.payload.sectionId))
.get();
if (!section || section.projectId !== input.projectId) {
throw new Error(
"Circuit section does not belong to project."
);
}
const sectionCircuits = tx
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
equipmentIdentifier: circuits.equipmentIdentifier,
})
.from(circuits)
.where(eq(circuits.sectionId, section.id))
.all();
const assignments = input.command.payload.assignments;
if (
sectionCircuits.length !== assignments.length ||
sectionCircuits.some(
(circuit) =>
circuit.circuitListId !== section.circuitListId
)
) {
throw new Error(
"Circuit renumber must include every circuit in the section."
);
}
const circuitsById = new Map(
sectionCircuits.map((circuit) => [circuit.id, circuit])
);
for (const assignment of assignments) {
const circuit = circuitsById.get(assignment.circuitId);
if (
!circuit ||
circuit.equipmentIdentifier !==
assignment.expectedEquipmentIdentifier
) {
throw new Error(
"Circuit changed before section renumber execution."
);
}
}
const listCircuits = tx
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
})
.from(circuits)
.where(eq(circuits.circuitListId, section.circuitListId))
.all();
const sectionCircuitIds = new Set(
assignments.map((assignment) => assignment.circuitId)
);
const targetIdentifiers = new Set(
assignments.map(
(assignment) => assignment.targetEquipmentIdentifier
)
);
const conflictingCircuit = listCircuits.find(
(circuit) =>
!sectionCircuitIds.has(circuit.id) &&
targetIdentifiers.has(circuit.equipmentIdentifier)
);
if (conflictingCircuit) {
throw new Error(
"Target equipment identifier already exists in circuit list."
);
}
const inverse = createCircuitSectionRenumberProjectCommand(
section.id,
assignments.map((assignment) => ({
circuitId: assignment.circuitId,
expectedEquipmentIdentifier:
assignment.targetEquipmentIdentifier,
targetEquipmentIdentifier:
assignment.expectedEquipmentIdentifier,
}))
);
const temporaryIdentifiers = this.createTemporaryIdentifiers(
section.id,
assignments,
listCircuits.map((circuit) => circuit.equipmentIdentifier)
);
for (const assignment of assignments) {
if (
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
) {
continue;
}
const temporaryIdentifier = temporaryIdentifiers.get(
assignment.circuitId
)!;
const updated = tx
.update(circuits)
.set({ equipmentIdentifier: temporaryIdentifier })
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, section.id),
eq(circuits.circuitListId, section.circuitListId),
eq(
circuits.equipmentIdentifier,
assignment.expectedEquipmentIdentifier
)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during section renumber execution."
);
}
}
for (const assignment of assignments) {
if (
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
) {
continue;
}
const temporaryIdentifier = temporaryIdentifiers.get(
assignment.circuitId
)!;
const updated = tx
.update(circuits)
.set({
equipmentIdentifier:
assignment.targetEquipmentIdentifier,
})
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, section.id),
eq(circuits.circuitListId, section.circuitListId),
eq(
circuits.equipmentIdentifier,
temporaryIdentifier
)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during final section renumber 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 };
});
}
private createTemporaryIdentifiers(
sectionId: string,
assignments: CircuitSectionRenumberAssignment[],
occupiedIdentifiers: string[]
) {
const occupied = new Set([
...occupiedIdentifiers,
...assignments.map(
(assignment) => assignment.targetEquipmentIdentifier
),
]);
const temporaryIdentifiers = new Map<string, string>();
for (let index = 0; index < assignments.length; index += 1) {
const assignment = assignments[index];
if (
assignment.expectedEquipmentIdentifier ===
assignment.targetEquipmentIdentifier
) {
continue;
}
const base = `__tmp_renumber_command_${sectionId}_${index}`;
let candidate = base;
while (occupied.has(candidate)) {
candidate = `${candidate}_`;
}
occupied.add(candidate);
temporaryIdentifiers.set(assignment.circuitId, candidate);
}
return temporaryIdentifiers;
}
}
@@ -0,0 +1,130 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitSectionRenumberCommandType =
"circuit.renumber-section" as const;
export const circuitSectionRenumberCommandSchemaVersion = 1 as const;
export interface CircuitSectionRenumberAssignment {
circuitId: string;
expectedEquipmentIdentifier: string;
targetEquipmentIdentifier: string;
}
export interface CircuitSectionRenumberCommandPayload {
sectionId: string;
assignments: CircuitSectionRenumberAssignment[];
}
export interface CircuitSectionRenumberProjectCommand
extends SerializedProjectCommand<CircuitSectionRenumberCommandPayload> {
schemaVersion: typeof circuitSectionRenumberCommandSchemaVersion;
type: typeof circuitSectionRenumberCommandType;
}
export function createCircuitSectionRenumberProjectCommand(
sectionId: string,
assignments: CircuitSectionRenumberAssignment[]
): CircuitSectionRenumberProjectCommand {
const command: CircuitSectionRenumberProjectCommand = {
schemaVersion: circuitSectionRenumberCommandSchemaVersion,
type: circuitSectionRenumberCommandType,
payload: { sectionId, assignments },
};
assertCircuitSectionRenumberProjectCommand(command);
return command;
}
export function assertCircuitSectionRenumberProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitSectionRenumberProjectCommand {
if (
command.schemaVersion !==
circuitSectionRenumberCommandSchemaVersion ||
command.type !== circuitSectionRenumberCommandType
) {
throw new Error("Unsupported circuit section renumber command.");
}
if (!isPlainObject(command.payload)) {
throw new Error(
"Circuit section renumber payload must be an object."
);
}
const { sectionId, assignments } = command.payload;
assertNonEmptyString(sectionId, "sectionId");
if (!Array.isArray(assignments) || assignments.length === 0) {
throw new Error(
"Circuit section renumber command requires assignments."
);
}
const circuitIds = new Set<string>();
const expectedIdentifiers = new Set<string>();
const targetIdentifiers = new Set<string>();
let hasChangedIdentifier = false;
for (const assignment of assignments) {
if (!isPlainObject(assignment)) {
throw new Error(
"Circuit section renumber command contains an invalid assignment."
);
}
assertNonEmptyString(assignment.circuitId, "circuitId");
assertNonEmptyString(
assignment.expectedEquipmentIdentifier,
"expectedEquipmentIdentifier"
);
assertNonEmptyString(
assignment.targetEquipmentIdentifier,
"targetEquipmentIdentifier"
);
if (circuitIds.has(assignment.circuitId)) {
throw new Error(
"Circuit section renumber command contains duplicate circuit ids."
);
}
if (
expectedIdentifiers.has(
assignment.expectedEquipmentIdentifier
)
) {
throw new Error(
"Circuit section renumber command contains duplicate expected identifiers."
);
}
if (
targetIdentifiers.has(assignment.targetEquipmentIdentifier)
) {
throw new Error(
"Circuit section renumber command contains duplicate target identifiers."
);
}
if (
assignment.expectedEquipmentIdentifier !==
assignment.targetEquipmentIdentifier
) {
hasChangedIdentifier = true;
}
circuitIds.add(assignment.circuitId);
expectedIdentifiers.add(
assignment.expectedEquipmentIdentifier
);
targetIdentifiers.add(assignment.targetEquipmentIdentifier);
}
if (!hasChangedIdentifier) {
throw new Error(
"Circuit section renumber command must change at least one identifier."
);
}
}
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 isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,26 @@
import type { CircuitSectionRenumberProjectCommand } from "../models/circuit-section-renumber-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitSectionRenumberCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitSectionRenumberProjectCommand;
}
export interface ExecutedCircuitSectionRenumberCommand {
revision: AppendedProjectRevision;
inverse: CircuitSectionRenumberProjectCommand;
}
export interface CircuitSectionRenumberProjectCommandStore {
execute(
input: ExecuteCircuitSectionRenumberCommandInput
): ExecutedCircuitSectionRenumberCommand;
}
@@ -23,6 +23,10 @@ import {
assertCircuitSectionReorderProjectCommand,
circuitSectionReorderCommandType,
} from "../models/circuit-section-reorder-project-command.model.js";
import {
assertCircuitSectionRenumberProjectCommand,
circuitSectionRenumberCommandType,
} from "../models/circuit-section-renumber-project-command.model.js";
import {
assertCircuitDeleteProjectCommand,
assertCircuitInsertProjectCommand,
@@ -38,6 +42,7 @@ import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-d
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 { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
import type {
ExecuteProjectHistoryCommandInput,
@@ -69,6 +74,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
@@ -199,6 +205,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case circuitSectionRenumberCommandType: {
assertCircuitSectionRenumberProjectCommand(input.command);
return this.circuitSectionRenumberStore.execute({
...input,
command: input.command,
}).revision;
}
default:
throw new Error(
`Unsupported project command type: ${input.command.type}.`
@@ -4,6 +4,7 @@ import { CircuitDeviceRowMoveProjectCommandRepository } from "../../db/repositor
import { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-project-command.repository.js";
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
@@ -19,6 +20,8 @@ export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db);
export const circuitSectionReorderProjectCommandStore =
new CircuitSectionReorderProjectCommandRepository(db);
export const circuitSectionRenumberProjectCommandStore =
new CircuitSectionRenumberProjectCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
@@ -27,5 +30,6 @@ export const projectCommandService = new ProjectCommandService(
circuitDeviceRowMoveProjectCommandStore,
circuitStructureProjectCommandStore,
circuitSectionReorderProjectCommandStore,
circuitSectionRenumberProjectCommandStore,
projectHistoryStore
);