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