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.
|
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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -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