Add project command API
This commit is contained in:
@@ -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.
|
||||
The server already persists immutable revisions and project-wide undo/redo
|
||||
eligibility for the first internal commands. Public command execution,
|
||||
server-side undo/redo endpoints and the UI cutover remain future work.
|
||||
eligibility for the first commands. Public command, undo and redo endpoints
|
||||
exist for Circuit and CircuitDeviceRow field updates. Complete command coverage
|
||||
and the UI cutover remain future work.
|
||||
|
||||
Required operations:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Project responses from `GET /projects` and `GET /projects/:projectId` include
|
||||
`currentRevision`. It currently exposes the persisted revision foundation only;
|
||||
no public undo or redo command exists yet.
|
||||
`currentRevision`. Versioned project commands use this value for optimistic
|
||||
concurrency checks.
|
||||
|
||||
### Project History State
|
||||
### Project Commands and History
|
||||
|
||||
- `GET /projects/:projectId/history`
|
||||
- returns `currentRevision`, `undoDepth`, `redoDepth` and the current top
|
||||
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
|
||||
|
||||
|
||||
@@ -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,
|
||||
persistente Undo-/Redo-Stacks verwalten die zulässige LIFO-Reihenfolge und
|
||||
verwerfen den Redo-Zweig bei einem neuen Benutzerkommando. Ihr Status ist über
|
||||
`GET /api/projects/:projectId/history` lesbar. Die schreibenden Command-Stores
|
||||
sind noch nicht über die API oder das Grid aktiviert; serverseitiges Undo/Redo
|
||||
ist daher noch nicht verfügbar.
|
||||
`GET /api/projects/:projectId/history` lesbar. Ein zentraler Dispatcher führt
|
||||
die unterstützten Typen `circuit.update` und `circuit-device-row.update` über
|
||||
ö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
|
||||
|
||||
|
||||
@@ -167,7 +167,12 @@ Completed foundation:
|
||||
- domain writes, audit revisions and stack transitions share one transaction;
|
||||
a forced stack failure rolls all of them back
|
||||
- `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:
|
||||
|
||||
@@ -176,10 +181,9 @@ Remaining constraints before completing persistent history:
|
||||
command envelope is complete
|
||||
- route the remaining structural, synchronization and destructive domain
|
||||
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
|
||||
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
|
||||
- keep database backup/restore checks separate from project history tests
|
||||
|
||||
|
||||
@@ -362,6 +362,8 @@ Tasks:
|
||||
- define concrete serializable domain command descriptions and executors
|
||||
outside React
|
||||
- [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
|
||||
- restore a historical snapshot as a new revision
|
||||
- keep database backups separate from logical history
|
||||
@@ -385,6 +387,9 @@ Implemented foundation:
|
||||
late-failure rollback coverage
|
||||
- a read-only project history endpoint exposes revision, stack depths and top
|
||||
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
|
||||
in one SQLite transaction
|
||||
- a stale expected revision produces no history writes
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@
|
||||
"build:api": "tsc -p tsconfig.json",
|
||||
"build:web": "next build",
|
||||
"start": "node dist/server/index.js",
|
||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-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/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/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",
|
||||
|
||||
@@ -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 {
|
||||
ProjectHistoryCommand,
|
||||
ProjectHistoryDirection,
|
||||
ProjectHistoryState,
|
||||
ProjectHistoryStore,
|
||||
} from "../../domain/ports/project-history.store.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 { projectRevisions } from "../schema/project-revisions.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
|
||||
export class ProjectHistoryRepository implements ProjectHistoryStore {
|
||||
@@ -41,4 +46,50 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
|
||||
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;
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { SerializedProjectCommand } from "../models/project-command.model.js";
|
||||
|
||||
export type ProjectHistoryDirection = "undo" | "redo";
|
||||
|
||||
export interface ProjectHistoryState {
|
||||
projectId: string;
|
||||
currentRevision: number;
|
||||
@@ -7,6 +11,15 @@ export interface ProjectHistoryState {
|
||||
redoChangeSetId: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectHistoryCommand {
|
||||
changeSetId: string;
|
||||
command: SerializedProjectCommand;
|
||||
}
|
||||
|
||||
export interface ProjectHistoryStore {
|
||||
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."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,23 @@ export interface ProjectHistoryStateDto {
|
||||
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 {
|
||||
id: string;
|
||||
projectId: string;
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
DistributionBoardDto,
|
||||
FloorDto,
|
||||
GlobalDeviceDto,
|
||||
ProjectCommandDto,
|
||||
ProjectCommandResultDto,
|
||||
ProjectDeviceDto,
|
||||
ProjectHistoryStateDto,
|
||||
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) {
|
||||
return request<ProjectDto>("/api/projects", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { db } from "../../db/client.js";
|
||||
import { CircuitDeviceRowProjectCommandRepository } from "../../db/repositories/circuit-device-row-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 circuitDeviceRowProjectCommandStore =
|
||||
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 { db } from "../../db/client.js";
|
||||
import { ProjectHistoryRepository } from "../../db/repositories/project-history.repository.js";
|
||||
|
||||
const projectHistoryRepository = new ProjectHistoryRepository(db);
|
||||
import { projectHistoryStore } from "../composition/project-command-stores.js";
|
||||
|
||||
export function getProjectHistory(req: Request, res: Response) {
|
||||
const { projectId } = req.params;
|
||||
@@ -10,7 +7,7 @@ export function getProjectHistory(req: Request, res: Response) {
|
||||
return res.status(400).json({ error: "Invalid projectId" });
|
||||
}
|
||||
|
||||
const state = projectHistoryRepository.getState(projectId);
|
||||
const state = projectHistoryStore.getState(projectId);
|
||||
if (!state) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ import { createFloor, listFloorsByProject } from "../controllers/floor.controlle
|
||||
import { createRoom, listRoomsByProject } from "../controllers/room.controller.js";
|
||||
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
|
||||
import { getProjectHistory } from "../controllers/project-history.controller.js";
|
||||
import {
|
||||
executeProjectCommand,
|
||||
redoProjectCommand,
|
||||
undoProjectCommand,
|
||||
} from "../controllers/project-command.controller.js";
|
||||
|
||||
export const projectRouter = Router();
|
||||
|
||||
@@ -21,6 +26,9 @@ projectRouter.get("/", listProjects);
|
||||
projectRouter.post("/", createProject);
|
||||
projectRouter.get("/:projectId", getProject);
|
||||
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.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
||||
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(),
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user