diff --git a/AGENTS.md b/AGENTS.md index 2141c99..641c6a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,7 +225,9 @@ The server already persists immutable revisions and project-wide undo/redo eligibility for the first commands. Public command, undo and redo endpoints exist for Circuit and CircuitDeviceRow field updates as well as Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and -the UI cutover remain future work. +the UI cutover remain future work. CircuitDeviceRow moves between existing +circuits are also persisted; moves that create a target circuit remain future +work. Required operations: diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 69cd4fe..1a1a9e5 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -25,14 +25,20 @@ concurrency checks. The public dispatcher currently supports `circuit.update`, `circuit.insert`, `circuit.delete`, `circuit-device-row.update`, -`circuit-device-row.insert` and `circuit-device-row.delete`, 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. This makes deletion undoable without generating +`circuit-device-row.insert`, `circuit-device-row.delete` and +`circuit-device-row.move`, 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` currently targets existing circuits in the same +circuit list. Creating a new target circuit while moving rows is not yet part +of the persistent command API. + Example: ```json diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 20bfb0c..8acb809 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -86,9 +86,14 @@ wiederherstellt. `circuit.insert` und `circuit.delete` behandeln einen Stromkreis mit null, einer oder mehreren Gerätezeilen als vollständigen Block. Undo bewahrt dabei sämtliche Circuit-/Row-UUIDs und ändert keine Betriebsmittelkennzeichen. Das Grid verwendet diese Endpunkte noch nicht; seine -sichtbare Historie bleibt daher sitzungslokal. Verschiebe-, Sortier-, -Neunummerierungs- und Synchronisierungsoperationen müssen vor der -Frontend-Umstellung ebenfalls als persistente Commands modelliert werden. +sichtbare Historie bleibt daher sitzungslokal. +`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen +zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue +Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; +Reservewerte aller beteiligten Stromkreise werden atomar neu abgeleitet. Das +Verschieben auf einen freien Platz mit gleichzeitiger Stromkreiserzeugung sowie +Circuit-Sortierung, Neunummerierung und Synchronisierung müssen vor der +Frontend-Umstellung noch als persistente Commands 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 e36b532..c3de1fd 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -179,12 +179,16 @@ Completed foundation: - `circuit.insert` and `circuit.delete` persist a complete circuit block with zero, one or multiple device rows; undo restores all ids and fields without renumbering +- `circuit-device-row.move` stores exact expected and target circuit/sort + positions for one or multiple rows, recalculates all affected reserve states + and reconstructs a deterministic inverse across multiple source circuits 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 slices; - the generic versioned command envelope is complete + Circuit field/insert/delete and CircuitDeviceRow + field/insert/delete/existing-target-move 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 36825d7..250e0b6 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -395,6 +395,9 @@ Implemented foundation: stack transition commit or roll back together - typed Circuit insert/delete commands treat the circuit and all device rows as one block, preserve every stable id and never renumber neighbouring circuits +- typed CircuitDeviceRow move commands persist exact old/new circuit and sort + positions for single or multi-row moves between existing circuits and derive + affected reserve states in the same transaction - 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 26ba22d..d62a7fe 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/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/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/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/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/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/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-device-row-move-project-command.repository.ts b/src/db/repositories/circuit-device-row-move-project-command.repository.ts new file mode 100644 index 0000000..92eb8e2 --- /dev/null +++ b/src/db/repositories/circuit-device-row-move-project-command.repository.ts @@ -0,0 +1,181 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { + assertCircuitDeviceRowMoveProjectCommand, + createCircuitDeviceRowMoveProjectCommand, +} from "../../domain/models/circuit-device-row-move-project-command.model.js"; +import type { + CircuitDeviceRowMoveProjectCommandStore, + ExecuteCircuitDeviceRowMoveCommandInput, +} from "../../domain/ports/circuit-device-row-move-project-command.store.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuits } from "../schema/circuits.js"; +import { applyProjectHistoryTransition } from "./project-history.persistence.js"; +import { appendProjectRevision } from "./project-revision.persistence.js"; + +export class CircuitDeviceRowMoveProjectCommandRepository + implements CircuitDeviceRowMoveProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteCircuitDeviceRowMoveCommandInput) { + assertCircuitDeviceRowMoveProjectCommand(input.command); + + return this.database.transaction((tx) => { + const rowIds = input.command.payload.moves.map( + (move) => move.rowId + ); + const persistedRows = tx + .select({ + id: circuitDeviceRows.id, + circuitId: circuitDeviceRows.circuitId, + sortOrder: circuitDeviceRows.sortOrder, + }) + .from(circuitDeviceRows) + .where(inArray(circuitDeviceRows.id, rowIds)) + .all(); + if (persistedRows.length !== rowIds.length) { + throw new Error( + "One or more circuit device rows do not exist." + ); + } + const rowsById = new Map( + persistedRows.map((row) => [row.id, row]) + ); + for (const move of input.command.payload.moves) { + const row = rowsById.get(move.rowId); + if ( + !row || + row.circuitId !== move.expectedCircuitId || + row.sortOrder !== move.expectedSortOrder + ) { + throw new Error( + "Circuit device row changed before move execution." + ); + } + } + + const circuitIds = [ + ...new Set( + input.command.payload.moves.flatMap((move) => [ + move.expectedCircuitId, + move.targetCircuitId, + ]) + ), + ]; + const participatingCircuits = tx + .select({ + id: circuits.id, + circuitListId: circuits.circuitListId, + projectId: circuitLists.projectId, + }) + .from(circuits) + .innerJoin( + circuitLists, + eq(circuitLists.id, circuits.circuitListId) + ) + .where(inArray(circuits.id, circuitIds)) + .all(); + if (participatingCircuits.length !== circuitIds.length) { + throw new Error( + "One or more move circuits do not exist." + ); + } + if ( + participatingCircuits.some( + (circuit) => circuit.projectId !== input.projectId + ) + ) { + throw new Error("Move circuit does not belong to project."); + } + const circuitListIds = new Set( + participatingCircuits.map( + (circuit) => circuit.circuitListId + ) + ); + if (circuitListIds.size !== 1) { + throw new Error( + "All moved rows and targets must belong to one circuit list." + ); + } + + const inverse = createCircuitDeviceRowMoveProjectCommand( + input.command.payload.moves.map((move) => { + const current = rowsById.get(move.rowId)!; + return { + rowId: move.rowId, + expectedCircuitId: move.targetCircuitId, + expectedSortOrder: move.targetSortOrder, + targetCircuitId: current.circuitId, + targetSortOrder: current.sortOrder, + }; + }) + ); + + for (const move of input.command.payload.moves) { + const result = tx + .update(circuitDeviceRows) + .set({ + circuitId: move.targetCircuitId, + sortOrder: move.targetSortOrder, + }) + .where( + and( + eq(circuitDeviceRows.id, move.rowId), + eq( + circuitDeviceRows.circuitId, + move.expectedCircuitId + ), + eq( + circuitDeviceRows.sortOrder, + move.expectedSortOrder + ) + ) + ) + .run(); + if (result.changes !== 1) { + throw new Error( + "Circuit device row changed during move execution." + ); + } + } + + for (const circuitId of circuitIds) { + const remainingRow = tx + .select({ id: circuitDeviceRows.id }) + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.circuitId, circuitId)) + .limit(1) + .get(); + const updated = tx + .update(circuits) + .set({ isReserve: remainingRow ? 0 : 1 }) + .where(eq(circuits.id, circuitId)) + .run(); + if (updated.changes !== 1) { + throw new Error( + "Circuit changed during device-row move execution." + ); + } + } + + const revision = appendProjectRevision(tx, { + projectId: input.projectId, + expectedRevision: input.expectedRevision, + source: input.source, + description: input.description, + actorId: input.actorId, + forward: input.command, + inverse, + }); + applyProjectHistoryTransition(tx, { + projectId: input.projectId, + source: input.source, + recordedChangeSetId: revision.changeSetId, + targetChangeSetId: input.historyTargetChangeSetId, + }); + return { revision, inverse }; + }); + } +} diff --git a/src/domain/models/circuit-device-row-move-project-command.model.ts b/src/domain/models/circuit-device-row-move-project-command.model.ts new file mode 100644 index 0000000..82d75e4 --- /dev/null +++ b/src/domain/models/circuit-device-row-move-project-command.model.ts @@ -0,0 +1,114 @@ +import type { SerializedProjectCommand } from "./project-command.model.js"; + +export const circuitDeviceRowMoveCommandType = + "circuit-device-row.move" as const; +export const circuitDeviceRowMoveCommandSchemaVersion = 1 as const; + +export interface CircuitDeviceRowMoveAssignment { + rowId: string; + expectedCircuitId: string; + expectedSortOrder: number; + targetCircuitId: string; + targetSortOrder: number; +} + +export interface CircuitDeviceRowMoveCommandPayload { + moves: CircuitDeviceRowMoveAssignment[]; +} + +export interface CircuitDeviceRowMoveProjectCommand + extends SerializedProjectCommand { + schemaVersion: typeof circuitDeviceRowMoveCommandSchemaVersion; + type: typeof circuitDeviceRowMoveCommandType; +} + +export function createCircuitDeviceRowMoveProjectCommand( + moves: CircuitDeviceRowMoveAssignment[] +): CircuitDeviceRowMoveProjectCommand { + const command: CircuitDeviceRowMoveProjectCommand = { + schemaVersion: circuitDeviceRowMoveCommandSchemaVersion, + type: circuitDeviceRowMoveCommandType, + payload: { moves }, + }; + assertCircuitDeviceRowMoveProjectCommand(command); + return command; +} + +export function assertCircuitDeviceRowMoveProjectCommand( + command: SerializedProjectCommand +): asserts command is CircuitDeviceRowMoveProjectCommand { + if ( + command.schemaVersion !== circuitDeviceRowMoveCommandSchemaVersion || + command.type !== circuitDeviceRowMoveCommandType + ) { + throw new Error("Unsupported circuit device-row move command."); + } + if (!isPlainObject(command.payload)) { + throw new Error( + "Circuit device-row move payload must be an object." + ); + } + const moves = command.payload.moves; + if (!Array.isArray(moves) || moves.length === 0) { + throw new Error( + "Circuit device-row move command requires at least one move." + ); + } + + const rowIds = new Set(); + for (const move of moves) { + if (!isPlainObject(move)) { + throw new Error( + "Circuit device-row move command contains an invalid move." + ); + } + assertNonEmptyString(move.rowId, "rowId"); + assertNonEmptyString( + move.expectedCircuitId, + "expectedCircuitId" + ); + assertFiniteNumber( + move.expectedSortOrder, + "expectedSortOrder" + ); + assertNonEmptyString(move.targetCircuitId, "targetCircuitId"); + assertFiniteNumber(move.targetSortOrder, "targetSortOrder"); + if (rowIds.has(move.rowId)) { + throw new Error( + "Circuit device-row move command contains duplicate row ids." + ); + } + if ( + move.expectedCircuitId === move.targetCircuitId && + move.expectedSortOrder === move.targetSortOrder + ) { + throw new Error( + "Circuit device-row move command contains a no-op move." + ); + } + + rowIds.add(move.rowId); + } +} + +function assertNonEmptyString( + value: unknown, + field: string +): asserts value is string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`${field} must be a non-empty string.`); + } +} + +function assertFiniteNumber( + value: unknown, + field: string +): asserts value is number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${field} must be a finite number.`); + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/circuit-device-row-move-project-command.store.ts b/src/domain/ports/circuit-device-row-move-project-command.store.ts new file mode 100644 index 0000000..0cc17b6 --- /dev/null +++ b/src/domain/ports/circuit-device-row-move-project-command.store.ts @@ -0,0 +1,26 @@ +import type { CircuitDeviceRowMoveProjectCommand } from "../models/circuit-device-row-move-project-command.model.js"; +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "./project-revision.store.js"; + +export interface ExecuteCircuitDeviceRowMoveCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: CircuitDeviceRowMoveProjectCommand; +} + +export interface ExecutedCircuitDeviceRowMoveCommand { + revision: AppendedProjectRevision; + inverse: CircuitDeviceRowMoveProjectCommand; +} + +export interface CircuitDeviceRowMoveProjectCommandStore { + execute( + input: ExecuteCircuitDeviceRowMoveCommandInput + ): ExecutedCircuitDeviceRowMoveCommand; +} diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index eda00ce..caf4180 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -1,4 +1,8 @@ import { ProjectHistoryOperationUnavailableError } from "../errors/project-history-operation-unavailable.error.js"; +import { + assertCircuitDeviceRowMoveProjectCommand, + circuitDeviceRowMoveCommandType, +} from "../models/circuit-device-row-move-project-command.model.js"; import { assertCircuitDeviceRowUpdateProjectCommand, circuitDeviceRowUpdateCommandType, @@ -24,6 +28,7 @@ import { type SerializedProjectCommand, } from "../models/project-command.model.js"; import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js"; +import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js"; import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js"; import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js"; import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js"; @@ -54,6 +59,7 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly circuitStore: CircuitProjectCommandStore, private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore, private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore, + private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore, private readonly circuitStructureStore: CircuitStructureProjectCommandStore, private readonly historyStore: ProjectHistoryStore ) {} @@ -148,6 +154,13 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case circuitDeviceRowMoveCommandType: { + assertCircuitDeviceRowMoveProjectCommand(input.command); + return this.deviceRowMoveStore.execute({ + ...input, + command: input.command, + }).revision; + } case circuitInsertCommandType: { assertCircuitInsertProjectCommand(input.command); return this.circuitStructureStore.execute({ diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index a580927..56d1075 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -1,5 +1,6 @@ import { db } from "../../db/client.js"; import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js"; +import { CircuitDeviceRowMoveProjectCommandRepository } from "../../db/repositories/circuit-device-row-move-project-command.repository.js"; import { CircuitDeviceRowStructureProjectCommandRepository } from "../../db/repositories/circuit-device-row-structure-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js"; import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js"; @@ -11,6 +12,8 @@ export const circuitDeviceRowProjectCommandStore = new CircuitDeviceRowProjectCommandRepository(db); export const circuitDeviceRowStructureProjectCommandStore = new CircuitDeviceRowStructureProjectCommandRepository(db); +export const circuitDeviceRowMoveProjectCommandStore = + new CircuitDeviceRowMoveProjectCommandRepository(db); export const circuitStructureProjectCommandStore = new CircuitStructureProjectCommandRepository(db); export const projectHistoryStore = new ProjectHistoryRepository(db); @@ -18,6 +21,7 @@ export const projectCommandService = new ProjectCommandService( circuitProjectCommandStore, circuitDeviceRowProjectCommandStore, circuitDeviceRowStructureProjectCommandStore, + circuitDeviceRowMoveProjectCommandStore, circuitStructureProjectCommandStore, projectHistoryStore ); diff --git a/tests/circuit-device-row-move-project-command.repository.test.ts b/tests/circuit-device-row-move-project-command.repository.test.ts new file mode 100644 index 0000000..65eadef --- /dev/null +++ b/tests/circuit-device-row-move-project-command.repository.test.ts @@ -0,0 +1,435 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { eq } from "drizzle-orm"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-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 { createCircuitDeviceRowMoveProjectCommand } from "../src/domain/models/circuit-device-row-move-project-command.model.js"; + +interface TestFixture { + context: DatabaseContext; + circuitListId: string; + sectionId: 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 section = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, board.id)) + .get(); + const foreignSection = context.db + .select() + .from(circuitSections) + .where(eq(circuitSections.circuitListId, foreignBoard.id)) + .get(); + assert.ok(section); + assert.ok(foreignSection); + + context.db + .insert(circuits) + .values([ + { + id: "circuit-1", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F1", + sortOrder: 10, + isReserve: 0, + }, + { + id: "circuit-2", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F2", + sortOrder: 20, + isReserve: 0, + }, + { + id: "circuit-3", + circuitListId: board.id, + sectionId: section.id, + equipmentIdentifier: "-1F3", + sortOrder: 30, + isReserve: 0, + }, + { + id: "circuit-foreign", + circuitListId: foreignBoard.id, + sectionId: foreignSection.id, + equipmentIdentifier: "-1F1", + sortOrder: 10, + isReserve: 1, + }, + ]) + .run(); + context.db + .insert(circuitDeviceRows) + .values([ + { + id: "row-1", + circuitId: "circuit-1", + sortOrder: 10, + name: "Quelle 1", + displayName: "Quelle 1", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + }, + { + id: "row-retained", + circuitId: "circuit-1", + sortOrder: 20, + name: "Bleibt", + displayName: "Bleibt", + quantity: 1, + powerPerUnit: 0.1, + simultaneityFactor: 1, + }, + { + id: "row-2", + circuitId: "circuit-2", + sortOrder: 10, + name: "Quelle 2", + displayName: "Quelle 2", + quantity: 1, + powerPerUnit: 0.2, + simultaneityFactor: 1, + }, + { + id: "row-target", + circuitId: "circuit-3", + sortOrder: 10, + name: "Ziel", + displayName: "Ziel", + quantity: 1, + powerPerUnit: 0.3, + simultaneityFactor: 1, + }, + ]) + .run(); + return { + context, + circuitListId: board.id, + sectionId: section.id, + }; +} + +function getRow(context: DatabaseContext, rowId: string) { + return context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, rowId)) + .get(); +} + +function isReserve(context: DatabaseContext, circuitId: string) { + return Boolean( + context.db + .select({ isReserve: circuits.isReserve }) + .from(circuits) + .where(eq(circuits.id, circuitId)) + .get()?.isReserve + ); +} + +describe("circuit device-row move project-command repository", () => { + it("moves multiple rows and supports persisted undo and redo", () => { + const fixture = createTestDatabase(); + try { + const store = new CircuitDeviceRowMoveProjectCommandRepository( + fixture.context.db + ); + const command = createCircuitDeviceRowMoveProjectCommand([ + { + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 10, + targetCircuitId: "circuit-3", + targetSortOrder: 20, + }, + { + rowId: "row-2", + expectedCircuitId: "circuit-2", + expectedSortOrder: 10, + targetCircuitId: "circuit-3", + targetSortOrder: 30, + }, + ]); + const moved = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command, + }); + assert.deepEqual( + [ + getRow(fixture.context, "row-1"), + getRow(fixture.context, "row-2"), + ].map((row) => [row?.circuitId, row?.sortOrder]), + [ + ["circuit-3", 20], + ["circuit-3", 30], + ] + ); + assert.equal(isReserve(fixture.context, "circuit-1"), false); + assert.equal(isReserve(fixture.context, "circuit-2"), true); + assert.equal(isReserve(fixture.context, "circuit-3"), false); + + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: moved.revision.changeSetId, + command: moved.inverse, + }); + assert.deepEqual( + [ + getRow(fixture.context, "row-1"), + getRow(fixture.context, "row-2"), + ].map((row) => [row?.circuitId, row?.sortOrder]), + [ + ["circuit-1", 10], + ["circuit-2", 10], + ] + ); + assert.equal(isReserve(fixture.context, "circuit-1"), false); + assert.equal(isReserve(fixture.context, "circuit-2"), false); + + store.execute({ + projectId: "project-1", + expectedRevision: 2, + source: "redo", + historyTargetChangeSetId: moved.revision.changeSetId, + command, + }); + assert.deepEqual( + new ProjectHistoryRepository(fixture.context.db).getState( + "project-1" + ), + { + projectId: "project-1", + currentRevision: 3, + undoDepth: 1, + redoDepth: 0, + undoChangeSetId: moved.revision.changeSetId, + redoChangeSetId: null, + } + ); + } finally { + fixture.context.close(); + } + }); + + it("reorders a row inside its current circuit", () => { + const fixture = createTestDatabase(); + try { + const store = new CircuitDeviceRowMoveProjectCommandRepository( + fixture.context.db + ); + const moved = store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowMoveProjectCommand([ + { + rowId: "row-retained", + expectedCircuitId: "circuit-1", + expectedSortOrder: 20, + targetCircuitId: "circuit-1", + targetSortOrder: 5, + }, + ]), + }); + assert.equal( + getRow(fixture.context, "row-retained")?.sortOrder, + 5 + ); + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "undo", + historyTargetChangeSetId: moved.revision.changeSetId, + command: moved.inverse, + }); + assert.equal( + getRow(fixture.context, "row-retained")?.sortOrder, + 20 + ); + } finally { + fixture.context.close(); + } + }); + + it("rejects stale row positions and cross-project targets", () => { + const fixture = createTestDatabase(); + try { + const store = new CircuitDeviceRowMoveProjectCommandRepository( + fixture.context.db + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowMoveProjectCommand([ + { + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 999, + targetCircuitId: "circuit-3", + targetSortOrder: 20, + }, + ]), + }), + /changed before move/ + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowMoveProjectCommand([ + { + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 10, + targetCircuitId: "circuit-foreign", + targetSortOrder: 10, + }, + ]), + }), + /does not belong to project/ + ); + assert.deepEqual( + [ + getRow(fixture.context, "row-1")?.circuitId, + getRow(fixture.context, "row-1")?.sortOrder, + ], + ["circuit-1", 10] + ); + assert.equal( + fixture.context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + fixture.context.close(); + } + }); + + it("rolls back rows and reserve states for late history failures", () => { + const fixture = createTestDatabase(); + try { + fixture.context.sqlite.exec(` + CREATE TRIGGER fail_move_history + BEFORE INSERT ON project_history_stack_entries + BEGIN + SELECT RAISE(ABORT, 'forced move history failure'); + END; + `); + const store = new CircuitDeviceRowMoveProjectCommandRepository( + fixture.context.db + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 0, + source: "user", + command: createCircuitDeviceRowMoveProjectCommand([ + { + rowId: "row-2", + expectedCircuitId: "circuit-2", + expectedSortOrder: 10, + targetCircuitId: "circuit-3", + targetSortOrder: 20, + }, + ]), + }), + /forced move history failure/ + ); + assert.deepEqual( + [ + getRow(fixture.context, "row-2")?.circuitId, + getRow(fixture.context, "row-2")?.sortOrder, + ], + ["circuit-2", 10] + ); + assert.equal(isReserve(fixture.context, "circuit-2"), false); + assert.equal(isReserve(fixture.context, "circuit-3"), false); + assert.equal( + fixture.context.db.select().from(projectRevisions).all().length, + 0 + ); + } finally { + fixture.context.close(); + } + }); + + it("rolls back a move for a stale project revision", () => { + const fixture = createTestDatabase(); + try { + const store = new CircuitDeviceRowMoveProjectCommandRepository( + fixture.context.db + ); + assert.throws( + () => + store.execute({ + projectId: "project-1", + expectedRevision: 1, + source: "user", + command: createCircuitDeviceRowMoveProjectCommand([ + { + rowId: "row-2", + expectedCircuitId: "circuit-2", + expectedSortOrder: 10, + targetCircuitId: "circuit-3", + targetSortOrder: 20, + }, + ]), + }), + /at revision 0, expected 1/ + ); + assert.equal( + getRow(fixture.context, "row-2")?.circuitId, + "circuit-2" + ); + assert.equal(isReserve(fixture.context, "circuit-2"), false); + } finally { + fixture.context.close(); + } + }); +}); diff --git a/tests/project-command.model.test.ts b/tests/project-command.model.test.ts index 8a25142..239ec47 100644 --- a/tests/project-command.model.test.ts +++ b/tests/project-command.model.test.ts @@ -18,6 +18,10 @@ import { createCircuitDeviceRowDeleteProjectCommand, createCircuitDeviceRowInsertProjectCommand, } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; +import { + assertCircuitDeviceRowMoveProjectCommand, + createCircuitDeviceRowMoveProjectCommand, +} from "../src/domain/models/circuit-device-row-move-project-command.model.js"; import { assertCircuitInsertProjectCommand, createCircuitDeleteProjectCommand, @@ -246,6 +250,71 @@ describe("circuit device-row structure project commands", () => { }); }); +describe("circuit device-row move project commands", () => { + it("captures deterministic source and target positions", () => { + const command = createCircuitDeviceRowMoveProjectCommand([ + { + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 10, + targetCircuitId: "circuit-2", + targetSortOrder: 30, + }, + { + rowId: "row-2", + expectedCircuitId: "circuit-1", + expectedSortOrder: 20, + targetCircuitId: "circuit-2", + targetSortOrder: 40, + }, + ]); + assert.equal(command.payload.moves.length, 2); + assert.doesNotThrow(() => + assertCircuitDeviceRowMoveProjectCommand(command) + ); + }); + + it("rejects empty, duplicate and no-op moves", () => { + assert.throws( + () => createCircuitDeviceRowMoveProjectCommand([]), + /at least one move/ + ); + assert.throws( + () => + createCircuitDeviceRowMoveProjectCommand([ + { + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 10, + targetCircuitId: "circuit-2", + targetSortOrder: 20, + }, + { + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 30, + targetCircuitId: "circuit-2", + targetSortOrder: 40, + }, + ]), + /duplicate row ids/ + ); + assert.throws( + () => + createCircuitDeviceRowMoveProjectCommand([ + { + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 10, + targetCircuitId: "circuit-1", + targetSortOrder: 10, + }, + ]), + /no-op/ + ); + }); +}); + describe("circuit structure project commands", () => { const row = { id: "row-1", diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index d24502a..9c42bf7 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -8,6 +8,7 @@ import { type DatabaseContext, } from "../src/db/database-context.js"; import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js"; +import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-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 { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js"; @@ -22,6 +23,7 @@ import { projects } from "../src/db/schema/projects.js"; import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js"; import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js"; import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js"; +import { createCircuitDeviceRowMoveProjectCommand } from "../src/domain/models/circuit-device-row-move-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 { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js"; @@ -77,6 +79,7 @@ function createService(context: DatabaseContext) { new CircuitProjectCommandRepository(context.db), new CircuitDeviceRowProjectCommandRepository(context.db), new CircuitDeviceRowStructureProjectCommandRepository(context.db), + new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db), new ProjectHistoryRepository(context.db) ); @@ -322,6 +325,68 @@ describe("project command service", () => { } }); + it("dispatches deterministic device-row moves and their inverse", () => { + const context = createTestDatabase(); + try { + const sourceCircuit = context.db + .select() + .from(circuits) + .where(eq(circuits.id, "circuit-1")) + .get(); + assert.ok(sourceCircuit); + context.db + .insert(circuits) + .values({ + id: "circuit-2", + circuitListId: sourceCircuit.circuitListId, + sectionId: sourceCircuit.sectionId, + equipmentIdentifier: "-1F2", + sortOrder: 20, + isReserve: 1, + }) + .run(); + + const service = createService(context); + const moved = service.executeUser({ + projectId: "project-1", + expectedRevision: 0, + command: createCircuitDeviceRowMoveProjectCommand([ + { + rowId: "row-1", + expectedCircuitId: "circuit-1", + expectedSortOrder: 10, + targetCircuitId: "circuit-2", + targetSortOrder: 10, + }, + ]), + }); + assert.equal(moved.history.undoDepth, 1); + assert.equal( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .get()?.circuitId, + "circuit-2" + ); + + createService(context).undo({ + projectId: "project-1", + expectedRevision: 1, + }); + assert.equal( + context.db + .select() + .from(circuitDeviceRows) + .where(eq(circuitDeviceRows.id, "row-1")) + .get()?.circuitId, + "circuit-1" + ); + } finally { + context.close(); + } + }); + it("rejects unavailable history directions without writing a revision", () => { const context = createTestDatabase(); try {