diff --git a/AGENTS.md b/AGENTS.md index de27816..5e93317 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -220,15 +220,17 @@ After saving, the row becomes linked to the new project device. ## Undo / Redo -The editor's visible undo/redo stack remains session-local while reorder and -renumber operations are being migrated. Existing Circuit and CircuitDeviceRow -cell edits, standalone insertion/deletion and CircuitDeviceRow moves already -execute persistent project commands; their toolbar undo/redo actions use the -project-wide history endpoints. Insertions and generated move targets use -client-generated stable UUIDs, and undo restores the same ids from complete -server snapshots. The tree response supplies the optimistic `currentRevision`; -obsolete direct field PATCH, structure POST, move, Circuit and CircuitDeviceRow -DELETE routes are removed. +The editor's visible undo/redo stack remains session-local while renumber +operations are being migrated. Existing Circuit and CircuitDeviceRow cell +edits, standalone insertion/deletion, CircuitDeviceRow moves and Circuit +reorders already execute persistent project commands; their toolbar undo/redo +actions use the project-wide history endpoints. Applying a sorted view across +multiple sections is one atomic `circuit.reorder-sections` command and one undo +step. Insertions and generated move targets use client-generated stable UUIDs, +and undo restores the same ids from complete server snapshots. The tree +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. CircuitDeviceRow moves between existing circuits and moves that create one new placeholder target circuit are persisted. The latter stores the complete empty diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index b347750..d9ef240 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -27,7 +27,8 @@ The public dispatcher currently supports `circuit.update`, `circuit.insert`, `circuit.delete`, `circuit-device-row.update`, `circuit-device-row.insert`, `circuit-device-row.delete`, `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.sync-rows`, all with schema version `1`. Other command types 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 replacement identities or renumbering circuits. Existing Circuit and CircuitDeviceRow cell edits, standalone insert/delete actions and device-row -moves in the editor consume these endpoints. Reorder and renumber UI paths are -still being migrated. +moves and reorders in the editor consume these endpoints. The renumber UI path +is still being migrated. `circuit-device-row.move` targets existing circuits in the same circuit list. `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`; 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 for every circuit in the section. Assignments contain expected and target equipment identifiers. The store rejects stale values, duplicate targets and @@ -169,24 +175,12 @@ Response sketch: - `GET /circuit-sections/:sectionId/next-identifier` - preview next identifier for section (`prefix + maxSuffix + 1`) -Circuit and device-row field updates, standalone insertions/deletions and -single or bulk device-row moves are available only as the corresponding -versioned commands through `POST /projects/:projectId/commands`. There are no -direct field-update PATCH, structure POST, move, 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"] -} -``` +Circuit and device-row field updates, standalone insertions/deletions, single +or bulk device-row moves and circuit reorders are available only as the +corresponding versioned commands through +`POST /projects/:projectId/commands`. There are no direct field-update PATCH, +structure POST, move, reorder, Circuit DELETE or CircuitDeviceRow DELETE +routes. ### Renumber Section diff --git a/docs/circuit-list-editor-interactions.md b/docs/circuit-list-editor-interactions.md index 7e93c83..368af3e 100644 --- a/docs/circuit-list-editor-interactions.md +++ b/docs/circuit-list-editor-interactions.md @@ -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 insert/delete actions execute persistent project commands; their toolbar undo/redo calls the project-wide server history. New entities receive stable -UUIDs before insertion, and deletion undo restores those same ids. Move, -reorder and renumber operations still use their existing direct API writes and -session-local inverse callbacks during the cutover. +UUIDs before insertion, and deletion undo restores those same ids. Device-row +moves and circuit reorders also use persistent project commands. Applying a +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: @@ -142,5 +144,5 @@ Covered operations include: Current limitations: - 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 - command boundary yet +- renumber editor actions are not connected to the persistent command boundary + yet diff --git a/docs/circuit-list-editor-known-limitations.md b/docs/circuit-list-editor-known-limitations.md index c8bf3be..ebe2c67 100644 --- a/docs/circuit-list-editor-known-limitations.md +++ b/docs/circuit-list-editor-known-limitations.md @@ -3,8 +3,9 @@ - Existing Circuit and CircuitDeviceRow cell edits plus standalone insert/delete actions and CircuitDeviceRow moves use persistent project commands and project-wide toolbar undo/redo. The editor's visible stack is - still session-local, is empty after reload and still contains direct - reorder/renumber operations during the cutover. + still session-local, is empty after reload and still contains the direct + 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 the command types documented in `circuit-list-editor-api.md`; complete editor command coverage and history reconstruction are not complete. diff --git a/docs/current-architecture.md b/docs/current-architecture.md index d71efc5..3ab31c3 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -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 dazu `currentRevision`; Undo/Redo für diese Aktionen läuft über die projektweite Serverhistorie. Direkte Feld-PATCH-, Struktur-POST-, Move-, Circuit- und -Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves im Grid -verwenden die persistenten Kommandos bereits. Nur Reorder- und -Renumber-UI-Pfade verbleiben vorerst im sichtbaren sitzungslokalen Stack; nach -einem Reload wird dieser noch nicht aus der Serverhistorie rekonstruiert. +Gerätezeilen-DELETE-Endpunkte sind entfernt. Gerätezeilen-Moves und +Stromkreis-Reorders im Grid verwenden die persistenten Kommandos bereits. Nur +der Renumber-UI-Pfad verbleibt vorerst im sichtbaren sitzungslokalen Stack; +nach einem Reload wird dieser noch nicht aus der Serverhistorie rekonstruiert. `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; @@ -115,6 +115,10 @@ bleiben unverändert. `circuit.renumber-section` bildet die getrennte, ausdrücklich ausgelöste Neunummerierung ab. Es speichert alle erwarteten und neuen BMKs des Abschnitts, löst Tauschkollisionen über temporäre Werte und ändert weder Sortierung noch Gerätezeilen. +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 Verknüpfen als atomaren Mehrzeilen-Command. Jede betroffene Zeile enthält den vollständigen erwarteten und neuen Stand aller synchronisierbaren Felder, diff --git a/docs/project-history-and-external-model-architecture.md b/docs/project-history-and-external-model-architecture.md index 40b2c9c..50119f0 100644 --- a/docs/project-history-and-external-model-architecture.md +++ b/docs/project-history-and-external-model-architecture.md @@ -200,6 +200,10 @@ Completed foundation: - `circuit.reorder-section` requires the complete section circuit set and stores exact expected/target sort positions; its inverse changes no equipment identifier and never splits a circuit block +- `circuit.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 stores every expected/target equipment identifier, uses collision-safe temporary values and restores the exact prior identifiers on undo @@ -214,8 +218,8 @@ Completed foundation: Remaining constraints before completing persistent history: -- route the remaining reorder and renumber editor writes through their existing - typed persistent commands and remove the final direct mutation routes +- route the remaining renumber editor writes through its existing typed + persistent command and remove the final direct mutation routes - reconstruct the visible frontend history state after reload; Circuit and CircuitDeviceRow field/insert/delete actions plus ProjectDevice operations already use persistent commands and project-wide undo diff --git a/docs/spec/07-implementation-phases-todo.md b/docs/spec/07-implementation-phases-todo.md index 2c528cf..6218b55 100644 --- a/docs/spec/07-implementation-phases-todo.md +++ b/docs/spec/07-implementation-phases-todo.md @@ -431,10 +431,11 @@ Implemented foundation: and rollback after a forced late persistence failure ProjectDevice CRUD, synchronization and disconnect on the project page and -Circuit/CircuitDeviceRow field/insert/delete actions in the circuit-list editor -are connected to this history boundary. Move, reorder and renumber UI paths are -not yet connected; the editor's visible undo/redo stack therefore remains -session-local and is not reconstructed after reload. +Circuit/CircuitDeviceRow field/insert/delete actions, device-row moves and +circuit reorders in the circuit-list editor are connected to this history +boundary. Applying sorted order across multiple sections is one atomic command. +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: diff --git a/package.json b/package.json index 874b1bf..fb1be40 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "build:api": "tsc -p tsconfig.json", "build:web": "next build", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-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-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-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:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/db/repositories/circuit-section-reorder-project-command.repository.ts b/src/db/repositories/circuit-section-reorder-project-command.repository.ts index 54ec6c8..c6fe281 100644 --- a/src/db/repositories/circuit-section-reorder-project-command.repository.ts +++ b/src/db/repositories/circuit-section-reorder-project-command.repository.ts @@ -1,8 +1,18 @@ import { and, eq } from "drizzle-orm"; import { assertCircuitSectionReorderProjectCommand, + circuitSectionReorderCommandType, createCircuitSectionReorderProjectCommand, + type CircuitSectionReorderAssignment, + type CircuitSectionReorderProjectCommand, } 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 { CircuitSectionReorderProjectCommandStore, ExecuteCircuitSectionReorderCommandInput, @@ -14,106 +24,49 @@ import { circuits } from "../schema/circuits.js"; import { applyProjectHistoryTransition } from "./project-history.persistence.js"; import { appendProjectRevision } from "./project-revision.persistence.js"; +type ReorderHistoryCommand = + | CircuitSectionReorderProjectCommand + | CircuitSectionsReorderProjectCommand; + export class CircuitSectionReorderProjectCommandRepository implements CircuitSectionReorderProjectCommandStore { constructor(private readonly database: AppDatabase) {} execute(input: ExecuteCircuitSectionReorderCommandInput) { - assertCircuitSectionReorderProjectCommand(input.command); + this.assertSupportedCommand(input.command); return this.database.transaction((tx) => { - const section = tx - .select({ - id: circuitSections.id, - circuitListId: circuitSections.circuitListId, - projectId: circuitLists.projectId, - }) - .from(circuitSections) - .innerJoin( - circuitLists, - eq(circuitLists.id, circuitSections.circuitListId) - ) - .where(eq(circuitSections.id, input.command.payload.sectionId)) - .get(); - if (!section || section.projectId !== input.projectId) { - throw new Error( - "Circuit section does not belong to project." + const commandSections = this.getCommandSections(input.command); + const inverseSections = commandSections.map((section) => { + this.assertCompleteCurrentSection( + tx, + input.projectId, + section ); + 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 - .select({ - id: circuits.id, - circuitListId: circuits.circuitListId, - sortOrder: circuits.sortOrder, - }) - .from(circuits) - .where(eq(circuits.sectionId, section.id)) - .all(); - const assignments = input.command.payload.assignments; - if ( - persistedCircuits.length !== assignments.length || - persistedCircuits.some( - (circuit) => - circuit.circuitListId !== section.circuitListId - ) - ) { - throw new Error( - "Circuit reorder must include every circuit in the section." - ); - } - const circuitsById = new Map( - persistedCircuits.map((circuit) => [circuit.id, circuit]) - ); - for (const assignment of assignments) { - const circuit = circuitsById.get(assignment.circuitId); - if ( - !circuit || - circuit.sortOrder !== assignment.expectedSortOrder - ) { - throw new Error( - "Circuit changed before section reorder execution." - ); - } - } - - const inverse = createCircuitSectionReorderProjectCommand( - section.id, - assignments.map((assignment) => ({ - circuitId: assignment.circuitId, - expectedSortOrder: assignment.targetSortOrder, - targetSortOrder: assignment.expectedSortOrder, - })) - ); - - for (const assignment of assignments) { - if ( - assignment.expectedSortOrder === assignment.targetSortOrder - ) { - continue; - } - const updated = tx - .update(circuits) - .set({ sortOrder: assignment.targetSortOrder }) - .where( - and( - eq(circuits.id, assignment.circuitId), - eq(circuits.sectionId, section.id), - eq( - circuits.sortOrder, - assignment.expectedSortOrder - ) + const inverse = + input.command.type === circuitSectionReorderCommandType + ? createCircuitSectionReorderProjectCommand( + inverseSections[0].sectionId, + inverseSections[0].assignments ) - ) - .run(); - if (updated.changes !== 1) { - throw new Error( - "Circuit changed during section reorder execution." - ); - } - } - + : createCircuitSectionsReorderProjectCommand( + inverseSections + ); const revision = appendProjectRevision(tx, { projectId: input.projectId, expectedRevision: input.expectedRevision, @@ -132,4 +85,125 @@ export class CircuitSectionReorderProjectCommandRepository 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." + ); + } + } + } } diff --git a/src/db/repositories/circuit-section-transaction.repository.ts b/src/db/repositories/circuit-section-transaction.repository.ts index 52dc97f..0729683 100644 --- a/src/db/repositories/circuit-section-transaction.repository.ts +++ b/src/db/repositories/circuit-section-transaction.repository.ts @@ -8,54 +8,6 @@ export class CircuitSectionTransactionRepository { 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( circuitListId: string, updates: Array<{ id: string; equipmentIdentifier: string }>, diff --git a/src/domain/models/circuit-sections-reorder-project-command.model.ts b/src/domain/models/circuit-sections-reorder-project-command.model.ts new file mode 100644 index 0000000..079c95d --- /dev/null +++ b/src/domain/models/circuit-sections-reorder-project-command.model.ts @@ -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 { + 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 +): 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(); + 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 { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/domain/ports/circuit-section-reorder-project-command.store.ts b/src/domain/ports/circuit-section-reorder-project-command.store.ts index 6502182..f4799f1 100644 --- a/src/domain/ports/circuit-section-reorder-project-command.store.ts +++ b/src/domain/ports/circuit-section-reorder-project-command.store.ts @@ -1,4 +1,5 @@ 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 { AppendedProjectRevision, ProjectRevisionSource, @@ -11,12 +12,16 @@ export interface ExecuteCircuitSectionReorderCommandInput { description?: string; actorId?: string; historyTargetChangeSetId?: string; - command: CircuitSectionReorderProjectCommand; + command: + | CircuitSectionReorderProjectCommand + | CircuitSectionsReorderProjectCommand; } export interface ExecutedCircuitSectionReorderCommand { revision: AppendedProjectRevision; - inverse: CircuitSectionReorderProjectCommand; + inverse: + | CircuitSectionReorderProjectCommand + | CircuitSectionsReorderProjectCommand; } export interface CircuitSectionReorderProjectCommandStore { diff --git a/src/domain/ports/circuit-section-transaction.store.ts b/src/domain/ports/circuit-section-transaction.store.ts index c1bba59..39e8023 100644 --- a/src/domain/ports/circuit-section-transaction.store.ts +++ b/src/domain/ports/circuit-section-transaction.store.ts @@ -4,5 +4,4 @@ export interface CircuitSectionTransactionStore { updates: Array<{ id: string; equipmentIdentifier: string }>, tempNamespace: string ): void; - updateSortOrders(sectionId: string, orderedCircuitIds: string[]): void; } diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index 72fcb98..c97252e 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -2,7 +2,6 @@ import type { CircuitSectionTransactionStore } from "../ports/circuit-section-tr import { CircuitRepository } from "../../db/repositories/circuit.repository.js"; import { CircuitSectionRepository } from "../../db/repositories/circuit-section.repository.js"; import type { - ReorderSectionCircuitsInput, UpdateSectionEquipmentIdentifiersInput, } from "../../shared/validation/circuit.schemas.js"; import { CircuitNumberingService } from "./circuit-numbering.service.js"; @@ -97,27 +96,4 @@ export class CircuitWriteService { 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); - } } diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 33b9de8..cd44fc0 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -23,6 +23,10 @@ import { assertCircuitSectionReorderProjectCommand, circuitSectionReorderCommandType, } from "../models/circuit-section-reorder-project-command.model.js"; +import { + assertCircuitSectionsReorderProjectCommand, + circuitSectionsReorderCommandType, +} from "../models/circuit-sections-reorder-project-command.model.js"; import { assertCircuitSectionRenumberProjectCommand, circuitSectionRenumberCommandType, @@ -225,6 +229,13 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case circuitSectionsReorderCommandType: { + assertCircuitSectionsReorderProjectCommand(input.command); + return this.circuitSectionReorderStore.execute({ + ...input, + command: input.command, + }).revision; + } case circuitSectionRenumberCommandType: { assertCircuitSectionRenumberProjectCommand(input.command); return this.circuitSectionRenumberStore.execute({ diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 9a4b157..c86e62d 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -30,6 +30,9 @@ import { import { buildCircuitDeviceRowMoveAssignments, } from "../utils/circuit-device-row-move-command"; +import { + buildCircuitSectionReorderAssignments, +} from "../utils/circuit-section-reorder-command"; import type { CellKey, CellKind, @@ -52,7 +55,8 @@ import { listProjectDevices, moveCircuitDeviceRowsCommand, moveCircuitDeviceRowsToNewCircuitCommand, - reorderSectionCircuits, + reorderCircuitSectionCommand, + reorderCircuitSectionsCommand, renumberCircuitSection, redoProjectCommand, updateSectionEquipmentIdentifiers, @@ -991,22 +995,42 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str 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({ label: "Sortierte Reihenfolge übernehmen", redo: async () => { - for (const section of changedSections) { - await reorderSectionCircuits(section.sectionId, section.order); - } + const result = await reorderCircuitSectionsCommand( + projectId, + getExpectedProjectRevision(), + reorderedSections, + "Sortierte Reihenfolge übernehmen" + ); + applyProjectCommandResult(result); setSortState(null); setOpenFilterColumn(null); return null; }, - undo: async () => { - for (const section of beforeBySection) { - await reorderSectionCircuits(section.sectionId, section.order); - } - return null; + undo: async () => null, + persistent: { + undoIntent: () => null, + redoIntent: () => null, }, }); } @@ -2112,7 +2136,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str // Applies same-section circuit reorder as explicit id ordering. // 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); if (!section) { 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; nextIds.splice(insertIndex, 0, ...block); } - await reorderSectionCircuits(section.id, nextIds); + return nextIds; } // 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."); return; } - const beforeOrder = section.circuits.map((circuit) => circuit.id); 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({ - label: sourceCircuitIds.length > 1 ? `${sourceCircuitIds.length} Stromkreise neu anordnen` : "Stromkreise neu anordnen", + label, redo: async () => { - await applyCircuitReorder(intent, sourceCircuitIds); + const result = await reorderCircuitSectionCommand( + projectId, + getExpectedProjectRevision(), + intent.sectionId, + assignments, + label + ); + applyProjectCommandResult(result); pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds; - return { - rowKey: `circuitSummary:${primaryCircuitId}`, - cellKey: "equipmentIdentifier", - rowType: "circuitSummary", - sectionId: intent.sectionId, - circuitId: primaryCircuitId, - }; + return selectionIntent; }, - undo: async () => { - await reorderSectionCircuits(intent.sectionId, beforeOrder); - pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds; - return { - rowKey: `circuitSummary:${primaryCircuitId}`, - cellKey: "equipmentIdentifier", - rowType: "circuitSummary", - sectionId: intent.sectionId, - circuitId: primaryCircuitId, - }; + undo: async () => null, + persistent: { + undoIntent: () => { + pendingSelectedCircuitIdsAfterReload.current = + sourceCircuitIds; + return selectionIntent; + }, + redoIntent: () => { + pendingSelectedCircuitIdsAfterReload.current = + sourceCircuitIds; + return selectionIntent; + }, }, }); } diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 0400040..6c0ae99 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -36,6 +36,12 @@ import type { import type { CircuitDeviceRowMoveAssignment, } 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(url: string, init?: RequestInit): Promise { const response = await fetch(url, { @@ -319,11 +325,41 @@ export function renumberCircuitSection(sectionId: string) { }); } -export function reorderSectionCircuits(sectionId: string, orderedCircuitIds: string[]) { - return request(`/api/circuit-sections/${sectionId}/circuits/reorder`, { - method: "PATCH", - body: JSON.stringify({ orderedCircuitIds }), - }); +export function reorderCircuitSectionCommand( + projectId: string, + expectedRevision: number, + 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( diff --git a/src/frontend/utils/circuit-section-reorder-command.ts b/src/frontend/utils/circuit-section-reorder-command.ts new file mode 100644 index 0000000..ce93730 --- /dev/null +++ b/src/frontend/utils/circuit-section-reorder-command.ts @@ -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; +} diff --git a/src/server/controllers/circuit-section.controller.ts b/src/server/controllers/circuit-section.controller.ts index 46c1a2f..43ed1ea 100644 --- a/src/server/controllers/circuit-section.controller.ts +++ b/src/server/controllers/circuit-section.controller.ts @@ -1,7 +1,6 @@ import type { Request, Response } from "express"; import { circuitWriteService } from "../composition/circuit-write-service.js"; import { - reorderSectionCircuitsSchema, updateSectionEquipmentIdentifiersSchema, } 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) { const { sectionId } = req.params; if (typeof sectionId !== "string") { diff --git a/src/server/routes/circuit-section.routes.ts b/src/server/routes/circuit-section.routes.ts index d7ae59f..e61a265 100644 --- a/src/server/routes/circuit-section.routes.ts +++ b/src/server/routes/circuit-section.routes.ts @@ -1,12 +1,10 @@ import { Router } from "express"; import { renumberCircuitSection, - reorderSectionCircuits, updateSectionEquipmentIdentifiers, } from "../controllers/circuit-section.controller.js"; export const circuitSectionRouter = Router(); circuitSectionRouter.post("/circuit-sections/:sectionId/renumber", renumberCircuitSection); -circuitSectionRouter.patch("/circuit-sections/:sectionId/circuits/reorder", reorderSectionCircuits); circuitSectionRouter.patch("/circuit-sections/:sectionId/equipment-identifiers", updateSectionEquipmentIdentifiers); diff --git a/src/shared/validation/circuit.schemas.ts b/src/shared/validation/circuit.schemas.ts index 8d2e797..9eb8ded 100644 --- a/src/shared/validation/circuit.schemas.ts +++ b/src/shared/validation/circuit.schemas.ts @@ -1,9 +1,5 @@ import { z } from "zod"; -export const reorderSectionCircuitsSchema = z.object({ - orderedCircuitIds: z.array(z.string().min(1)).min(1), -}); - export const updateSectionEquipmentIdentifiersSchema = z.object({ identifiers: z .array( @@ -15,5 +11,4 @@ export const updateSectionEquipmentIdentifiersSchema = z.object({ .min(1), }); -export type ReorderSectionCircuitsInput = z.infer; export type UpdateSectionEquipmentIdentifiersInput = z.infer; diff --git a/tests/circuit-section-reorder-command.test.ts b/tests/circuit-section-reorder-command.test.ts new file mode 100644 index 0000000..4a871e0 --- /dev/null +++ b/tests/circuit-section-reorder-command.test.ts @@ -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> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (_input, init) => { + requests.push( + JSON.parse(String(init?.body)) as Record + ); + 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"], + ] + ); + }); +}); diff --git a/tests/circuit-section-reorder-project-command.repository.test.ts b/tests/circuit-section-reorder-project-command.repository.test.ts index 7cca002..f8064cb 100644 --- a/tests/circuit-section-reorder-project-command.repository.test.ts +++ b/tests/circuit-section-reorder-project-command.repository.test.ts @@ -16,10 +16,12 @@ import { circuits } from "../src/db/schema/circuits.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js"; +import { createCircuitSectionsReorderProjectCommand } from "../src/domain/models/circuit-sections-reorder-project-command.model.js"; interface TestFixture { context: DatabaseContext; sectionId: string; + secondSectionId: string; foreignSectionId: string; } @@ -44,17 +46,20 @@ function createTestDatabase(): TestFixture { "project-2", "UV-02" ); - const section = context.db + const projectSections = context.db .select() .from(circuitSections) .where(eq(circuitSections.circuitListId, board.id)) - .get(); + .all(); + const section = projectSections[0]; + const secondSection = projectSections[1]; const foreignSection = context.db .select() .from(circuitSections) .where(eq(circuitSections.circuitListId, foreignBoard.id)) .get(); assert.ok(section); + assert.ok(secondSection); assert.ok(foreignSection); context.db @@ -107,6 +112,7 @@ function createTestDatabase(): TestFixture { return { context, sectionId: section.id, + secondSectionId: secondSection.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", () => { const fixture = createTestDatabase(); try { diff --git a/tests/circuit-section-transaction.repository.test.ts b/tests/circuit-section-transaction.repository.test.ts index 36a4993..e85413d 100644 --- a/tests/circuit-section-transaction.repository.test.ts +++ b/tests/circuit-section-transaction.repository.test.ts @@ -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(); - } - }); }); diff --git a/tests/circuit-write.rules.test.ts b/tests/circuit-write.rules.test.ts index 580eef5..f14fd76 100644 --- a/tests/circuit-write.rules.test.ts +++ b/tests/circuit-write.rules.test.ts @@ -109,39 +109,6 @@ describe("circuit write service rules", () => { 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 () => { let safeCalled = false; const service = new CircuitWriteService({ diff --git a/tests/project-command.service.test.ts b/tests/project-command.service.test.ts index 0a51dcb..31ab626 100644 --- a/tests/project-command.service.test.ts +++ b/tests/project-command.service.test.ts @@ -36,6 +36,7 @@ import { import { createCircuitDeviceRowInsertProjectCommand } from "../src/domain/models/circuit-device-row-structure-project-command.model.js"; import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js"; import { createCircuitSectionReorderProjectCommand } from "../src/domain/models/circuit-section-reorder-project-command.model.js"; +import { 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 { 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"; @@ -764,6 +765,34 @@ describe("project command service", () => { { 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 { context.close(); }