Add device row move history

This commit is contained in:
2026-07-23 22:29:44 +02:00
parent ca13efbfcb
commit 0a51703315
14 changed files with 940 additions and 13 deletions
+3 -1
View File
@@ -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:
+11 -5
View File
@@ -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
+8 -3
View File
@@ -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
@@ -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
@@ -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
+2 -2
View File
@@ -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",
@@ -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 };
});
}
}
@@ -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<CircuitDeviceRowMoveCommandPayload> {
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<unknown>
): 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<string>();
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<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -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;
}
@@ -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({
@@ -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
);
@@ -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();
}
});
});
+69
View File
@@ -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",
+65
View File
@@ -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 {