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.
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:
+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.
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
+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,
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
View File
@@ -14,8 +14,8 @@
"build:api": "tsc -p tsconfig.json",
"build:web": "next build",
"start": "node dist/server/index.js",
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-write.rules.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/circuit-device-row-transaction.repository.test.ts tests/project-device-row-sync.repository.test.ts tests/circuit-section-transaction.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-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;
}
+13
View File
@@ -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."
);
}
}
+17
View File
@@ -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;
+57
View File
@@ -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" });
}
+8
View File
@@ -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(),
});
+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();
}
});
});