diff --git a/AGENTS.md b/AGENTS.md index 6d640a2..d71cd84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 restore the rows and remove only the unchanged generated circuit. Complete 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: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index daf16eb..4df579c 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -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.move` plus `circuit-device-row.move-with-new-circuit` and `circuit.reorder-section`, all -with schema version `1`. Other command types are rejected. Insert commands -contain the complete entity or circuit block with stable ids; delete commands -include the expected parent identity. Circuit snapshots contain zero, one or -multiple complete device rows. Move commands contain each row's expected and -target circuit plus its exact expected and target sort order. This makes -deletion and moves undoable without generating replacement identities or -renumbering circuits. The editor does not consume these endpoints yet. +with `circuit.renumber-section`, all with schema version `1`. Other command +types are rejected. Insert commands contain the complete entity or circuit +block with stable ids; delete commands include the expected parent identity. +Circuit snapshots contain zero, one or multiple complete device rows. Move +commands contain each row's expected and target circuit plus its exact expected +and target sort order. This makes deletion and moves undoable without +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-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`; 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: ```json diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 07f9041..fe58dc7 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -99,8 +99,11 @@ Felder und vollständiger Zeilenbestand unverändert sind. `circuit.reorder-section` speichert die erwartete und neue Sortierposition jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs -bleiben unverändert. Neunummerierung und Synchronisierung müssen vor der -Frontend-Umstellung noch als persistente Commands modelliert werden. +bleiben unverändert. `circuit.renumber-section` bildet die getrennte, +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 diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index e8c4500..bdc2b53 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -189,13 +189,16 @@ Completed foundation: - `circuit.reorder-section` requires the complete section circuit set and stores exact expected/target sort positions; its inverse changes no 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: - extend the concrete project-scoped command union and executors beyond the Circuit field/insert/delete and CircuitDeviceRow - field/insert/delete/move plus section-reorder slices; the generic versioned - command envelope is complete + field/insert/delete/move plus section-reorder/renumber slices; the generic + versioned command envelope is complete - route the remaining structural, synchronization and destructive domain writes through the shared command, revision and stack transaction boundary - replace the session-local frontend command stack only after server-side diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 4b7215b..e7c1a1f 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -403,6 +403,9 @@ Implemented foundation: unchanged generated circuit, without renumbering - complete section reorders persist every expected and target circuit `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 in one SQLite transaction - a stale expected revision produces no history writes diff --git a/package.json b/package.json index 8380717..be90451 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "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: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": "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/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:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/db/repositories/circuit-section-renumber-project-command.repository.ts b/src/db/repositories/circuit-section-renumber-project-command.repository.ts new file mode 100644 index 0000000..9430a89 --- /dev/null +++ b/src/db/repositories/circuit-section-renumber-project-command.repository.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(); + 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; + } +} diff --git a/src/domain/models/circuit-section-renumber-project-command.model.ts b/src/domain/models/circuit-section-renumber-project-command.model.ts new file mode 100644 index 0000000..325027e --- /dev/null +++ b/src/domain/models/circuit-section-renumber-project-command.model.ts @@ -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 { + 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 +): 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(); + const expectedIdentifiers = new Set(); + const targetIdentifiers = new Set(); + 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 { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/circuit-section-renumber-project-command.store.ts b/src/domain/ports/circuit-section-renumber-project-command.store.ts new file mode 100644 index 0000000..789abd4 --- /dev/null +++ b/src/domain/ports/circuit-section-renumber-project-command.store.ts @@ -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; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 1502c41..ee0f7a5 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -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}.` diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 254c5e5..24fd1db 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -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 ); diff --git a/tests/circuit-section-renumber-project-command.repository.test.ts b/tests/circuit-section-renumber-project-command.repository.test.ts new file mode 100644 index 0000000..f480899 --- /dev/null +++ b/tests/circuit-section-renumber-project-command.repository.test.ts @@ -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(); + } + }); +}); diff --git a/tests/project-command.model.test.ts b/tests/project-command.model.test.ts index e9c0454..0d65373 100644 --- a/tests/project-command.model.test.ts +++ b/tests/project-command.model.test.ts @@ -33,6 +33,10 @@ import { assertCircuitSectionReorderProjectCommand, createCircuitSectionReorderProjectCommand, } 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", () => { 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/ + ); + }); +}); diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 6a04b08..dbe5d6e 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -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 { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-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 { DistributionBoardRepository } from "../src/db/repositories/distribution-board.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 { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-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 { ProjectCommandService } from "../src/domain/services/project-command.service.js"; @@ -87,6 +89,7 @@ function createService(context: DatabaseContext) { new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db), new CircuitSectionReorderProjectCommandRepository(context.db), + new CircuitSectionRenumberProjectCommandRepository(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", () => { const context = createTestDatabase(); try {