Add circuit reorder history

This commit is contained in:
2026-07-24 08:21:32 +02:00
parent 53282e8c7c
commit 332dfdb5d9
14 changed files with 865 additions and 16 deletions
+3 -1
View File
@@ -228,7 +228,9 @@ Circuit/CircuitDeviceRow insertion and deletion. Complete command coverage and
the UI cutover remain future work. CircuitDeviceRow moves between existing the UI cutover remain future work. CircuitDeviceRow moves between existing
circuits and moves that create one new placeholder target circuit are also 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. 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.
Required operations: Required operations:
+13 -8
View File
@@ -27,14 +27,14 @@ The public dispatcher currently supports `circuit.update`, `circuit.insert`,
`circuit.delete`, `circuit-device-row.update`, `circuit.delete`, `circuit-device-row.update`,
`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`, all with schema version `1`. Other `circuit-device-row.move-with-new-circuit` and `circuit.reorder-section`, all
command types are rejected. Insert commands contain the complete entity or with schema version `1`. Other command types are rejected. Insert commands
circuit block with stable ids; delete commands include the expected parent contain the complete entity or circuit block with stable ids; delete commands
identity. Circuit snapshots contain zero, one or multiple complete device include the expected parent identity. Circuit snapshots contain zero, one or
rows. Move commands contain each row's expected and target circuit plus its multiple complete device rows. Move commands contain each row's expected and
exact expected and target sort order. This makes deletion and moves undoable target circuit plus its exact expected and target sort order. This makes
without generating replacement identities or renumbering circuits. The editor deletion and moves undoable without generating replacement identities or
does not consume these endpoints yet. 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
@@ -43,6 +43,11 @@ rows into it. Its inverse restores every row to its exact prior position and
deletes the generated circuit only if its fields and complete row set still deletes the generated circuit only if its fields and complete row set still
match the recorded state. match the recorded state.
`circuit.reorder-section` requires one assignment for every circuit currently
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.
Example: Example:
```json ```json
+6 -3
View File
@@ -95,9 +95,12 @@ Kompositkommando `circuit-device-row.move-with-new-circuit` bildet auch das
Verschieben auf einen freien Platz ab: Ein Zielstromkreis mit stabiler UUID und Verschieben auf einen freien Platz ab: Ein Zielstromkreis mit stabiler UUID und
BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten BMK wird zusammen mit allen Zeilenbewegungen erzeugt. Undo stellt die exakten
Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen Quellpositionen wieder her und löscht den erzeugten Stromkreis nur, wenn dessen
Felder und vollständiger Zeilenbestand unverändert sind. Circuit-Sortierung, Felder und vollständiger Zeilenbestand unverändert sind.
Neunummerierung und Synchronisierung müssen vor der Frontend-Umstellung noch `circuit.reorder-section` speichert die erwartete und neue Sortierposition
als persistente Commands modelliert werden. 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.
## Projektgeräte ## Projektgeräte
@@ -186,13 +186,16 @@ Completed foundation:
empty target-circuit snapshot; forward creation and all row moves share one empty target-circuit snapshot; forward creation and all row moves share one
transaction, while undo restores the source positions and deletes only an transaction, while undo restores the source positions and deletes only an
unchanged generated target unchanged generated target
- `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
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 slices; the generic versioned command envelope is field/insert/delete/move plus section-reorder slices; the generic versioned
complete 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
@@ -401,6 +401,8 @@ Implemented foundation:
- placeholder-target moves persist one complete generated circuit identity and - placeholder-target moves persist one complete generated circuit identity and
all row positions atomically; undo restores every source and removes only an all row positions atomically; undo restores every source and removes only an
unchanged generated circuit, without renumbering unchanged generated circuit, without renumbering
- complete section reorders persist every expected and target circuit
`sortOrder`; undo/redo changes neither equipment identifiers nor 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/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/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", "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",
"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,135 @@
import { and, eq } from "drizzle-orm";
import {
assertCircuitSectionReorderProjectCommand,
createCircuitSectionReorderProjectCommand,
} from "../../domain/models/circuit-section-reorder-project-command.model.js";
import type {
CircuitSectionReorderProjectCommandStore,
ExecuteCircuitSectionReorderCommandInput,
} from "../../domain/ports/circuit-section-reorder-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 CircuitSectionReorderProjectCommandRepository
implements CircuitSectionReorderProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitSectionReorderCommandInput) {
assertCircuitSectionReorderProjectCommand(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 persistedCircuits = tx
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
sortOrder: circuits.sortOrder,
})
.from(circuits)
.where(eq(circuits.sectionId, section.id))
.all();
const assignments = input.command.payload.assignments;
if (
persistedCircuits.length !== assignments.length ||
persistedCircuits.some(
(circuit) =>
circuit.circuitListId !== section.circuitListId
)
) {
throw new Error(
"Circuit reorder must include every circuit in the section."
);
}
const circuitsById = new Map(
persistedCircuits.map((circuit) => [circuit.id, circuit])
);
for (const assignment of assignments) {
const circuit = circuitsById.get(assignment.circuitId);
if (
!circuit ||
circuit.sortOrder !== assignment.expectedSortOrder
) {
throw new Error(
"Circuit changed before section reorder execution."
);
}
}
const inverse = createCircuitSectionReorderProjectCommand(
section.id,
assignments.map((assignment) => ({
circuitId: assignment.circuitId,
expectedSortOrder: assignment.targetSortOrder,
targetSortOrder: assignment.expectedSortOrder,
}))
);
for (const assignment of assignments) {
if (
assignment.expectedSortOrder === assignment.targetSortOrder
) {
continue;
}
const updated = tx
.update(circuits)
.set({ sortOrder: assignment.targetSortOrder })
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, section.id),
eq(
circuits.sortOrder,
assignment.expectedSortOrder
)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during section reorder execution."
);
}
}
const revision = appendProjectRevision(tx, {
projectId: input.projectId,
expectedRevision: input.expectedRevision,
source: input.source,
description: input.description,
actorId: input.actorId,
forward: input.command,
inverse,
});
applyProjectHistoryTransition(tx, {
projectId: input.projectId,
source: input.source,
recordedChangeSetId: revision.changeSetId,
targetChangeSetId: input.historyTargetChangeSetId,
});
return { revision, inverse };
});
}
}
@@ -0,0 +1,115 @@
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitSectionReorderCommandType =
"circuit.reorder-section" as const;
export const circuitSectionReorderCommandSchemaVersion = 1 as const;
export interface CircuitSectionReorderAssignment {
circuitId: string;
expectedSortOrder: number;
targetSortOrder: number;
}
export interface CircuitSectionReorderCommandPayload {
sectionId: string;
assignments: CircuitSectionReorderAssignment[];
}
export interface CircuitSectionReorderProjectCommand
extends SerializedProjectCommand<CircuitSectionReorderCommandPayload> {
schemaVersion: typeof circuitSectionReorderCommandSchemaVersion;
type: typeof circuitSectionReorderCommandType;
}
export function createCircuitSectionReorderProjectCommand(
sectionId: string,
assignments: CircuitSectionReorderAssignment[]
): CircuitSectionReorderProjectCommand {
const command: CircuitSectionReorderProjectCommand = {
schemaVersion: circuitSectionReorderCommandSchemaVersion,
type: circuitSectionReorderCommandType,
payload: { sectionId, assignments },
};
assertCircuitSectionReorderProjectCommand(command);
return command;
}
export function assertCircuitSectionReorderProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitSectionReorderProjectCommand {
if (
command.schemaVersion !== circuitSectionReorderCommandSchemaVersion ||
command.type !== circuitSectionReorderCommandType
) {
throw new Error("Unsupported circuit section reorder command.");
}
if (!isPlainObject(command.payload)) {
throw new Error(
"Circuit section reorder 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 reorder command requires assignments."
);
}
const circuitIds = new Set<string>();
let hasChangedPosition = false;
for (const assignment of assignments) {
if (!isPlainObject(assignment)) {
throw new Error(
"Circuit section reorder command contains an invalid assignment."
);
}
assertNonEmptyString(assignment.circuitId, "circuitId");
assertFiniteNumber(
assignment.expectedSortOrder,
"expectedSortOrder"
);
assertFiniteNumber(
assignment.targetSortOrder,
"targetSortOrder"
);
if (circuitIds.has(assignment.circuitId)) {
throw new Error(
"Circuit section reorder command contains duplicate circuit ids."
);
}
if (
assignment.expectedSortOrder !== assignment.targetSortOrder
) {
hasChangedPosition = true;
}
circuitIds.add(assignment.circuitId);
}
if (!hasChangedPosition) {
throw new Error(
"Circuit section reorder command must change at least one position."
);
}
}
function assertNonEmptyString(
value: unknown,
field: string
): asserts value is string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} must be a non-empty string.`);
}
}
function assertFiniteNumber(
value: unknown,
field: string
): asserts value is number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error(`${field} must be a finite number.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,26 @@
import type { CircuitSectionReorderProjectCommand } from "../models/circuit-section-reorder-project-command.model.js";
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "./project-revision.store.js";
export interface ExecuteCircuitSectionReorderCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: CircuitSectionReorderProjectCommand;
}
export interface ExecutedCircuitSectionReorderCommand {
revision: AppendedProjectRevision;
inverse: CircuitSectionReorderProjectCommand;
}
export interface CircuitSectionReorderProjectCommandStore {
execute(
input: ExecuteCircuitSectionReorderCommandInput
): ExecutedCircuitSectionReorderCommand;
}
@@ -19,6 +19,10 @@ import {
assertCircuitUpdateProjectCommand, assertCircuitUpdateProjectCommand,
circuitUpdateCommandType, circuitUpdateCommandType,
} from "../models/circuit-project-command.model.js"; } from "../models/circuit-project-command.model.js";
import {
assertCircuitSectionReorderProjectCommand,
circuitSectionReorderCommandType,
} from "../models/circuit-section-reorder-project-command.model.js";
import { import {
assertCircuitDeleteProjectCommand, assertCircuitDeleteProjectCommand,
assertCircuitInsertProjectCommand, assertCircuitInsertProjectCommand,
@@ -33,6 +37,7 @@ import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-devic
import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-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 { 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 { 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,
@@ -63,6 +68,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore, private readonly deviceRowStructureStore: CircuitDeviceRowStructureProjectCommandStore,
private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore, private readonly deviceRowMoveStore: CircuitDeviceRowMoveProjectCommandStore,
private readonly circuitStructureStore: CircuitStructureProjectCommandStore, private readonly circuitStructureStore: CircuitStructureProjectCommandStore,
private readonly circuitSectionReorderStore: CircuitSectionReorderProjectCommandStore,
private readonly historyStore: ProjectHistoryStore private readonly historyStore: ProjectHistoryStore
) {} ) {}
@@ -186,6 +192,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case circuitSectionReorderCommandType: {
assertCircuitSectionReorderProjectCommand(input.command);
return this.circuitSectionReorderStore.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}.`
@@ -3,6 +3,7 @@ import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/
import { CircuitDeviceRowMoveProjectCommandRepository } from "../../db/repositories/circuit-device-row-move-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 { 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 { 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";
@@ -16,6 +17,8 @@ export const circuitDeviceRowMoveProjectCommandStore =
new CircuitDeviceRowMoveProjectCommandRepository(db); new CircuitDeviceRowMoveProjectCommandRepository(db);
export const circuitStructureProjectCommandStore = export const circuitStructureProjectCommandStore =
new CircuitStructureProjectCommandRepository(db); new CircuitStructureProjectCommandRepository(db);
export const circuitSectionReorderProjectCommandStore =
new CircuitSectionReorderProjectCommandRepository(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,
@@ -23,5 +26,6 @@ export const projectCommandService = new ProjectCommandService(
circuitDeviceRowStructureProjectCommandStore, circuitDeviceRowStructureProjectCommandStore,
circuitDeviceRowMoveProjectCommandStore, circuitDeviceRowMoveProjectCommandStore,
circuitStructureProjectCommandStore, circuitStructureProjectCommandStore,
circuitSectionReorderProjectCommandStore,
projectHistoryStore projectHistoryStore
); );
@@ -0,0 +1,391 @@
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 { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-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 { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-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 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,
},
{
id: "circuit-2",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F2",
sortOrder: 20,
},
{
id: "circuit-3",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F3",
sortOrder: 30,
},
{
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: section.id,
foreignSectionId: foreignSection.id,
};
}
function getCircuitState(context: DatabaseContext) {
return context.db
.select({
id: circuits.id,
equipmentIdentifier: circuits.equipmentIdentifier,
sortOrder: circuits.sortOrder,
})
.from(circuits)
.all()
.filter((circuit) => circuit.id !== "circuit-foreign")
.sort((left, right) => left.id.localeCompare(right.id));
}
function createReorderCommand(sectionId: string) {
return createCircuitSectionReorderProjectCommand(sectionId, [
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 30,
},
{
circuitId: "circuit-3",
expectedSortOrder: 30,
targetSortOrder: 10,
},
]);
}
describe("circuit section reorder project-command repository", () => {
it("reorders a complete section and supports persisted undo and redo", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionReorderProjectCommandRepository(
fixture.context.db
);
const command = createReorderCommand(fixture.sectionId);
const reordered = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(getCircuitState(fixture.context), [
{
id: "circuit-1",
equipmentIdentifier: "-1F1",
sortOrder: 20,
},
{
id: "circuit-2",
equipmentIdentifier: "-1F2",
sortOrder: 30,
},
{
id: "circuit-3",
equipmentIdentifier: "-1F3",
sortOrder: 10,
},
]);
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:
reordered.revision.changeSetId,
command: reordered.inverse,
});
assert.deepEqual(
getCircuitState(fixture.context).map(
(circuit) => circuit.sortOrder
),
[10, 20, 30]
);
store.execute({
projectId: "project-1",
expectedRevision: 2,
source: "redo",
historyTargetChangeSetId:
reordered.revision.changeSetId,
command,
});
assert.deepEqual(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
),
{
projectId: "project-1",
currentRevision: 3,
undoDepth: 1,
redoDepth: 0,
undoChangeSetId: reordered.revision.changeSetId,
redoChangeSetId: null,
}
);
} finally {
fixture.context.close();
}
});
it("rejects incomplete, stale and foreign-section reorders", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionReorderProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionReorderProjectCommand(
fixture.sectionId,
[
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 10,
},
]
),
}),
/every circuit/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionReorderProjectCommand(
fixture.sectionId,
[
{
circuitId: "circuit-1",
expectedSortOrder: 999,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 30,
},
{
circuitId: "circuit-3",
expectedSortOrder: 30,
targetSortOrder: 10,
},
]
),
}),
/changed before/
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createCircuitSectionReorderProjectCommand(
fixture.foreignSectionId,
[
{
circuitId: "circuit-foreign",
expectedSortOrder: 10,
targetSortOrder: 20,
},
]
),
}),
/does not belong to project/
);
assert.deepEqual(
getCircuitState(fixture.context).map(
(circuit) => circuit.sortOrder
),
[10, 20, 30]
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back all sort positions for late history failures", () => {
const fixture = createTestDatabase();
try {
fixture.context.sqlite.exec(`
CREATE TRIGGER fail_reorder_history
BEFORE INSERT ON project_history_stack_entries
BEGIN
SELECT RAISE(ABORT, 'forced reorder history failure');
END;
`);
const store =
new CircuitSectionReorderProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createReorderCommand(fixture.sectionId),
}),
/forced reorder history failure/
);
assert.deepEqual(
getCircuitState(fixture.context).map(
(circuit) => circuit.sortOrder
),
[10, 20, 30]
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rolls back the reorder for a stale project revision", () => {
const fixture = createTestDatabase();
try {
const store =
new CircuitSectionReorderProjectCommandRepository(
fixture.context.db
);
assert.throws(
() =>
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createReorderCommand(fixture.sectionId),
}),
/at revision 0, expected 1/
);
assert.deepEqual(
getCircuitState(fixture.context).map(
(circuit) => circuit.sortOrder
),
[10, 20, 30]
);
} finally {
fixture.context.close();
}
});
});
+62
View File
@@ -29,6 +29,10 @@ import {
createCircuitDeleteProjectCommand, createCircuitDeleteProjectCommand,
createCircuitInsertProjectCommand, createCircuitInsertProjectCommand,
} from "../src/domain/models/circuit-structure-project-command.model.js"; } from "../src/domain/models/circuit-structure-project-command.model.js";
import {
assertCircuitSectionReorderProjectCommand,
createCircuitSectionReorderProjectCommand,
} from "../src/domain/models/circuit-section-reorder-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", () => {
@@ -519,3 +523,61 @@ describe("circuit structure project commands", () => {
); );
}); });
}); });
describe("circuit section reorder project commands", () => {
it("captures every expected and target sort position", () => {
const command = createCircuitSectionReorderProjectCommand(
"section-1",
[
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 10,
},
]
);
assert.equal(command.payload.assignments.length, 2);
assert.doesNotThrow(() =>
assertCircuitSectionReorderProjectCommand(command)
);
});
it("rejects empty, duplicate and complete no-op assignments", () => {
assert.throws(
() => createCircuitSectionReorderProjectCommand("", []),
/sectionId/
);
assert.throws(
() =>
createCircuitSectionReorderProjectCommand("section-1", [
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-1",
expectedSortOrder: 20,
targetSortOrder: 10,
},
]),
/duplicate circuit ids/
);
assert.throws(
() =>
createCircuitSectionReorderProjectCommand("section-1", [
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 10,
},
]),
/change at least one position/
);
});
});
+88
View File
@@ -11,6 +11,7 @@ import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories
import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/repositories/circuit-device-row-move-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 { 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 { 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";
@@ -29,6 +30,7 @@ import {
} from "../src/domain/models/circuit-device-row-move-project-command.model.js"; } 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 { 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 { 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";
@@ -84,6 +86,7 @@ function createService(context: DatabaseContext) {
new CircuitDeviceRowStructureProjectCommandRepository(context.db), new CircuitDeviceRowStructureProjectCommandRepository(context.db),
new CircuitDeviceRowMoveProjectCommandRepository(context.db), new CircuitDeviceRowMoveProjectCommandRepository(context.db),
new CircuitStructureProjectCommandRepository(context.db), new CircuitStructureProjectCommandRepository(context.db),
new CircuitSectionReorderProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db) new ProjectHistoryRepository(context.db)
); );
} }
@@ -475,6 +478,91 @@ describe("project command service", () => {
} }
}); });
it("dispatches complete section reorders without changing identifiers", () => {
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 reordered = createService(context).executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createCircuitSectionReorderProjectCommand(
firstCircuit.sectionId,
[
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 10,
},
]
),
});
assert.equal(reordered.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: "-1F1",
sortOrder: 20,
},
{
id: "circuit-2",
equipmentIdentifier: "-1F2",
sortOrder: 10,
},
]
);
createService(context).undo({
projectId: "project-1",
expectedRevision: 1,
});
assert.deepEqual(
context.db
.select({ id: circuits.id, sortOrder: circuits.sortOrder })
.from(circuits)
.all()
.sort((left, right) => left.id.localeCompare(right.id)),
[
{ id: "circuit-1", sortOrder: 10 },
{ id: "circuit-2", sortOrder: 20 },
]
);
} 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 {