Add project command API

This commit is contained in:
2026-07-23 21:56:13 +02:00
parent 1e4dd26bb8
commit e4c7cf06e9
19 changed files with 781 additions and 21 deletions
+3 -2
View File
@@ -222,8 +222,9 @@ After saving, the row becomes linked to the new project device.
Session-local UI undo/redo exists for structural and destructive operations. Session-local UI undo/redo exists for structural and destructive operations.
The server already persists immutable revisions and project-wide undo/redo The server already persists immutable revisions and project-wide undo/redo
eligibility for the first internal commands. Public command execution, eligibility for the first commands. Public command, undo and redo endpoints
server-side undo/redo endpoints and the UI cutover remain future work. exist for Circuit and CircuitDeviceRow field updates. Complete command coverage
and the UI cutover remain future work.
Required operations: Required operations:
+38 -4
View File
@@ -6,15 +6,49 @@ The circuit-first editor uses tree, circuit, row and project-device endpoints.
All paths below are mounted below `/api`. There is no Consumer application API. All paths below are mounted below `/api`. There is no Consumer application API.
Project responses from `GET /projects` and `GET /projects/:projectId` include Project responses from `GET /projects` and `GET /projects/:projectId` include
`currentRevision`. It currently exposes the persisted revision foundation only; `currentRevision`. Versioned project commands use this value for optimistic
no public undo or redo command exists yet. concurrency checks.
### Project History State ### Project Commands and History
- `GET /projects/:projectId/history` - `GET /projects/:projectId/history`
- returns `currentRevision`, `undoDepth`, `redoDepth` and the current top - returns `currentRevision`, `undoDepth`, `redoDepth` and the current top
change-set id for both persistent stacks change-set id for both persistent stacks
- is read-only; the editor does not consume it yet - `POST /projects/:projectId/commands`
- executes a supported versioned command as a new user revision
- body: `{ "expectedRevision": 0, "command": { ... } }`
- `POST /projects/:projectId/history/undo`
- `POST /projects/:projectId/history/redo`
- body: `{ "expectedRevision": 1 }`
- executes the eligible inverse or forward command as a new auditable
revision
The public dispatcher currently supports `circuit.update` and
`circuit-device-row.update`, both with schema version `1`. Other command types
are rejected. The editor does not consume these endpoints yet.
Example:
```json
{
"expectedRevision": 0,
"description": "Rename circuit",
"command": {
"schemaVersion": 1,
"type": "circuit.update",
"payload": {
"circuitId": "cir_1",
"changes": [
{ "field": "displayName", "value": "Sockets East" }
]
}
}
}
```
A stale `expectedRevision` returns HTTP `409` with
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
## Circuit-First Endpoints ## Circuit-First Endpoints
+6 -3
View File
@@ -76,9 +76,12 @@ Gerätezeilen-Kommandos bewahren dabei auch lokale ProjectDevice-Overrides und
prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite, prüfen Projektzugehörigkeit von Verknüpfungen und Räumen. Projektweite,
persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über
`GET /api/projects/:projectId/history` lesbar. Die schreibenden Command-Stores `GET /api/projects/:projectId/history` lesbar. Ein zentraler Dispatcher führt
sind noch nicht über die API oder das Grid aktiviert; serverseitiges Undo/Redo die unterstützten Typen `circuit.update` und `circuit-device-row.update` über
ist daher noch nicht verfügbar. öffentliche Command-, Undo- und Redo-Endpunkte aus. Das Grid verwendet diese
Endpunkte noch nicht; seine sichtbare Historie bleibt daher sitzungslokal.
Struktur-, Synchronisierungs- und Löschoperationen müssen vor der
Frontend-Umstellung ebenfalls als persistente Commands modelliert werden.
## Projektgeräte ## Projektgeräte
@@ -167,7 +167,12 @@ Completed foundation:
- domain writes, audit revisions and stack transitions share one transaction; - domain writes, audit revisions and stack transitions share one transaction;
a forced stack failure rolls all of them back a forced stack failure rolls all of them back
- `GET /api/projects/:projectId/history` exposes current revision, stack depths - `GET /api/projects/:projectId/history` exposes current revision, stack depths
and top change-set ids without enabling mutation endpoints and top change-set ids
- a central application dispatcher executes the supported `circuit.update`
and `circuit-device-row.update` envelopes without coupling the domain service
to SQLite
- public command, undo and redo endpoints require an expected revision; undo
and redo reconstruct the eligible persisted inverse or forward command
Remaining constraints before completing persistent history: Remaining constraints before completing persistent history:
@@ -176,10 +181,9 @@ Remaining constraints before completing persistent history:
command envelope is complete command envelope is complete
- route the remaining structural, synchronization and destructive domain - route the remaining structural, synchronization and destructive domain
writes through the shared command, revision and stack transaction boundary writes through the shared command, revision and stack transaction boundary
- add public command execution plus undo/redo endpoints; read-only revision and
stack eligibility state is already exposed
- replace the session-local frontend command stack only after server-side - replace the session-local frontend command stack only after server-side
command execution and inverse application are complete command coverage is complete for structural, destructive and synchronization
operations
- do not model IFCGUID as an overloaded circuit equipment identifier - do not model IFCGUID as an overloaded circuit equipment identifier
- keep database backup/restore checks separate from project history tests - keep database backup/restore checks separate from project history tests
@@ -362,6 +362,8 @@ Tasks:
- define concrete serializable domain command descriptions and executors - define concrete serializable domain command descriptions and executors
outside React outside React
- [x] persist undo/redo eligibility on the server - [x] persist undo/redo eligibility on the server
- [x] expose public command, undo and redo endpoints for the first supported
update command types
- add named and periodic logical project snapshots - add named and periodic logical project snapshots
- restore a historical snapshot as a new revision - restore a historical snapshot as a new revision
- keep database backups separate from logical history - keep database backups separate from logical history
@@ -385,6 +387,9 @@ Implemented foundation:
late-failure rollback coverage late-failure rollback coverage
- a read-only project history endpoint exposes revision, stack depths and top - a read-only project history endpoint exposes revision, stack depths and top
change-set ids change-set ids
- a central application dispatcher reconstructs persisted commands and exposes
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
field updates
- revision metadata, change-set payloads and the project counter are committed - revision metadata, change-set payloads and the project counter are committed
in one SQLite transaction in one SQLite transaction
- a stale expected revision produces no history writes - a stale expected revision produces no history writes
+2 -2
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
"start": "node dist/server/index.js", "start": "node dist/server/index.js",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/project-history.repository.test.ts tests/database-backup.test.ts", "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/project-history.repository.test.ts tests/database-backup.test.ts", "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-device-row-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,10 +1,15 @@
import { asc, eq } from "drizzle-orm"; import { and, asc, desc, eq } from "drizzle-orm";
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
import type { import type {
ProjectHistoryCommand,
ProjectHistoryDirection,
ProjectHistoryState, ProjectHistoryState,
ProjectHistoryStore, ProjectHistoryStore,
} from "../../domain/ports/project-history.store.js"; } from "../../domain/ports/project-history.store.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js"; import { projectHistoryStackEntries } from "../schema/project-history-stack-entries.js";
import { projectRevisions } from "../schema/project-revisions.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
export class ProjectHistoryRepository implements ProjectHistoryStore { export class ProjectHistoryRepository implements ProjectHistoryStore {
@@ -41,4 +46,50 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
redoChangeSetId: redoEntries.at(-1)?.changeSetId ?? null, redoChangeSetId: redoEntries.at(-1)?.changeSetId ?? null,
}; };
} }
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
): ProjectHistoryCommand | null {
const entry = this.database
.select({
changeSetId: projectHistoryStackEntries.changeSetId,
forwardPayloadJson: projectChangeSets.forwardPayloadJson,
inversePayloadJson: projectChangeSets.inversePayloadJson,
})
.from(projectHistoryStackEntries)
.innerJoin(
projectChangeSets,
eq(
projectChangeSets.id,
projectHistoryStackEntries.changeSetId
)
)
.innerJoin(
projectRevisions,
eq(projectRevisions.id, projectChangeSets.projectRevisionId)
)
.where(
and(
eq(projectHistoryStackEntries.projectId, projectId),
eq(projectHistoryStackEntries.stack, direction),
eq(projectRevisions.projectId, projectId)
)
)
.orderBy(desc(projectHistoryStackEntries.position))
.limit(1)
.get();
if (!entry) {
return null;
}
return {
changeSetId: entry.changeSetId,
command: deserializeProjectCommand(
direction === "undo"
? entry.inversePayloadJson
: entry.forwardPayloadJson
),
};
}
} }
@@ -0,0 +1,13 @@
import type { ProjectHistoryDirection } from "../ports/project-history.store.js";
export class ProjectHistoryOperationUnavailableError extends Error {
constructor(
readonly projectId: string,
readonly direction: ProjectHistoryDirection
) {
super(
`Project ${projectId} has no ${direction} operation available.`
);
this.name = "ProjectHistoryOperationUnavailableError";
}
}
@@ -0,0 +1,33 @@
import type { AppendedProjectRevision } from "./project-revision.store.js";
import type { ProjectHistoryState } from "./project-history.store.js";
export interface ExecuteUserProjectCommandInput {
projectId: string;
expectedRevision: number;
description?: string;
actorId?: string;
command: unknown;
}
export interface ExecuteProjectHistoryCommandInput {
projectId: string;
expectedRevision: number;
actorId?: string;
}
export interface ExecutedProjectCommand {
revision: AppendedProjectRevision;
history: ProjectHistoryState;
}
export interface ProjectCommandExecutor {
executeUser(
input: ExecuteUserProjectCommandInput
): ExecutedProjectCommand;
undo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand;
redo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand;
}
+13
View File
@@ -1,3 +1,7 @@
import type { SerializedProjectCommand } from "../models/project-command.model.js";
export type ProjectHistoryDirection = "undo" | "redo";
export interface ProjectHistoryState { export interface ProjectHistoryState {
projectId: string; projectId: string;
currentRevision: number; currentRevision: number;
@@ -7,6 +11,15 @@ export interface ProjectHistoryState {
redoChangeSetId: string | null; redoChangeSetId: string | null;
} }
export interface ProjectHistoryCommand {
changeSetId: string;
command: SerializedProjectCommand;
}
export interface ProjectHistoryStore { export interface ProjectHistoryStore {
getState(projectId: string): ProjectHistoryState | null; getState(projectId: string): ProjectHistoryState | null;
getNextCommand(
projectId: string,
direction: ProjectHistoryDirection
): ProjectHistoryCommand | null;
} }
@@ -0,0 +1,138 @@
import { ProjectHistoryOperationUnavailableError } from "../errors/project-history-operation-unavailable.error.js";
import {
assertCircuitDeviceRowUpdateProjectCommand,
circuitDeviceRowUpdateCommandType,
} from "../models/circuit-device-row-project-command.model.js";
import {
assertCircuitUpdateProjectCommand,
circuitUpdateCommandType,
} from "../models/circuit-project-command.model.js";
import {
assertSerializedProjectCommand,
type SerializedProjectCommand,
} from "../models/project-command.model.js";
import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-device-row-project-command.store.js";
import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
import type {
ExecuteProjectHistoryCommandInput,
ExecutedProjectCommand,
ExecuteUserProjectCommandInput,
ProjectCommandExecutor,
} from "../ports/project-command.executor.js";
import type {
ProjectHistoryDirection,
ProjectHistoryStore,
} from "../ports/project-history.store.js";
import type { ProjectRevisionSource } from "../ports/project-revision.store.js";
interface DispatchProjectCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: SerializedProjectCommand;
}
export class ProjectCommandService implements ProjectCommandExecutor {
constructor(
private readonly circuitStore: CircuitProjectCommandStore,
private readonly deviceRowStore: CircuitDeviceRowProjectCommandStore,
private readonly historyStore: ProjectHistoryStore
) {}
executeUser(
input: ExecuteUserProjectCommandInput
): ExecutedProjectCommand {
assertExpectedRevision(input.expectedRevision);
assertSerializedProjectCommand(input.command);
return this.dispatch({
...input,
source: "user",
command: input.command,
});
}
undo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand {
return this.executeHistory(input, "undo");
}
redo(
input: ExecuteProjectHistoryCommandInput
): ExecutedProjectCommand {
return this.executeHistory(input, "redo");
}
private executeHistory(
input: ExecuteProjectHistoryCommandInput,
direction: ProjectHistoryDirection
) {
assertExpectedRevision(input.expectedRevision);
const step = this.historyStore.getNextCommand(
input.projectId,
direction
);
if (!step) {
throw new ProjectHistoryOperationUnavailableError(
input.projectId,
direction
);
}
return this.dispatch({
...input,
source: direction,
description: `${direction === "undo" ? "Undo" : "Redo"} ${step.command.type}`,
historyTargetChangeSetId: step.changeSetId,
command: step.command,
});
}
private dispatch(
input: DispatchProjectCommandInput
): ExecutedProjectCommand {
const revision = this.executeTypedCommand(input);
const history = this.historyStore.getState(input.projectId);
if (!history) {
throw new Error("Project history disappeared after command execution.");
}
return { revision, history };
}
private executeTypedCommand(input: DispatchProjectCommandInput) {
switch (input.command.type) {
case circuitUpdateCommandType: {
assertCircuitUpdateProjectCommand(input.command);
return this.circuitStore.executeUpdate({
...input,
command: input.command,
}).revision;
}
case circuitDeviceRowUpdateCommandType: {
assertCircuitDeviceRowUpdateProjectCommand(input.command);
return this.deviceRowStore.executeUpdate({
...input,
command: input.command,
}).revision;
}
default:
throw new Error(
`Unsupported project command type: ${input.command.type}.`
);
}
}
}
function assertExpectedRevision(expectedRevision: number) {
if (
!Number.isSafeInteger(expectedRevision) ||
expectedRevision < 0
) {
throw new Error(
"Expected project revision must be a non-negative integer."
);
}
}
+17
View File
@@ -15,6 +15,23 @@ export interface ProjectHistoryStateDto {
redoChangeSetId: string | null; redoChangeSetId: string | null;
} }
export interface ProjectCommandDto {
schemaVersion: number;
type: string;
payload: unknown;
}
export interface ProjectCommandResultDto {
revision: {
revisionId: string;
changeSetId: string;
projectId: string;
revisionNumber: number;
createdAtIso: string;
};
history: ProjectHistoryStateDto;
}
export interface DistributionBoardDto { export interface DistributionBoardDto {
id: string; id: string;
projectId: string; projectId: string;
+57
View File
@@ -7,6 +7,8 @@ import type {
DistributionBoardDto, DistributionBoardDto,
FloorDto, FloorDto,
GlobalDeviceDto, GlobalDeviceDto,
ProjectCommandDto,
ProjectCommandResultDto,
ProjectDeviceDto, ProjectDeviceDto,
ProjectHistoryStateDto, ProjectHistoryStateDto,
ProjectDeviceDisconnectResultDto, ProjectDeviceDisconnectResultDto,
@@ -61,6 +63,61 @@ export function getProjectHistory(projectId: string) {
); );
} }
export function executeProjectCommand(
projectId: string,
expectedRevision: number,
command: ProjectCommandDto,
description?: string
) {
return request<ProjectCommandResultDto>(
`/api/projects/${projectId}/commands`,
{
method: "POST",
body: JSON.stringify({
expectedRevision,
command,
...(description ? { description } : {}),
}),
}
);
}
export function undoProjectCommand(
projectId: string,
expectedRevision: number
) {
return executeProjectHistoryCommand(
projectId,
expectedRevision,
"undo"
);
}
export function redoProjectCommand(
projectId: string,
expectedRevision: number
) {
return executeProjectHistoryCommand(
projectId,
expectedRevision,
"redo"
);
}
function executeProjectHistoryCommand(
projectId: string,
expectedRevision: number,
direction: "undo" | "redo"
) {
return request<ProjectCommandResultDto>(
`/api/projects/${projectId}/history/${direction}`,
{
method: "POST",
body: JSON.stringify({ expectedRevision }),
}
);
}
export function createProject(name: string) { export function createProject(name: string) {
return request<ProjectDto>("/api/projects", { return request<ProjectDto>("/api/projects", {
method: "POST", method: "POST",
@@ -1,7 +1,15 @@
import { db } from "../../db/client.js"; import { db } from "../../db/client.js";
import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js"; import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-project-command.repository.js";
import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js"; import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-project-command.repository.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
import { ProjectCommandService } from "../../domain/services/project-command.service.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
export const circuitDeviceRowProjectCommandStore = export const circuitDeviceRowProjectCommandStore =
new CircuitDeviceRowProjectCommandRepository(db); new CircuitDeviceRowProjectCommandRepository(db);
export const projectHistoryStore = new ProjectHistoryRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore,
projectHistoryStore
);
@@ -0,0 +1,99 @@
import type { Request, Response } from "express";
import { ProjectHistoryOperationUnavailableError } from "../../domain/errors/project-history-operation-unavailable.error.js";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import {
executeProjectCommandSchema,
executeProjectHistoryCommandSchema,
} from "../../shared/validation/project-command.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
export function executeProjectCommand(req: Request, res: Response) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const parsed = executeProjectCommandSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
return res.json(
projectCommandService.executeUser({
projectId,
...parsed.data,
})
);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function undoProjectCommand(req: Request, res: Response) {
return executeHistoryCommand(req, res, "undo");
}
export function redoProjectCommand(req: Request, res: Response) {
return executeHistoryCommand(req, res, "redo");
}
function executeHistoryCommand(
req: Request,
res: Response,
direction: "undo" | "redo"
) {
const projectId = getProjectId(req, res);
if (!projectId) {
return;
}
const parsed = executeProjectHistoryCommandSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const result = projectCommandService[direction]({
projectId,
...parsed.data,
});
return res.json(result);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
function getProjectId(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string" || !projectId.trim()) {
res.status(400).json({ error: "Invalid projectId" });
return null;
}
return projectId;
}
export function respondWithProjectCommandError(
error: unknown,
res: Response
) {
if (error instanceof ProjectRevisionConflictError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_REVISION_CONFLICT",
expectedRevision: error.expectedRevision,
currentRevision: error.actualRevision,
});
}
if (error instanceof ProjectHistoryOperationUnavailableError) {
return res.status(409).json({
error: error.message,
code: "PROJECT_HISTORY_OPERATION_UNAVAILABLE",
direction: error.direction,
});
}
return res.status(400).json({
error:
error instanceof Error
? error.message
: "Project command could not be executed.",
});
}
@@ -1,8 +1,5 @@
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { db } from "../../db/client.js"; import { projectHistoryStore } from "../composition/project-command-stores.js";
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
const projectHistoryRepository = new ProjectHistoryRepository(db);
export function getProjectHistory(req: Request, res: Response) { export function getProjectHistory(req: Request, res: Response) {
const { projectId } = req.params; const { projectId } = req.params;
@@ -10,7 +7,7 @@ export function getProjectHistory(req: Request, res: Response) {
return res.status(400).json({ error: "Invalid projectId" }); return res.status(400).json({ error: "Invalid projectId" });
} }
const state = projectHistoryRepository.getState(projectId); const state = projectHistoryStore.getState(projectId);
if (!state) { if (!state) {
return res.status(404).json({ error: "Project not found" }); return res.status(404).json({ error: "Project not found" });
} }
+8
View File
@@ -14,6 +14,11 @@ import { createFloor, listFloorsByProject } from "../controllers/floor.controlle
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js"; import { createRoom, listRoomsByProject } from "../controllers/room.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js"; import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
import { getProjectHistory } from "../controllers/project-history.controller.js"; import { getProjectHistory } from "../controllers/project-history.controller.js";
import {
executeProjectCommand,
redoProjectCommand,
undoProjectCommand,
} from "../controllers/project-command.controller.js";
export const projectRouter = Router(); export const projectRouter = Router();
@@ -21,6 +26,9 @@ projectRouter.get("/", listProjects);
projectRouter.post("/", createProject); projectRouter.post("/", createProject);
projectRouter.get("/:projectId", getProject); projectRouter.get("/:projectId", getProject);
projectRouter.get("/:projectId/history", getProjectHistory); projectRouter.get("/:projectId/history", getProjectHistory);
projectRouter.post("/:projectId/commands", executeProjectCommand);
projectRouter.post("/:projectId/history/undo", undoProjectCommand);
projectRouter.post("/:projectId/history/redo", redoProjectCommand);
projectRouter.put("/:projectId", updateProjectSettings); projectRouter.put("/:projectId", updateProjectSettings);
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject); projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard); projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
@@ -0,0 +1,17 @@
import { z } from "zod";
const projectCommandEnvelopeSchema = z.object({
schemaVersion: z.number().int().positive(),
type: z.string().trim().min(1),
payload: z.unknown(),
});
export const executeProjectCommandSchema = z.object({
expectedRevision: z.number().int().nonnegative(),
description: z.string().trim().min(1).max(500).optional(),
command: projectCommandEnvelopeSchema,
});
export const executeProjectHistoryCommandSchema = z.object({
expectedRevision: z.number().int().nonnegative(),
});
+262
View File
@@ -0,0 +1,262 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { CircuitDeviceRowProjectCommandRepository } from "../src/db/repositories/circuit-device-row-project-command.repository.js";
import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-project-command.repository.js";
import { DistributionBoardRepository } from "../src/db/repositories/distribution-board.repository.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js";
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js";
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
import { createCircuitUpdateProjectCommand } from "../src/domain/models/circuit-project-command.model.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values({ id: "project-1", name: "Test project" })
.run();
const board = new DistributionBoardRepository(
context.db
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
const section = context.db
.select()
.from(circuitSections)
.where(eq(circuitSections.circuitListId, board.id))
.get();
assert.ok(section);
context.db
.insert(circuits)
.values({
id: "circuit-1",
circuitListId: board.id,
sectionId: section.id,
equipmentIdentifier: "-1F1",
displayName: "Bestand",
sortOrder: 10,
})
.run();
context.db
.insert(circuitDeviceRows)
.values({
id: "row-1",
circuitId: "circuit-1",
sortOrder: 10,
name: "Leuchte",
displayName: "Leuchte",
quantity: 1,
powerPerUnit: 0.1,
simultaneityFactor: 1,
})
.run();
return context;
}
function createService(context: DatabaseContext) {
return new ProjectCommandService(
new CircuitProjectCommandRepository(context.db),
new CircuitDeviceRowProjectCommandRepository(context.db),
new ProjectHistoryRepository(context.db)
);
}
function getCircuitName(context: DatabaseContext) {
return context.db
.select({ displayName: circuits.displayName })
.from(circuits)
.where(eq(circuits.id, "circuit-1"))
.get()?.displayName;
}
function getRowQuantity(context: DatabaseContext) {
return context.db
.select({ quantity: circuitDeviceRows.quantity })
.from(circuitDeviceRows)
.where(eq(circuitDeviceRows.id, "row-1"))
.get()?.quantity;
}
describe("project command service", () => {
it("dispatches supported commands and persists undo/redo across service instances", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const circuitResult = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Neu",
}),
});
assert.equal(circuitResult.revision.revisionNumber, 1);
assert.equal(circuitResult.history.undoDepth, 1);
assert.equal(getCircuitName(context), "Neu");
const rowResult = service.executeUser({
projectId: "project-1",
expectedRevision: 1,
command: createCircuitDeviceRowUpdateProjectCommand("row-1", {
quantity: 2,
}),
});
assert.equal(rowResult.revision.revisionNumber, 2);
assert.equal(rowResult.history.undoDepth, 2);
assert.equal(getRowQuantity(context), 2);
const reloadedService = createService(context);
const firstUndo = reloadedService.undo({
projectId: "project-1",
expectedRevision: 2,
});
assert.equal(firstUndo.revision.revisionNumber, 3);
assert.equal(firstUndo.history.undoDepth, 1);
assert.equal(firstUndo.history.redoDepth, 1);
assert.equal(getRowQuantity(context), 1);
const secondUndo = reloadedService.undo({
projectId: "project-1",
expectedRevision: 3,
});
assert.equal(secondUndo.revision.revisionNumber, 4);
assert.equal(secondUndo.history.undoDepth, 0);
assert.equal(secondUndo.history.redoDepth, 2);
assert.equal(getCircuitName(context), "Bestand");
const redo = reloadedService.redo({
projectId: "project-1",
expectedRevision: 4,
});
assert.equal(redo.revision.revisionNumber, 5);
assert.equal(redo.history.undoDepth, 1);
assert.equal(redo.history.redoDepth, 1);
assert.equal(getCircuitName(context), "Neu");
assert.deepEqual(
context.db
.select({ source: projectRevisions.source })
.from(projectRevisions)
.all()
.map((row) => row.source),
["user", "user", "undo", "undo", "redo"]
);
} finally {
context.close();
}
});
it("rejects stale undo and preserves the domain value and stack", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const changed = service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createCircuitUpdateProjectCommand("circuit-1", {
displayName: "Neu",
}),
});
assert.throws(
() =>
service.undo({
projectId: "project-1",
expectedRevision: 0,
}),
(error) =>
error instanceof ProjectRevisionConflictError &&
error.actualRevision === 1
);
assert.equal(getCircuitName(context), "Neu");
assert.deepEqual(
new ProjectHistoryRepository(context.db).getState("project-1"),
changed.history
);
assert.equal(context.db.select().from(projectRevisions).all().length, 1);
assert.equal(context.db.select().from(projectChangeSets).all().length, 1);
} finally {
context.close();
}
});
it("rejects unavailable history directions without writing a revision", () => {
const context = createTestDatabase();
try {
const service = createService(context);
assert.throws(
() =>
service.undo({
projectId: "project-1",
expectedRevision: 0,
}),
(error) =>
error instanceof ProjectHistoryOperationUnavailableError &&
error.direction === "undo"
);
assert.throws(
() =>
service.redo({
projectId: "project-1",
expectedRevision: 0,
}),
(error) =>
error instanceof ProjectHistoryOperationUnavailableError &&
error.direction === "redo"
);
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
} finally {
context.close();
}
});
it("rejects unsupported and malformed commands before domain writes", () => {
const context = createTestDatabase();
try {
const service = createService(context);
assert.throws(
() =>
service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: {
schemaVersion: 1,
type: "unknown.command",
payload: {},
},
}),
/Unsupported project command type/
);
assert.throws(
() =>
service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: {
schemaVersion: 1,
type: "circuit.update",
payload: { circuitId: "circuit-1", changes: [] },
},
}),
/at least one change/
);
assert.equal(getCircuitName(context), "Bestand");
assert.equal(context.db.select().from(projectRevisions).all().length, 0);
} finally {
context.close();
}
});
});