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
+3 -1
View File
@@ -230,7 +230,9 @@ circuits and moves that create one new placeholder target circuit are also
persisted. The latter stores the complete empty target snapshot so undo can persisted. The latter stores the complete empty target snapshot so undo can
restore the rows and remove only the unchanged generated circuit. Complete restore the rows and remove only the unchanged generated circuit. Complete
in-section Circuit reorders are persisted separately and change sort positions in-section Circuit reorders are persisted separately and change sort positions
without changing equipment identifiers. without changing equipment identifiers. Explicit complete-section renumbering
is persisted through a separate collision-safe command and is never triggered
by sorting or moving.
Required operations: Required operations:
+15 -7
View File
@@ -28,13 +28,14 @@ The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit-device-row.insert`, `circuit-device-row.delete` and `circuit-device-row.insert`, `circuit-device-row.delete` and
`circuit-device-row.move` plus `circuit-device-row.move` plus
`circuit-device-row.move-with-new-circuit` and `circuit.reorder-section`, all `circuit-device-row.move-with-new-circuit` and `circuit.reorder-section`, all
with schema version `1`. Other command types are rejected. Insert commands with `circuit.renumber-section`, all with schema version `1`. Other command
contain the complete entity or circuit block with stable ids; delete commands types are rejected. Insert commands contain the complete entity or circuit
include the expected parent identity. Circuit snapshots contain zero, one or block with stable ids; delete commands include the expected parent identity.
multiple complete device rows. Move commands contain each row's expected and Circuit snapshots contain zero, one or multiple complete device rows. Move
target circuit plus its exact expected and target sort order. This makes commands contain each row's expected and target circuit plus its exact expected
deletion and moves undoable without generating replacement identities or and target sort order. This makes deletion and moves undoable without
renumbering circuits. The editor does not consume these endpoints yet. generating replacement identities or renumbering circuits. The editor does not
consume these endpoints yet.
`circuit-device-row.move` targets existing circuits in the same circuit list. `circuit-device-row.move` targets existing circuits in the same circuit list.
`circuit-device-row.move-with-new-circuit` atomically creates exactly one `circuit-device-row.move-with-new-circuit` atomically creates exactly one
@@ -48,6 +49,13 @@ in the section. Each assignment records the expected and target `sortOrder`.
The command and its inverse change no circuit field other than `sortOrder`; The command and its inverse change no circuit field other than `sortOrder`;
equipment identifiers and complete device-row blocks remain unchanged. equipment identifiers and complete device-row blocks remain unchanged.
`circuit.renumber-section` is an explicit operation requiring one assignment
for every circuit in the section. Assignments contain expected and target
equipment identifiers. The store rejects stale values, duplicate targets and
targets occupied by other sections, then applies swaps through collision-safe
temporary identifiers. Undo restores the exact prior identifiers; sort
positions and device rows remain unchanged.
Example: Example:
```json ```json
+5 -2
View File
@@ -99,8 +99,11 @@ Felder und vollständiger Zeilenbestand unverändert sind.
`circuit.reorder-section` speichert die erwartete und neue Sortierposition `circuit.reorder-section` speichert die erwartete und neue Sortierposition
jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo
ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs
bleiben unverändert. Neunummerierung und Synchronisierung müssen vor der bleiben unverändert. `circuit.renumber-section` bildet die getrennte,
Frontend-Umstellung noch als persistente Commands modelliert werden. ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und
neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und
ändert weder Sortierung noch Gerätezeilen. Synchronisierung muss vor der
Frontend-Umstellung noch als persistentes Command modelliert werden.
## Projektgeräte ## Projektgeräte
@@ -189,13 +189,16 @@ Completed foundation:
- `circuit.reorder-section` requires the complete section circuit set and - `circuit.reorder-section` requires the complete section circuit set and
stores exact expected/target sort positions; its inverse changes no stores exact expected/target sort positions; its inverse changes no
equipment identifier and never splits a circuit block equipment identifier and never splits a circuit block
- `circuit.renumber-section` is the separate explicit renumber operation; it
stores every expected/target equipment identifier, uses collision-safe
temporary values and restores the exact prior identifiers on undo
Remaining constraints before completing persistent history: Remaining constraints before completing persistent history:
- extend the concrete project-scoped command union and executors beyond the - extend the concrete project-scoped command union and executors beyond the
Circuit field/insert/delete and CircuitDeviceRow Circuit field/insert/delete and CircuitDeviceRow
field/insert/delete/move plus section-reorder slices; the generic versioned field/insert/delete/move plus section-reorder/renumber slices; the generic
command envelope is complete versioned command envelope is complete
- route the remaining structural, synchronization and destructive domain - route the remaining structural, synchronization and destructive domain
writes through the shared command, revision and stack transaction boundary writes through the shared command, revision and stack transaction boundary
- replace the session-local frontend command stack only after server-side - replace the session-local frontend command stack only after server-side
@@ -403,6 +403,9 @@ Implemented foundation:
unchanged generated circuit, without renumbering unchanged generated circuit, without renumbering
- complete section reorders persist every expected and target circuit - complete section reorders persist every expected and target circuit
`sortOrder`; undo/redo changes neither equipment identifiers nor device rows `sortOrder`; undo/redo changes neither equipment identifiers nor device rows
- explicit section renumber commands persist every expected and target
equipment identifier, apply swaps collision-safely and restore exact prior
identifiers without changing sort positions or device rows
- revision metadata, change-set payloads and the project counter are committed - revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction in one SQLite transaction
- a stale expected revision produces no history writes - a stale expected revision produces no history writes
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"start": "node dist/server/index.js", "start": "node dist/server/index.js",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
@@ -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, assertCircuitSectionReorderProjectCommand,
circuitSectionReorderCommandType, circuitSectionReorderCommandType,
} from "../models/circuit-section-reorder-project-command.model.js"; } from "../models/circuit-section-reorder-project-command.model.js";
import {
assertCircuitSectionRenumberProjectCommand,
circuitSectionRenumberCommandType,
} from "../models/circuit-section-renumber-project-command.model.js";
import { import {
assertCircuitDeleteProjectCommand, assertCircuitDeleteProjectCommand,
assertCircuitInsertProjectCommand, 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 { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
import type { CircuitProjectCommandStore } from "../ports/circuit-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 { 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 { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
import type { import type {
ExecuteProjectHistoryCommandInput, ExecuteProjectHistoryCommandInput,
@@ -69,6 +74,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore, private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
private readonly circuitStructureStore: CircuitStructureProjectCommandStore, private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore, private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
private readonly circuitSectionRenumberStore: CircuitSectionRenumberProjectCommandStore,
private readonly historyStore: ProjectHistoryStore private readonly historyStore: ProjectHistoryStore
) {} ) {}
@@ -199,6 +205,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case circuitSectionRenumberCommandType: {
assertCircuitSectionRenumberProjectCommand(input.command);
return this.circuitSectionRenumberStore.execute({
...input,
command: input.command,
}).revision;
}
default: default:
throw new Error( throw new Error(
`Unsupported project command type: ${input.command.type}.` `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 { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-project-command.repository.js";
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-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 { 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 { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js"; import { ProjectCommandService } from "../../domain/services/project-command.service.js";
@@ -19,6 +20,8 @@ export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db); new CircuitStructureProjectCommandRepository(db);
export const circuitSectionReorderProjectCommandStore = export const circuitSectionReorderProjectCommandStore =
new CircuitSectionReorderProjectCommandRepository(db); new CircuitSectionReorderProjectCommandRepository(db);
export const circuitSectionRenumberProjectCommandStore =
new CircuitSectionRenumberProjectCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db);
export const projectCommandService = new ProjectCommandService( export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore, circuitProjectCommandStore,
@@ -27,5 +30,6 @@ export const projectCommandService = new ProjectCommandService(
circuitDeviceRowMoveProjectCommandStore, circuitDeviceRowMoveProjectCommandStore,
circuitStructureProjectCommandStore, circuitStructureProjectCommandStore,
circuitSectionReorderProjectCommandStore, circuitSectionReorderProjectCommandStore,
circuitSectionRenumberProjectCommandStore,
projectHistoryStore projectHistoryStore
); );
@@ -0,0 +1,454 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { asc, eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
interface TestFixture {
context: DatabaseContext;
sectionId: string;
foreignSectionId: string;
}
function createTestDatabase(): TestFixture {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Other project" },
])
.run();
const boards = new DistributionBoardRepository(context.db);
const board = boards.createWithCircuitListAndDefaultSections(
"project-1",
"UV-01"
);
const foreignBoard = boards.createWithCircuitListAndDefaultSections(
"project-2",
"UV-02"
);
const ownSections = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.orderBy(asc(circuitSections.sortOrder))
.all();
const foreignSection = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, foreignBoard.id))
.get();
assert.ok(ownSections[0]);
assert.ok(ownSections[1]);
assert.ok(foreignSection);
context.db
.insert(circuits)
.values([
{
id: "circuit-1",
circuitListId: board.id,
sectionId: ownSections[0].id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
{
id: "circuit-2",
circuitListId: board.id,
sectionId: ownSections[0].id,
equipmentIdentifier: "-1F2",
sortOrder: 20,
},
{
id: "circuit-3",
circuitListId: board.id,
sectionId: ownSections[0].id,
equipmentIdentifier: "-1F3",
sortOrder: 30,
},
{
id: "circuit-other-section",
circuitListId: board.id,
sectionId: ownSections[1].id,
equipmentIdentifier: "-2F1",
sortOrder: 10,
},
{
id: "circuit-foreign",
circuitListId: foreignBoard.id,
sectionId: foreignSection.id,
equipmentIdentifier: "-1F1",
sortOrder: 10,
},
])
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "row-1",
circuitId: "circuit-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})
.run();
return {
context,
sectionId: ownSections[0].id,
foreignSectionId: foreignSection.id,
};
}
function getSectionCircuitState(
context: DatabaseContext,
sectionId: string
) {
return context.db
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
sortOrder: circuits.sortOrder,
})
.from(circuits)
.where(eq(circuits.sectionId, sectionId))
.orderBy(asc(circuits.id))
.all();
}
function createSwapCommand(sectionId: string) {
return createCircuitSectionRenumberProjectCommand(sectionId, [
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F1",
},
{
circuitId: "circuit-3",
expectedEquipmentIdentifier: "-1F3",
targetEquipmentIdentifier: "-1F3",
},
]);
}
describe("circuit section renumber project-command repository", () => {
it("swaps identifiers and supports persisted undo and redo", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionRenumberProjectCommandRepository(
fixture.context.db
);
const command = createSwapCommand(fixture.sectionId);
const renumbered = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
getSectionCircuitState(
fixture.context,
fixture.sectionId
),
[
{
id: "circuit-1",
equipmentIdentifier: "-1F2",
sortOrder: 10,
},
{
id: "circuit-2",
equipmentIdentifier: "-1F1",
sortOrder: 20,
},
{
id: "circuit-3",
equipmentIdentifier: "-1F3",
sortOrder: 30,
},
]
);
assert.equal(
fixture.context.db
.select()
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()?.circuitId,
"circuit-1"
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
renumbered.revision.changeSetId,
command: renumbered.inverse,
});
assert.deepEqual(
getSectionCircuitState(
fixture.context,
fixture.sectionId
).map((circuit) => circuit.equipmentIdentifier),
["-1F1", "-1F2", "-1F3"]
);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId:
renumbered.revision.changeSetId,
command,
});
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: renumbered.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
fixture.context.close();
}
});
it("rejects incomplete, stale and foreign-section assignments", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionRenumberProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionRenumberProjectCommand(
fixture.sectionId,
[
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F1",
},
]
),
}),
/every circuit/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionRenumberProjectCommand(
fixture.sectionId,
[
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F999",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F1",
},
{
circuitId: "circuit-3",
expectedEquipmentIdentifier: "-1F3",
targetEquipmentIdentifier: "-1F3",
},
]
),
}),
/changed before/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionRenumberProjectCommand(
fixture.foreignSectionId,
[
{
circuitId: "circuit-foreign",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
]
),
}),
/does not belong to project/
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rejects target identifiers used by another section", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionRenumberProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionRenumberProjectCommand(
fixture.sectionId,
[
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-2F1",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F1",
},
{
circuitId: "circuit-3",
expectedEquipmentIdentifier: "-1F3",
targetEquipmentIdentifier: "-1F2",
},
]
),
}),
/already exists in circuit list/
);
assert.deepEqual(
getSectionCircuitState(
fixture.context,
fixture.sectionId
).map((circuit) => circuit.equipmentIdentifier),
["-1F1", "-1F2", "-1F3"]
);
} finally {
fixture.context.close();
}
});
it("rolls back temporary and final identifiers for late history failures", () => {
const fixture = createTestDatabase();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_renumber_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced renumber history failure');
END;
`);
const store =
new CircuitSectionRenumberProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createSwapCommand(fixture.sectionId),
}),
/forced renumber history failure/
);
assert.deepEqual(
getSectionCircuitState(
fixture.context,
fixture.sectionId
).map((circuit) => circuit.equipmentIdentifier),
["-1F1", "-1F2", "-1F3"]
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back renumbering for a stale project revision", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionRenumberProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createSwapCommand(fixture.sectionId),
}),
/at revision 0, expected 1/
);
assert.deepEqual(
getSectionCircuitState(
fixture.context,
fixture.sectionId
).map((circuit) => circuit.equipmentIdentifier),
["-1F1", "-1F2", "-1F3"]
);
} finally {
fixture.context.close();
}
});
});
+58
View File
@@ -33,6 +33,10 @@ import {
assertCircuitSectionReorderProjectCommand, assertCircuitSectionReorderProjectCommand,
createCircuitSectionReorderProjectCommand, createCircuitSectionReorderProjectCommand,
} from "../src/domain/models/circuit-section-reorder-project-command.model.js"; } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
import {
assertCircuitSectionRenumberProjectCommand,
createCircuitSectionRenumberProjectCommand,
} from "../src/domain/models/circuit-section-renumber-project-command.model.js";
describe("serialized project commands", () => { describe("serialized project commands", () => {
it("round-trips a versioned command envelope", () => { it("round-trips a versioned command envelope", () => {
@@ -581,3 +585,57 @@ describe("circuit section reorder project commands", () => {
); );
}); });
}); });
describe("circuit section renumber project commands", () => {
it("captures every expected and target equipment identifier", () => {
const command = createCircuitSectionRenumberProjectCommand(
"section-1",
[
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F1",
},
]
);
assert.equal(command.payload.assignments.length, 2);
assert.doesNotThrow(() =>
assertCircuitSectionRenumberProjectCommand(command)
);
});
it("rejects duplicate and complete no-op assignments", () => {
assert.throws(
() =>
createCircuitSectionRenumberProjectCommand("section-1", [
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F2",
},
]),
/duplicate target identifiers/
);
assert.throws(
() =>
createCircuitSectionRenumberProjectCommand("section-1", [
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F1",
},
]),
/change at least one identifier/
);
});
});
+97
View File
@@ -12,6 +12,7 @@ import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/reposito
import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js"; import { CircuitDeviceRowStructureProjectCommandRepository } from "../src/db/repositories/circuit-device-row-structure-project-command.repository.js";
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js"; import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js"; import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
@@ -31,6 +32,7 @@ import {
import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js"; import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js"; import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js"; import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js"; import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
@@ -87,6 +89,7 @@ function createService(context: DatabaseContext) {
new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitDeviceRowMoveProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db),
new CircuitSectionRenumberProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db) new ProjectHistoryRepository(context.db)
); );
} }
@@ -563,6 +566,100 @@ describe("project command service", () => {
} }
}); });
it("dispatches explicit section renumbering and its inverse", () => {
const context = createTestDatabase();
try {
const firstCircuit = context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(firstCircuit);
context.db
.insert(circuits)
.values({
id: "circuit-2",
circuitListId: firstCircuit.circuitListId,
sectionId: firstCircuit.sectionId,
equipmentIdentifier: "-1F2",
sortOrder: 20,
isReserve: 1,
})
.run();
const renumbered = createService(context).executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createCircuitSectionRenumberProjectCommand(
firstCircuit.sectionId,
[
{
circuitId: "circuit-1",
expectedEquipmentIdentifier: "-1F1",
targetEquipmentIdentifier: "-1F2",
},
{
circuitId: "circuit-2",
expectedEquipmentIdentifier: "-1F2",
targetEquipmentIdentifier: "-1F1",
},
]
),
});
assert.equal(renumbered.history.undoDepth, 1);
assert.deepEqual(
context.db
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
sortOrder: circuits.sortOrder,
})
.from(circuits)
.all()
.sort((left, right) => left.id.localeCompare(right.id)),
[
{
id: "circuit-1",
equipmentIdentifier: "-1F2",
sortOrder: 10,
},
{
id: "circuit-2",
equipmentIdentifier: "-1F1",
sortOrder: 20,
},
]
);
createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.deepEqual(
context.db
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
})
.from(circuits)
.all()
.sort((left, right) => left.id.localeCompare(right.id)),
[
{
id: "circuit-1",
equipmentIdentifier: "-1F1",
},
{
id: "circuit-2",
equipmentIdentifier: "-1F2",
},
]
);
} finally {
context.close();
}
});
it("rejects unavailable history directions without writing a revision", () => { it("rejects unavailable history directions without writing a revision", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {