Persist circuit reorders

This commit is contained in:
2026-07-25 21:22:10 +02:00
parent 08a2775a88
commit abcb468807
26 changed files with 914 additions and 372 deletions
+11 -9
View File
@@ -220,15 +220,17 @@ After saving, the row becomes linked to the new project device.
## Undo / Redo ## Undo / Redo
The editor's visible undo/redo stack remains session-local while reorder and The editor's visible undo/redo stack remains session-local while renumber
renumber operations are being migrated. Existing Circuit and CircuitDeviceRow operations are being migrated. Existing Circuit and CircuitDeviceRow cell
cell edits, standalone insertion/deletion and CircuitDeviceRow moves already edits, standalone insertion/deletion, CircuitDeviceRow moves and Circuit
execute persistent project commands; their toolbar undo/redo actions use the reorders already execute persistent project commands; their toolbar undo/redo
project-wide history endpoints. Insertions and generated move targets use actions use the project-wide history endpoints. Applying a sorted view across
client-generated stable UUIDs, and undo restores the same ids from complete multiple sections is one atomic `circuit.reorder-sections` command and one undo
server snapshots. The tree response supplies the optimistic `currentRevision`; step. Insertions and generated move targets use client-generated stable UUIDs,
obsolete direct field PATCH, structure POST, move, Circuit and CircuitDeviceRow and undo restores the same ids from complete server snapshots. The tree
DELETE routes are removed. response supplies the optimistic `currentRevision`; obsolete direct field
PATCH, structure POST, move, reorder, Circuit and CircuitDeviceRow DELETE
routes are removed.
Reloading the page does not yet reconstruct the visible editor stack. Reloading the page does not yet reconstruct the visible editor stack.
CircuitDeviceRow moves between existing circuits and moves that create one new CircuitDeviceRow moves between existing circuits and moves that create one new
placeholder target circuit are persisted. The latter stores the complete empty placeholder target circuit are persisted. The latter stores the complete empty
+15 -21
View File
@@ -27,7 +27,8 @@ 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`, `circuit-device-row.insert`, `circuit-device-row.delete`,
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`, `circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
`circuit.reorder-section`, `circuit.renumber-section` and `circuit.reorder-section`, `circuit.reorder-sections`,
`circuit.renumber-section` and
`project-device.update`, `project-device.insert`, `project-device.delete` and `project-device.update`, `project-device.insert`, `project-device.delete` and
`project-device.sync-rows`, all with schema version `1`. Other command types `project-device.sync-rows`, all with schema version `1`. Other command types
are rejected. Insert commands contain the complete entity or circuit block are rejected. Insert commands contain the complete entity or circuit block
@@ -37,8 +38,8 @@ contain each row's expected and target circuit plus its exact expected and
target sort order. This makes deletion and moves undoable without generating target sort order. This makes deletion and moves undoable without generating
replacement identities or renumbering circuits. Existing Circuit and replacement identities or renumbering circuits. Existing Circuit and
CircuitDeviceRow cell edits, standalone insert/delete actions and device-row CircuitDeviceRow cell edits, standalone insert/delete actions and device-row
moves in the editor consume these endpoints. Reorder and renumber UI paths are moves and reorders in the editor consume these endpoints. The renumber UI path
still being migrated. is still being migrated.
`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
@@ -52,6 +53,11 @@ in the section. Each assignment records the expected and target `sortOrder`.
The command and its inverse change no circuit field other than `sortOrder`; The command and its inverse change no circuit field other than `sortOrder`;
equipment identifiers and complete device-row blocks remain unchanged. equipment identifiers and complete device-row blocks remain unchanged.
`circuit.reorder-sections` contains one complete assignment block per affected
section. All blocks are validated before any write and commit or roll back
together as one revision and one undo step. The editor uses it when applying a
sorted view that changes multiple sections.
`circuit.renumber-section` is an explicit operation requiring one assignment `circuit.renumber-section` is an explicit operation requiring one assignment
for every circuit in the section. Assignments contain expected and target for every circuit in the section. Assignments contain expected and target
equipment identifiers. The store rejects stale values, duplicate targets and equipment identifiers. The store rejects stale values, duplicate targets and
@@ -169,24 +175,12 @@ Response sketch:
- `GET /circuit-sections/:sectionId/next-identifier` - `GET /circuit-sections/:sectionId/next-identifier`
- preview next identifier for section (`prefix + maxSuffix + 1`) - preview next identifier for section (`prefix + maxSuffix + 1`)
Circuit and device-row field updates, standalone insertions/deletions and Circuit and device-row field updates, standalone insertions/deletions, single
single or bulk device-row moves are available only as the corresponding or bulk device-row moves and circuit reorders are available only as the
versioned commands through `POST /projects/:projectId/commands`. There are no corresponding versioned commands through
direct field-update PATCH, structure POST, move, Circuit DELETE or `POST /projects/:projectId/commands`. There are no direct field-update PATCH,
CircuitDeviceRow DELETE routes. structure POST, move, reorder, Circuit DELETE or CircuitDeviceRow DELETE
routes.
### Reorder Circuits
- `PATCH /circuit-sections/:sectionId/circuits/reorder`
- Purpose: persist explicit circuit order within one section.
Request sketch:
```json
{
"orderedCircuitIds": ["cir_2", "cir_1", "cir_3"]
}
```
### Renumber Section ### Renumber Section
+7 -5
View File
@@ -126,9 +126,11 @@ Undo/redo wraps editor operations as command objects and reloads the tree after
each command. Existing Circuit and device-row cell edits plus standalone each command. Existing Circuit and device-row cell edits plus standalone
insert/delete actions execute persistent project commands; their toolbar insert/delete actions execute persistent project commands; their toolbar
undo/redo calls the project-wide server history. New entities receive stable undo/redo calls the project-wide server history. New entities receive stable
UUIDs before insertion, and deletion undo restores those same ids. Move, UUIDs before insertion, and deletion undo restores those same ids. Device-row
reorder and renumber operations still use their existing direct API writes and moves and circuit reorders also use persistent project commands. Applying a
session-local inverse callbacks during the cutover. sorted view across multiple sections is atomic and occupies one history step.
Renumbering still uses its direct API write and session-local inverse callback
during the cutover.
Covered operations include: Covered operations include:
@@ -142,5 +144,5 @@ Covered operations include:
Current limitations: Current limitations:
- the visible editor stack is still session-local and is empty after reload - the visible editor stack is still session-local and is empty after reload
- move, reorder and renumber editor actions are not connected to the persistent - renumber editor actions are not connected to the persistent command boundary
command boundary yet yet
@@ -3,8 +3,9 @@
- Existing Circuit and CircuitDeviceRow cell edits plus standalone - Existing Circuit and CircuitDeviceRow cell edits plus standalone
insert/delete actions and CircuitDeviceRow moves use persistent project insert/delete actions and CircuitDeviceRow moves use persistent project
commands and project-wide toolbar undo/redo. The editor's visible stack is commands and project-wide toolbar undo/redo. The editor's visible stack is
still session-local, is empty after reload and still contains direct still session-local, is empty after reload and still contains the direct
reorder/renumber operations during the cutover. renumber operation during the cutover. Circuit reorder is already persistent,
including atomic multi-section sorted-order application.
- Server-side project revisions and persistent undo/redo eligibility exist for - Server-side project revisions and persistent undo/redo eligibility exist for
the command types documented in `circuit-list-editor-api.md`; complete editor the command types documented in `circuit-list-editor-api.md`; complete editor
command coverage and history reconstruction are not complete. command coverage and history reconstruction are not complete.
+8 -4
View File
@@ -92,10 +92,10 @@ sowie eigenständiges Einfügen und Löschen verwendet das Grid bereits über di
UUID vor dem Command; Löschen und Undo bewahren diese Identität. Der Tree liefert UUID vor dem Command; Löschen und Undo bewahren diese Identität. Der Tree liefert
dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektweite dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektweite
Serverhistorie. Direkte Feld-PATCH-, Struktur-POST-, Move-, Circuit- und Serverhistorie. Direkte Feld-PATCH-, Struktur-POST-, Move-, Circuit- und
Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves im Grid Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves und
verwenden die persistenten Kommandos bereits. Nur Reorder- und Stromkreis-Reorders im Grid verwenden die persistenten Kommandos bereits. Nur
Renumber-UI-Pfade verbleiben vorerst im sichtbaren sitzungslokalen Stack; nach der Renumber-UI-Pfad verbleibt vorerst im sichtbaren sitzungslokalen Stack;
einem Reload wird dieser noch nicht aus der Serverhistorie rekonstruiert. nach einem Reload wird dieser noch nicht aus der Serverhistorie rekonstruiert.
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen `circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
@@ -115,6 +115,10 @@ bleiben unverändert. `circuit.renumber-section` bildet die getrennte,
ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und
neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und
ändert weder Sortierung noch Gerätezeilen. ändert weder Sortierung noch Gerätezeilen.
Drag-and-drop verwendet `circuit.reorder-section`. Die explizite Übernahme
einer sortierten Ansicht verwendet `circuit.reorder-sections`, damit alle
betroffenen Bereiche in einer Transaktion und als ein Undo-Schritt gespeichert
werden. Direkte Reorder-Endpunkte existieren nicht mehr.
`project-device.sync-rows` persistiert Synchronisierung, Trennen und erneutes `project-device.sync-rows` persistiert Synchronisierung, Trennen und erneutes
Verknüpfen als atomaren Mehrzeilen-Command. Jede betroffene Zeile enthält den Verknüpfen als atomaren Mehrzeilen-Command. Jede betroffene Zeile enthält den
vollständigen erwarteten und neuen Stand aller synchronisierbaren Felder, vollständigen erwarteten und neuen Stand aller synchronisierbaren Felder,
@@ -200,6 +200,10 @@ Completed foundation:
- `circuit.reorder-section` requires the complete section circuit set and - `circuit.reorder-section` requires the complete section circuit set and
stores exact expected/target sort positions; its inverse changes no stores exact expected/target sort positions; its inverse changes no
equipment identifier and never splits a circuit block equipment identifier and never splits a circuit block
- `circuit.reorder-sections` applies the same invariants to every affected
section from a sorted view in one transaction and one history entry
- circuit drag-and-drop and sorted-order application use these commands; the
obsolete direct reorder route and transaction method are removed
- `circuit.renumber-section` is the separate explicit renumber operation; it - `circuit.renumber-section` is the separate explicit renumber operation; it
stores every expected/target equipment identifier, uses collision-safe stores every expected/target equipment identifier, uses collision-safe
temporary values and restores the exact prior identifiers on undo temporary values and restores the exact prior identifiers on undo
@@ -214,8 +218,8 @@ Completed foundation:
Remaining constraints before completing persistent history: Remaining constraints before completing persistent history:
- route the remaining reorder and renumber editor writes through their existing - route the remaining renumber editor writes through its existing typed
typed persistent commands and remove the final direct mutation routes persistent command and remove the final direct mutation routes
- reconstruct the visible frontend history state after reload; Circuit and - reconstruct the visible frontend history state after reload; Circuit and
CircuitDeviceRow field/insert/delete actions plus ProjectDevice operations CircuitDeviceRow field/insert/delete actions plus ProjectDevice operations
already use persistent commands and project-wide undo already use persistent commands and project-wide undo
+5 -4
View File
@@ -431,10 +431,11 @@ Implemented foundation:
and rollback after a forced late persistence failure and rollback after a forced late persistence failure
ProjectDevice CRUD, synchronization and disconnect on the project page and ProjectDevice CRUD, synchronization and disconnect on the project page and
Circuit/CircuitDeviceRow field/insert/delete actions in the circuit-list editor Circuit/CircuitDeviceRow field/insert/delete actions, device-row moves and
are connected to this history boundary. Move, reorder and renumber UI paths are circuit reorders in the circuit-list editor are connected to this history
not yet connected; the editor's visible undo/redo stack therefore remains boundary. Applying sorted order across multiple sections is one atomic command.
session-local and is not reconstructed after reload. The renumber UI path is not yet connected; the editor's visible undo/redo stack
therefore remains session-local and is not reconstructed after reload.
Acceptance criteria: Acceptance criteria:
+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-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "test": "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-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
@@ -1,8 +1,18 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { import {
assertCircuitSectionReorderProjectCommand, assertCircuitSectionReorderProjectCommand,
circuitSectionReorderCommandType,
createCircuitSectionReorderProjectCommand, createCircuitSectionReorderProjectCommand,
type CircuitSectionReorderAssignment,
type CircuitSectionReorderProjectCommand,
} from "../../domain/models/circuit-section-reorder-project-command.model.js"; } from "../../domain/models/circuit-section-reorder-project-command.model.js";
import {
assertCircuitSectionsReorderProjectCommand,
circuitSectionsReorderCommandType,
createCircuitSectionsReorderProjectCommand,
type CircuitSectionsReorderProjectCommand,
type CircuitSectionsReorderSection,
} from "../../domain/models/circuit-sections-reorder-project-command.model.js";
import type { import type {
CircuitSectionReorderProjectCommandStore, CircuitSectionReorderProjectCommandStore,
ExecuteCircuitSectionReorderCommandInput, ExecuteCircuitSectionReorderCommandInput,
@@ -14,106 +24,49 @@ import { circuits } from "../schema/circuits.js";
import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { applyProjectHistoryTransition } from "./project-history.persistence.js";
import { appendProjectRevision } from "./project-revision.persistence.js"; import { appendProjectRevision } from "./project-revision.persistence.js";
type ReorderHistoryCommand =
| CircuitSectionReorderProjectCommand
| CircuitSectionsReorderProjectCommand;
export class CircuitSectionReorderProjectCommandRepository export class CircuitSectionReorderProjectCommandRepository
implements CircuitSectionReorderProjectCommandStore implements CircuitSectionReorderProjectCommandStore
{ {
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteCircuitSectionReorderCommandInput) { execute(input: ExecuteCircuitSectionReorderCommandInput) {
assertCircuitSectionReorderProjectCommand(input.command); this.assertSupportedCommand(input.command);
return this.database.transaction((tx) => { return this.database.transaction((tx) => {
const section = tx const commandSections = this.getCommandSections(input.command);
.select({ const inverseSections = commandSections.map((section) => {
id: circuitSections.id, this.assertCompleteCurrentSection(
circuitListId: circuitSections.circuitListId, tx,
projectId: circuitLists.projectId, input.projectId,
}) section
.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."
); );
return {
sectionId: section.sectionId,
assignments: section.assignments.map((assignment) => ({
circuitId: assignment.circuitId,
expectedSortOrder: assignment.targetSortOrder,
targetSortOrder: assignment.expectedSortOrder,
})),
};
});
for (const section of commandSections) {
this.applyAssignments(tx, section);
} }
const persistedCircuits = tx const inverse =
.select({ input.command.type === circuitSectionReorderCommandType
id: circuits.id, ? createCircuitSectionReorderProjectCommand(
circuitListId: circuits.circuitListId, inverseSections[0].sectionId,
sortOrder: circuits.sortOrder, inverseSections[0].assignments
})
.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
)
) )
) : createCircuitSectionsReorderProjectCommand(
.run(); inverseSections
if (updated.changes !== 1) { );
throw new Error(
"Circuit changed during section reorder execution."
);
}
}
const revision = appendProjectRevision(tx, { const revision = appendProjectRevision(tx, {
projectId: input.projectId, projectId: input.projectId,
expectedRevision: input.expectedRevision, expectedRevision: input.expectedRevision,
@@ -132,4 +85,125 @@ export class CircuitSectionReorderProjectCommandRepository
return { revision, inverse }; return { revision, inverse };
}); });
} }
private assertSupportedCommand(
command: ReorderHistoryCommand
) {
if (command.type === circuitSectionReorderCommandType) {
assertCircuitSectionReorderProjectCommand(command);
return;
}
if (command.type === circuitSectionsReorderCommandType) {
assertCircuitSectionsReorderProjectCommand(command);
return;
}
throw new Error("Unsupported circuit section reorder command.");
}
private getCommandSections(
command: ReorderHistoryCommand
): CircuitSectionsReorderSection[] {
if (command.type === circuitSectionReorderCommandType) {
return [
{
sectionId: command.payload.sectionId,
assignments: command.payload.assignments,
},
];
}
return command.payload.sections;
}
private assertCompleteCurrentSection(
database: AppDatabase,
projectId: string,
sectionCommand: CircuitSectionsReorderSection
) {
const section = database
.select({
id: circuitSections.id,
circuitListId: circuitSections.circuitListId,
projectId: circuitLists.projectId,
})
.from(circuitSections)
.innerJoin(
circuitLists,
eq(circuitLists.id, circuitSections.circuitListId)
)
.where(eq(circuitSections.id, sectionCommand.sectionId))
.get();
if (!section || section.projectId !== projectId) {
throw new Error(
"Circuit section does not belong to project."
);
}
const persistedCircuits = database
.select({
id: circuits.id,
circuitListId: circuits.circuitListId,
sortOrder: circuits.sortOrder,
})
.from(circuits)
.where(eq(circuits.sectionId, section.id))
.all();
const assignments = sectionCommand.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."
);
}
}
}
private applyAssignments(
database: AppDatabase,
sectionCommand: {
sectionId: string;
assignments: CircuitSectionReorderAssignment[];
}
) {
for (const assignment of sectionCommand.assignments) {
if (
assignment.expectedSortOrder === assignment.targetSortOrder
) {
continue;
}
const updated = database
.update(circuits)
.set({ sortOrder: assignment.targetSortOrder })
.where(
and(
eq(circuits.id, assignment.circuitId),
eq(circuits.sectionId, sectionCommand.sectionId),
eq(circuits.sortOrder, assignment.expectedSortOrder)
)
)
.run();
if (updated.changes !== 1) {
throw new Error(
"Circuit changed during section reorder execution."
);
}
}
}
} }
@@ -8,54 +8,6 @@ export class CircuitSectionTransactionRepository
{ {
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
updateSortOrders(sectionId: string, orderedCircuitIds: string[]) {
if (orderedCircuitIds.length === 0) {
return;
}
if (new Set(orderedCircuitIds).size !== orderedCircuitIds.length) {
throw new Error(
"Stromkreise dürfen in der Reihenfolge nicht mehrfach vorkommen."
);
}
this.database.transaction((tx) => {
const sectionCircuits = tx
.select({ id: circuits.id })
.from(circuits)
.where(eq(circuits.sectionId, sectionId))
.all();
const sectionCircuitIds = new Set(
sectionCircuits.map((circuit) => circuit.id)
);
if (
sectionCircuits.length !== orderedCircuitIds.length ||
orderedCircuitIds.some((circuitId) => !sectionCircuitIds.has(circuitId))
) {
throw new Error(
"Die Reihenfolge muss alle Stromkreise des Bereichs enthalten."
);
}
for (let index = 0; index < orderedCircuitIds.length; index += 1) {
const result = tx
.update(circuits)
.set({ sortOrder: (index + 1) * 10 })
.where(
and(
eq(circuits.sectionId, sectionId),
eq(circuits.id, orderedCircuitIds[index])
)
)
.run();
if (result.changes !== 1) {
throw new Error(
"Ein Stromkreis wurde vor Abschluss der Sortierung verändert."
);
}
}
});
}
updateEquipmentIdentifiers( updateEquipmentIdentifiers(
circuitListId: string, circuitListId: string,
updates: Array<{ id: string; equipmentIdentifier: string }>, updates: Array<{ id: string; equipmentIdentifier: string }>,
@@ -0,0 +1,94 @@
import {
assertCircuitSectionReorderProjectCommand,
circuitSectionReorderCommandSchemaVersion,
type CircuitSectionReorderAssignment,
} from "./circuit-section-reorder-project-command.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const circuitSectionsReorderCommandType =
"circuit.reorder-sections" as const;
export const circuitSectionsReorderCommandSchemaVersion =
circuitSectionReorderCommandSchemaVersion;
export interface CircuitSectionsReorderSection {
sectionId: string;
assignments: CircuitSectionReorderAssignment[];
}
export interface CircuitSectionsReorderCommandPayload {
sections: CircuitSectionsReorderSection[];
}
export interface CircuitSectionsReorderProjectCommand
extends SerializedProjectCommand<CircuitSectionsReorderCommandPayload> {
schemaVersion: typeof circuitSectionsReorderCommandSchemaVersion;
type: typeof circuitSectionsReorderCommandType;
}
export function createCircuitSectionsReorderProjectCommand(
sections: CircuitSectionsReorderSection[]
): CircuitSectionsReorderProjectCommand {
const command: CircuitSectionsReorderProjectCommand = {
schemaVersion: circuitSectionsReorderCommandSchemaVersion,
type: circuitSectionsReorderCommandType,
payload: { sections },
};
assertCircuitSectionsReorderProjectCommand(command);
return command;
}
export function assertCircuitSectionsReorderProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is CircuitSectionsReorderProjectCommand {
if (
command.schemaVersion !==
circuitSectionsReorderCommandSchemaVersion ||
command.type !== circuitSectionsReorderCommandType
) {
throw new Error("Unsupported circuit sections reorder command.");
}
if (!isPlainObject(command.payload)) {
throw new Error(
"Circuit sections reorder payload must be an object."
);
}
const sections = command.payload.sections;
if (!Array.isArray(sections) || sections.length === 0) {
throw new Error(
"Circuit sections reorder command requires sections."
);
}
const sectionIds = new Set<string>();
for (const section of sections) {
if (!isPlainObject(section)) {
throw new Error(
"Circuit sections reorder command contains an invalid section."
);
}
if (
typeof section.sectionId !== "string" ||
!section.sectionId.trim()
) {
throw new Error("sectionId must be a non-empty string.");
}
if (sectionIds.has(section.sectionId)) {
throw new Error(
"Circuit sections reorder command contains duplicate section ids."
);
}
assertCircuitSectionReorderProjectCommand({
schemaVersion: circuitSectionReorderCommandSchemaVersion,
type: "circuit.reorder-section",
payload: {
sectionId: section.sectionId,
assignments: section.assignments,
},
});
sectionIds.add(section.sectionId);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -1,4 +1,5 @@
import type { CircuitSectionReorderProjectCommand } from "../models/circuit-section-reorder-project-command.model.js"; import type { CircuitSectionReorderProjectCommand } from "../models/circuit-section-reorder-project-command.model.js";
import type { CircuitSectionsReorderProjectCommand } from "../models/circuit-sections-reorder-project-command.model.js";
import type { import type {
AppendedProjectRevision, AppendedProjectRevision,
ProjectRevisionSource, ProjectRevisionSource,
@@ -11,12 +12,16 @@ export interface ExecuteCircuitSectionReorderCommandInput {
description?: string; description?: string;
actorId?: string; actorId?: string;
historyTargetChangeSetId?: string; historyTargetChangeSetId?: string;
command: CircuitSectionReorderProjectCommand; command:
| CircuitSectionReorderProjectCommand
| CircuitSectionsReorderProjectCommand;
} }
export interface ExecutedCircuitSectionReorderCommand { export interface ExecutedCircuitSectionReorderCommand {
revision: AppendedProjectRevision; revision: AppendedProjectRevision;
inverse: CircuitSectionReorderProjectCommand; inverse:
| CircuitSectionReorderProjectCommand
| CircuitSectionsReorderProjectCommand;
} }
export interface CircuitSectionReorderProjectCommandStore { export interface CircuitSectionReorderProjectCommandStore {
@@ -4,5 +4,4 @@ export interface CircuitSectionTransactionStore {
updates: Array<{ id: string; equipmentIdentifier: string }>, updates: Array<{ id: string; equipmentIdentifier: string }>,
tempNamespace: string tempNamespace: string
): void; ): void;
updateSortOrders(sectionId: string, orderedCircuitIds: string[]): void;
} }
@@ -2,7 +2,6 @@ import type { CircuitSectionTransactionStore } from "../ports/circuit-section-tr
import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js";
import type { import type {
ReorderSectionCircuitsInput,
UpdateSectionEquipmentIdentifiersInput, UpdateSectionEquipmentIdentifiersInput,
} from "../../shared/validation/circuit.schemas.js"; } from "../../shared/validation/circuit.schemas.js";
import { CircuitNumberingService } from "./circuit-numbering.service.js"; import { CircuitNumberingService } from "./circuit-numbering.service.js";
@@ -97,27 +96,4 @@ export class CircuitWriteService {
return this.circuitRepository.listBySection(sectionId); return this.circuitRepository.listBySection(sectionId);
} }
async reorderCircuitsInSection(sectionId: string, input: ReorderSectionCircuitsInput) {
// Reorder updates sortOrder only. BMKs remain unchanged; users may renumber explicitly later.
const section = await this.circuitSectionRepository.findById(sectionId);
if (!section) {
throw new Error("Invalid section id.");
}
const sectionCircuits = await this.circuitRepository.listBySection(sectionId);
const sectionIds = new Set(sectionCircuits.map((circuit) => circuit.id));
if (sectionCircuits.length !== input.orderedCircuitIds.length) {
throw new Error("orderedCircuitIds must include all circuits of the section.");
}
for (const circuitId of input.orderedCircuitIds) {
if (!sectionIds.has(circuitId)) {
throw new Error("Circuit id does not belong to section.");
}
}
this.getCircuitSectionTransactionStore().updateSortOrders(
sectionId,
input.orderedCircuitIds
);
return this.circuitRepository.listBySection(sectionId);
}
} }
@@ -23,6 +23,10 @@ import {
assertCircuitSectionReorderProjectCommand, assertCircuitSectionReorderProjectCommand,
circuitSectionReorderCommandType, circuitSectionReorderCommandType,
} from "../models/circuit-section-reorder-project-command.model.js"; } from "../models/circuit-section-reorder-project-command.model.js";
import {
assertCircuitSectionsReorderProjectCommand,
circuitSectionsReorderCommandType,
} from "../models/circuit-sections-reorder-project-command.model.js";
import { import {
assertCircuitSectionRenumberProjectCommand, assertCircuitSectionRenumberProjectCommand,
circuitSectionRenumberCommandType, circuitSectionRenumberCommandType,
@@ -225,6 +229,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).revision;
} }
case circuitSectionsReorderCommandType: {
assertCircuitSectionsReorderProjectCommand(input.command);
return this.circuitSectionReorderStore.execute({
...input,
command: input.command,
}).revision;
}
case circuitSectionRenumberCommandType: { case circuitSectionRenumberCommandType: {
assertCircuitSectionRenumberProjectCommand(input.command); assertCircuitSectionRenumberProjectCommand(input.command);
return this.circuitSectionRenumberStore.execute({ return this.circuitSectionRenumberStore.execute({
+80 -32
View File
@@ -30,6 +30,9 @@ import {
import { import {
buildCircuitDeviceRowMoveAssignments, buildCircuitDeviceRowMoveAssignments,
} from "../utils/circuit-device-row-move-command"; } from "../utils/circuit-device-row-move-command";
import {
buildCircuitSectionReorderAssignments,
} from "../utils/circuit-section-reorder-command";
import type { import type {
CellKey, CellKey,
CellKind, CellKind,
@@ -52,7 +55,8 @@ import {
listProjectDevices, listProjectDevices,
moveCircuitDeviceRowsCommand, moveCircuitDeviceRowsCommand,
moveCircuitDeviceRowsToNewCircuitCommand, moveCircuitDeviceRowsToNewCircuitCommand,
reorderSectionCircuits, reorderCircuitSectionCommand,
reorderCircuitSectionsCommand,
renumberCircuitSection, renumberCircuitSection,
redoProjectCommand, redoProjectCommand,
updateSectionEquipmentIdentifiers, updateSectionEquipmentIdentifiers,
@@ -991,22 +995,42 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return; return;
} }
// Sorting is view-only until explicitly applied; this avoids accidental persisted reorders. const reorderedSections = changedSections.map((changedSection) => {
const currentSection = data.sections.find(
(section) => section.id === changedSection.sectionId
);
if (!currentSection) {
throw new Error("Ein sortierter Bereich wurde nicht gefunden.");
}
return {
sectionId: currentSection.id,
assignments: buildCircuitSectionReorderAssignments(
currentSection.circuits,
changedSection.order
),
};
});
// Sorting is view-only until explicitly applied. All affected sections are
// then committed as one atomic project-history entry.
await runCommand({ await runCommand({
label: "Sortierte Reihenfolge übernehmen", label: "Sortierte Reihenfolge übernehmen",
redo: async () => { redo: async () => {
for (const section of changedSections) { const result = await reorderCircuitSectionsCommand(
await reorderSectionCircuits(section.sectionId, section.order); projectId,
} getExpectedProjectRevision(),
reorderedSections,
"Sortierte Reihenfolge übernehmen"
);
applyProjectCommandResult(result);
setSortState(null); setSortState(null);
setOpenFilterColumn(null); setOpenFilterColumn(null);
return null; return null;
}, },
undo: async () => { undo: async () => null,
for (const section of beforeBySection) { persistent: {
await reorderSectionCircuits(section.sectionId, section.order); undoIntent: () => null,
} redoIntent: () => null,
return null;
}, },
}); });
} }
@@ -2112,7 +2136,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Applies same-section circuit reorder as explicit id ordering. // Applies same-section circuit reorder as explicit id ordering.
// No implicit renumbering is performed here. // No implicit renumbering is performed here.
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) { function resolveCircuitReorderOrder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) {
const section = data?.sections.find((entry) => entry.id === intent.sectionId); const section = data?.sections.find((entry) => entry.id === intent.sectionId);
if (!section) { if (!section) {
throw new Error("Der Bereich ist ungültig."); throw new Error("Der Bereich ist ungültig.");
@@ -2135,7 +2159,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const insertIndex = intent.kind === "after-circuit" ? targetIndex + 1 : targetIndex; const insertIndex = intent.kind === "after-circuit" ? targetIndex + 1 : targetIndex;
nextIds.splice(insertIndex, 0, ...block); nextIds.splice(insertIndex, 0, ...block);
} }
await reorderSectionCircuits(section.id, nextIds); return nextIds;
} }
// Handles project-device drag intent. This path creates linked rows/circuits and // Handles project-device drag intent. This path creates linked rows/circuits and
@@ -2414,31 +2438,55 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Stromkreise können derzeit nicht bereichsübergreifend verschoben werden."); setError("Stromkreise können derzeit nicht bereichsübergreifend verschoben werden.");
return; return;
} }
const beforeOrder = section.circuits.map((circuit) => circuit.id);
const primaryCircuitId = sourceCircuitIds[0]; const primaryCircuitId = sourceCircuitIds[0];
const targetOrder = resolveCircuitReorderOrder(
intent,
sourceCircuitIds
);
const assignments = buildCircuitSectionReorderAssignments(
section.circuits,
targetOrder
);
if (assignments.length === 0) {
return;
}
const label =
sourceCircuitIds.length > 1
? `${sourceCircuitIds.length} Stromkreise neu anordnen`
: "Stromkreise neu anordnen";
const selectionIntent: SelectionIntent = {
rowKey: `circuitSummary:${primaryCircuitId}`,
cellKey: "equipmentIdentifier",
rowType: "circuitSummary",
sectionId: intent.sectionId,
circuitId: primaryCircuitId,
};
await runCommand({ await runCommand({
label: sourceCircuitIds.length > 1 ? `${sourceCircuitIds.length} Stromkreise neu anordnen` : "Stromkreise neu anordnen", label,
redo: async () => { redo: async () => {
await applyCircuitReorder(intent, sourceCircuitIds); const result = await reorderCircuitSectionCommand(
projectId,
getExpectedProjectRevision(),
intent.sectionId,
assignments,
label
);
applyProjectCommandResult(result);
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds; pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
return { return selectionIntent;
rowKey: `circuitSummary:${primaryCircuitId}`,
cellKey: "equipmentIdentifier",
rowType: "circuitSummary",
sectionId: intent.sectionId,
circuitId: primaryCircuitId,
};
}, },
undo: async () => { undo: async () => null,
await reorderSectionCircuits(intent.sectionId, beforeOrder); persistent: {
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds; undoIntent: () => {
return { pendingSelectedCircuitIdsAfterReload.current =
rowKey: `circuitSummary:${primaryCircuitId}`, sourceCircuitIds;
cellKey: "equipmentIdentifier", return selectionIntent;
rowType: "circuitSummary", },
sectionId: intent.sectionId, redoIntent: () => {
circuitId: primaryCircuitId, pendingSelectedCircuitIdsAfterReload.current =
}; sourceCircuitIds;
return selectionIntent;
},
}, },
}); });
} }
+41 -5
View File
@@ -36,6 +36,12 @@ import type {
import type { import type {
CircuitDeviceRowMoveAssignment, CircuitDeviceRowMoveAssignment,
} from "../../domain/models/circuit-device-row-move-project-command.model"; } from "../../domain/models/circuit-device-row-move-project-command.model";
import type {
CircuitSectionReorderAssignment,
} from "../../domain/models/circuit-section-reorder-project-command.model";
import type {
CircuitSectionsReorderSection,
} from "../../domain/models/circuit-sections-reorder-project-command.model";
async function request<T>(url: string, init?: RequestInit): Promise<T> { async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { const response = await fetch(url, {
@@ -319,11 +325,41 @@ export function renumberCircuitSection(sectionId: string) {
}); });
} }
export function reorderSectionCircuits(sectionId: string, orderedCircuitIds: string[]) { export function reorderCircuitSectionCommand(
return request(`/api/circuit-sections/${sectionId}/circuits/reorder`, { projectId: string,
method: "PATCH", expectedRevision: number,
body: JSON.stringify({ orderedCircuitIds }), sectionId: string,
}); assignments: CircuitSectionReorderAssignment[],
description: string
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit.reorder-section",
payload: { sectionId, assignments },
},
description
);
}
export function reorderCircuitSectionsCommand(
projectId: string,
expectedRevision: number,
sections: CircuitSectionsReorderSection[],
description: string
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit.reorder-sections",
payload: { sections },
},
description
);
} }
export function updateSectionEquipmentIdentifiers( export function updateSectionEquipmentIdentifiers(
@@ -0,0 +1,44 @@
import type {
CircuitSectionReorderAssignment,
} from "../../domain/models/circuit-section-reorder-project-command.model.js";
import type { CircuitTreeCircuitDto } from "../types.js";
export function buildCircuitSectionReorderAssignments(
circuits: CircuitTreeCircuitDto[],
orderedCircuitIds: string[]
): CircuitSectionReorderAssignment[] {
if (
circuits.length !== orderedCircuitIds.length ||
new Set(orderedCircuitIds).size !== orderedCircuitIds.length
) {
throw new Error(
"Die Reihenfolge muss jeden Stromkreis des Bereichs genau einmal enthalten."
);
}
const circuitsById = new Map(
circuits.map((circuit) => [circuit.id, circuit])
);
const assignments = orderedCircuitIds.map((circuitId, index) => {
const circuit = circuitsById.get(circuitId);
if (!circuit) {
throw new Error(
"Die Reihenfolge enthält einen ungültigen Stromkreis."
);
}
return {
circuitId,
expectedSortOrder: circuit.sortOrder,
targetSortOrder: (index + 1) * 10,
};
});
if (
assignments.every(
(assignment) =>
assignment.expectedSortOrder === assignment.targetSortOrder
)
) {
return [];
}
return assignments;
}
@@ -1,7 +1,6 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { circuitWriteService } from "../composition/circuit-write-service.js"; import { circuitWriteService } from "../composition/circuit-write-service.js";
import { import {
reorderSectionCircuitsSchema,
updateSectionEquipmentIdentifiersSchema, updateSectionEquipmentIdentifiersSchema,
} from "../../shared/validation/circuit.schemas.js"; } from "../../shared/validation/circuit.schemas.js";
@@ -19,25 +18,6 @@ export async function renumberCircuitSection(req: Request, res: Response) {
} }
} }
export async function reorderSectionCircuits(req: Request, res: Response) {
const { sectionId } = req.params;
if (typeof sectionId !== "string") {
return res.status(400).json({ error: "Invalid sectionId" });
}
const parsed = reorderSectionCircuitsSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const updatedCircuits = await circuitWriteService.reorderCircuitsInSection(sectionId, parsed.data);
return res.json({ sectionId, circuits: updatedCircuits });
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to reorder circuits." });
}
}
export async function updateSectionEquipmentIdentifiers(req: Request, res: Response) { export async function updateSectionEquipmentIdentifiers(req: Request, res: Response) {
const { sectionId } = req.params; const { sectionId } = req.params;
if (typeof sectionId !== "string") { if (typeof sectionId !== "string") {
@@ -1,12 +1,10 @@
import { Router } from "express"; import { Router } from "express";
import { import {
renumberCircuitSection, renumberCircuitSection,
reorderSectionCircuits,
updateSectionEquipmentIdentifiers, updateSectionEquipmentIdentifiers,
} from "../controllers/circuit-section.controller.js"; } from "../controllers/circuit-section.controller.js";
export const circuitSectionRouter = Router(); export const circuitSectionRouter = Router();
circuitSectionRouter.post("/circuit-sections/:sectionId/renumber", renumberCircuitSection); circuitSectionRouter.post("/circuit-sections/:sectionId/renumber", renumberCircuitSection);
circuitSectionRouter.patch("/circuit-sections/:sectionId/circuits/reorder", reorderSectionCircuits);
circuitSectionRouter.patch("/circuit-sections/:sectionId/equipment-identifiers", updateSectionEquipmentIdentifiers); circuitSectionRouter.patch("/circuit-sections/:sectionId/equipment-identifiers", updateSectionEquipmentIdentifiers);
-5
View File
@@ -1,9 +1,5 @@
import { z } from "zod"; import { z } from "zod";
export const reorderSectionCircuitsSchema = z.object({
orderedCircuitIds: z.array(z.string().min(1)).min(1),
});
export const updateSectionEquipmentIdentifiersSchema = z.object({ export const updateSectionEquipmentIdentifiersSchema = z.object({
identifiers: z identifiers: z
.array( .array(
@@ -15,5 +11,4 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({
.min(1), .min(1),
}); });
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>; export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;
@@ -0,0 +1,190 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
createCircuitSectionsReorderProjectCommand,
} from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
import {
buildCircuitSectionReorderAssignments,
} from "../src/frontend/utils/circuit-section-reorder-command.js";
import {
reorderCircuitSectionCommand,
reorderCircuitSectionsCommand,
} from "../src/frontend/utils/api.js";
import type { CircuitTreeCircuitDto } from "../src/frontend/types.js";
function circuit(
id: string,
sortOrder: number
): CircuitTreeCircuitDto {
return {
id,
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: id,
sortOrder,
isReserve: true,
circuitTotalPower: 0,
deviceRows: [],
};
}
describe("circuit section reorder command adapters", () => {
it("builds complete exact assignments for a requested order", () => {
const circuits = [
circuit("circuit-1", 10),
circuit("circuit-2", 20),
circuit("circuit-3", 30),
];
assert.deepEqual(
buildCircuitSectionReorderAssignments(circuits, [
"circuit-3",
"circuit-1",
"circuit-2",
]),
[
{
circuitId: "circuit-3",
expectedSortOrder: 30,
targetSortOrder: 10,
},
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 30,
},
]
);
});
it("omits unchanged orders and rejects incomplete orders", () => {
const circuits = [
circuit("circuit-1", 10),
circuit("circuit-2", 20),
];
assert.deepEqual(
buildCircuitSectionReorderAssignments(circuits, [
"circuit-1",
"circuit-2",
]),
[]
);
assert.throws(
() =>
buildCircuitSectionReorderAssignments(circuits, [
"circuit-1",
]),
/jeden Stromkreis/
);
});
it("validates atomic multi-section reorder envelopes", () => {
const assignments = [
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 10,
},
];
const command = createCircuitSectionsReorderProjectCommand([
{ sectionId: "section-1", assignments },
{
sectionId: "section-2",
assignments: assignments.map((assignment) => ({
...assignment,
circuitId: `${assignment.circuitId}-other`,
})),
},
]);
assert.equal(command.type, "circuit.reorder-sections");
assert.throws(
() =>
createCircuitSectionsReorderProjectCommand([
{ sectionId: "section-1", assignments },
{ sectionId: "section-1", assignments },
]),
/duplicate section ids/
);
});
it("sends single and multi-section commands through project history", async () => {
const requests: Array<Record<string, unknown>> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (_input, init) => {
requests.push(
JSON.parse(String(init?.body)) as Record<string, unknown>
);
return new Response(
JSON.stringify({
revision: {
revisionId: "revision-1",
changeSetId: "change-1",
projectId: "project-1",
revisionNumber: requests.length,
createdAtIso: "2026-07-25T00:00:00.000Z",
},
history: {
projectId: "project-1",
currentRevision: requests.length,
undoDepth: requests.length,
redoDepth: 0,
undoChangeSetId: "change-1",
redoChangeSetId: null,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
};
const assignments = [
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 10,
},
];
try {
await reorderCircuitSectionCommand(
"project-1",
0,
"section-1",
assignments,
"single"
);
await reorderCircuitSectionsCommand(
"project-1",
1,
[{ sectionId: "section-1", assignments }],
"multiple"
);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(
requests.map((request) => {
const command = request.command as { type: string };
return [request.expectedRevision, command.type];
}),
[
[0, "circuit.reorder-section"],
[1, "circuit.reorder-sections"],
]
);
});
});
@@ -16,10 +16,12 @@ import { circuits } from "../src/db/schema/circuits.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js"; import { projects } from "../src/db/schema/projects.js";
import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js"; import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
interface TestFixture { interface TestFixture {
context: DatabaseContext; context: DatabaseContext;
sectionId: string; sectionId: string;
secondSectionId: string;
foreignSectionId: string; foreignSectionId: string;
} }
@@ -44,17 +46,20 @@ function createTestDatabase(): TestFixture {
"project-2", "project-2",
"UV-02" "UV-02"
); );
const section = context.db const projectSections = context.db
.select() .select()
.from(circuitSections) .from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id)) .where(eq(circuitSections.circuitListId, board.id))
.get(); .all();
const section = projectSections[0];
const secondSection = projectSections[1];
const foreignSection = context.db const foreignSection = context.db
.select() .select()
.from(circuitSections) .from(circuitSections)
.where(eq(circuitSections.circuitListId, foreignBoard.id)) .where(eq(circuitSections.circuitListId, foreignBoard.id))
.get(); .get();
assert.ok(section); assert.ok(section);
assert.ok(secondSection);
assert.ok(foreignSection); assert.ok(foreignSection);
context.db context.db
@@ -107,6 +112,7 @@ function createTestDatabase(): TestFixture {
return { return {
context, context,
sectionId: section.id, sectionId: section.id,
secondSectionId: secondSection.id,
foreignSectionId: foreignSection.id, foreignSectionId: foreignSection.id,
}; };
} }
@@ -226,6 +232,197 @@ describe("circuit section reorder project-command repository", () => {
} }
}); });
it("reorders multiple sections atomically with one undo step", () => {
const fixture = createTestDatabase();
try {
const firstCircuit = fixture.context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(firstCircuit);
fixture.context.db
.insert(circuits)
.values([
{
id: "circuit-4",
circuitListId: firstCircuit.circuitListId,
sectionId: fixture.secondSectionId,
equipmentIdentifier: "-2F1",
sortOrder: 10,
},
{
id: "circuit-5",
circuitListId: firstCircuit.circuitListId,
sectionId: fixture.secondSectionId,
equipmentIdentifier: "-2F2",
sortOrder: 20,
},
])
.run();
const store =
new CircuitSectionReorderProjectCommandRepository(
fixture.context.db
);
const command = createCircuitSectionsReorderProjectCommand([
{
sectionId: fixture.sectionId,
assignments: createReorderCommand(fixture.sectionId)
.payload.assignments,
},
{
sectionId: fixture.secondSectionId,
assignments: [
{
circuitId: "circuit-4",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-5",
expectedSortOrder: 20,
targetSortOrder: 10,
},
],
},
]);
const reordered = store.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
});
assert.deepEqual(
fixture.context.db
.select({ id: circuits.id, sortOrder: circuits.sortOrder })
.from(circuits)
.all()
.filter((circuit) => circuit.id !== "circuit-foreign")
.sort((left, right) => left.id.localeCompare(right.id)),
[
{ id: "circuit-1", sortOrder: 20 },
{ id: "circuit-2", sortOrder: 30 },
{ id: "circuit-3", sortOrder: 10 },
{ id: "circuit-4", sortOrder: 20 },
{ id: "circuit-5", sortOrder: 10 },
]
);
store.execute({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId:
reordered.revision.changeSetId,
command: reordered.inverse,
});
assert.deepEqual(
fixture.context.db
.select({ id: circuits.id, sortOrder: circuits.sortOrder })
.from(circuits)
.all()
.filter((circuit) => circuit.id !== "circuit-foreign")
.sort((left, right) => left.id.localeCompare(right.id)),
[
{ id: "circuit-1", sortOrder: 10 },
{ id: "circuit-2", sortOrder: 20 },
{ id: "circuit-3", sortOrder: 30 },
{ id: "circuit-4", sortOrder: 10 },
{ id: "circuit-5", sortOrder: 20 },
]
);
assert.equal(
new ProjectHistoryRepository(fixture.context.db).getState(
"project-1"
)?.redoDepth,
1
);
} finally {
fixture.context.close();
}
});
it("rolls back every section when a later section is stale", () => {
const fixture = createTestDatabase();
try {
const firstCircuit = fixture.context.db
.select()
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get();
assert.ok(firstCircuit);
fixture.context.db
.insert(circuits)
.values([
{
id: "circuit-4",
circuitListId: firstCircuit.circuitListId,
sectionId: fixture.secondSectionId,
equipmentIdentifier: "-2F1",
sortOrder: 10,
},
{
id: "circuit-5",
circuitListId: firstCircuit.circuitListId,
sectionId: fixture.secondSectionId,
equipmentIdentifier: "-2F2",
sortOrder: 20,
},
])
.run();
const command = createCircuitSectionsReorderProjectCommand([
{
sectionId: fixture.sectionId,
assignments: createReorderCommand(fixture.sectionId)
.payload.assignments,
},
{
sectionId: fixture.secondSectionId,
assignments: [
{
circuitId: "circuit-4",
expectedSortOrder: 999,
targetSortOrder: 20,
},
{
circuitId: "circuit-5",
expectedSortOrder: 20,
targetSortOrder: 10,
},
],
},
]);
assert.throws(
() =>
new CircuitSectionReorderProjectCommandRepository(
fixture.context.db
).execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command,
}),
/changed before/
);
assert.deepEqual(
getCircuitState(fixture.context).map(
(circuit) => circuit.sortOrder
),
[10, 20, 30, 10, 20]
);
assert.equal(
fixture.context.db.select().from(projectRevisions).all()
.length,
0
);
} finally {
fixture.context.close();
}
});
it("rejects incomplete, stale and foreign-section reorders", () => { it("rejects incomplete, stale and foreign-section reorders", () => {
const fixture = createTestDatabase(); const fixture = createTestDatabase();
try { try {
@@ -150,65 +150,4 @@ describe("circuit-section transaction repository", () => {
} }
}); });
it("commits a complete section reorder without changing identifiers", () => {
const context = createTestDatabase();
try {
const persisted = listCircuits(context);
const repository = new CircuitSectionTransactionRepository(context.db);
repository.updateSortOrders(persisted[0].sectionId, [
"circuit-3",
"circuit-1",
"circuit-2",
]);
assert.deepEqual(
listCircuits(context).map((circuit) => [
circuit.id,
circuit.sortOrder,
circuit.equipmentIdentifier,
]),
[
["circuit-1", 20, "-1F1"],
["circuit-2", 30, "-1F2"],
["circuit-3", 10, "-1F3"],
]
);
} finally {
context.close();
}
});
it("rolls back earlier sort orders when a later update fails", () => {
const context = createTestDatabase();
try {
const persisted = listCircuits(context);
context.sqlite.exec(`
CREATE TRIGGER fail_last_sort_order
BEFORE UPDATE OF sort_order ON circuits
WHEN OLD.id = 'circuit-2' AND NEW.sort_order = 30
BEGIN
SELECT RAISE(ABORT, 'forced sort failure');
END;
`);
const repository = new CircuitSectionTransactionRepository(context.db);
assert.throws(
() =>
repository.updateSortOrders(persisted[0].sectionId, [
"circuit-3",
"circuit-1",
"circuit-2",
]),
/forced sort failure/
);
assert.deepEqual(
listCircuits(context).map((circuit) => circuit.sortOrder),
[10, 20, 30]
);
} finally {
context.close();
}
});
}); });
-33
View File
@@ -109,39 +109,6 @@ describe("circuit write service rules", () => {
assert.equal(safeCalled, 1); assert.equal(safeCalled, 1);
}); });
it("reorders circuits inside one section without renumbering identifiers", async () => {
let safeReorder: { sectionId: string; circuitIds: string[] } | undefined;
const service = new CircuitWriteService({
circuitSectionRepository: {
async findById() {
return { id: "s1", circuitListId: "l1" } as never;
},
} as never,
circuitRepository: {
async listBySection() {
return [
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-2F7", sortOrder: 10, isReserve: 0 },
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-2F9", sortOrder: 20, isReserve: 0 },
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-2F5", sortOrder: 30, isReserve: 1 },
] as never[];
},
} as never,
circuitSectionTransactionStore: {
updateSortOrders(sectionId: string, circuitIds: string[]) {
safeReorder = { sectionId, circuitIds };
},
} as never,
});
await service.reorderCircuitsInSection("s1", {
orderedCircuitIds: ["c3", "c1", "c2"],
});
assert.deepEqual(safeReorder, {
sectionId: "s1",
circuitIds: ["c3", "c1", "c2"],
});
});
it("validates the complete section set before safe identifier updates", async () => { it("validates the complete section set before safe identifier updates", async () => {
let safeCalled = false; let safeCalled = false;
const service = new CircuitWriteService({ const service = new CircuitWriteService({
+29
View File
@@ -36,6 +36,7 @@ import {
import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js";
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js"; import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js"; import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js";
import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js";
import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js"; import { createCircuitSectionRenumberProjectCommand } from "../src/domain/models/circuit-section-renumber-project-command.model.js";
import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js"; import { createCircuitInsertProjectCommand } from "../src/domain/models/circuit-structure-project-command.model.js";
import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/project-device-row-sync-project-command.model.js"; import { createProjectDeviceRowSyncProjectCommand } from "../src/domain/models/project-device-row-sync-project-command.model.js";
@@ -764,6 +765,34 @@ describe("project command service", () => {
{ id: "circuit-2", sortOrder: 20 }, { id: "circuit-2", sortOrder: 20 },
] ]
); );
const multiSection = createService(context).executeUser({
projectId: "project-1",
expectedRevision: 2,
command: createCircuitSectionsReorderProjectCommand([
{
sectionId: firstCircuit.sectionId,
assignments:
createCircuitSectionReorderProjectCommand(
firstCircuit.sectionId,
[
{
circuitId: "circuit-1",
expectedSortOrder: 10,
targetSortOrder: 20,
},
{
circuitId: "circuit-2",
expectedSortOrder: 20,
targetSortOrder: 10,
},
]
).payload.assignments,
},
]),
});
assert.equal(multiSection.history.currentRevision, 3);
assert.equal(multiSection.history.redoDepth, 0);
} finally { } finally {
context.close(); context.close();
} }