117 lines
4.3 KiB
TypeScript
117 lines
4.3 KiB
TypeScript
import {
|
|
Authorization,
|
|
CaseSurface,
|
|
CommandContext,
|
|
CorrectiveCaseRunnerError,
|
|
MainMutationCommand,
|
|
MainMutationResult,
|
|
SurfaceReceipt,
|
|
TargetKind,
|
|
runDesktopCase,
|
|
runWasmCase,
|
|
} from "./corrective-case-runner";
|
|
|
|
export const CORRECTIVE_COMMAND_REGISTRY_SCHEMA = 1 as const;
|
|
|
|
export type CommandExecutor = (command: MainMutationCommand) => MainMutationResult | Promise<MainMutationResult>;
|
|
|
|
export interface RegisteredCommand {
|
|
commandType: string;
|
|
targetKinds: readonly TargetKind[];
|
|
authorizations: readonly Exclude<Authorization, "NONE">[];
|
|
execute: CommandExecutor;
|
|
}
|
|
|
|
export class CommandRegistryError extends Error {
|
|
readonly code: "REGISTRY_INVALID" | "REGISTRY_DUPLICATE";
|
|
|
|
constructor(code: "REGISTRY_INVALID" | "REGISTRY_DUPLICATE", message: string) {
|
|
super(`${code}: ${message}`);
|
|
this.name = "CommandRegistryError";
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
export interface DispatchOptions {
|
|
command: unknown;
|
|
mainRevision: number;
|
|
}
|
|
|
|
const COMMAND_TYPE = /^[a-z][A-Za-z0-9]{0,127}$/u;
|
|
const TARGET_KINDS = new Set<TargetKind>(["ASSET_LIBRARY", "DATABLOCK", "MESH", "MODIFIER", "SCENE"]);
|
|
const AUTHORIZATIONS = new Set<Exclude<Authorization, "NONE">>(["USER_EDIT", "SYSTEM"]);
|
|
|
|
function unique<T>(values: readonly T[]): boolean {
|
|
return new Set(values).size === values.length;
|
|
}
|
|
|
|
function validateDefinition(definition: RegisteredCommand): void {
|
|
if (!definition || typeof definition !== "object" || !COMMAND_TYPE.test(definition.commandType)) {
|
|
throw new CommandRegistryError("REGISTRY_INVALID", "commandType is invalid");
|
|
}
|
|
if (!Array.isArray(definition.targetKinds) || definition.targetKinds.length === 0 ||
|
|
!unique(definition.targetKinds) || definition.targetKinds.some((kind) => !TARGET_KINDS.has(kind))) {
|
|
throw new CommandRegistryError("REGISTRY_INVALID", "targetKinds must contain unique supported targets");
|
|
}
|
|
if (!Array.isArray(definition.authorizations) || definition.authorizations.length === 0 ||
|
|
!unique(definition.authorizations) || definition.authorizations.some((authorization) => !AUTHORIZATIONS.has(authorization))) {
|
|
throw new CommandRegistryError("REGISTRY_INVALID", "authorizations must contain unique mutating authorities");
|
|
}
|
|
if (typeof definition.execute !== "function") {
|
|
throw new CommandRegistryError("REGISTRY_INVALID", "execute must be a function");
|
|
}
|
|
}
|
|
|
|
function contextError(context: CommandContext, message: string): CorrectiveCaseRunnerError {
|
|
return new CorrectiveCaseRunnerError("INVALID_CONTEXT", `${context.targetKind}: ${message}`);
|
|
}
|
|
|
|
export class CorrectiveCommandRegistry {
|
|
readonly schemaVersion = CORRECTIVE_COMMAND_REGISTRY_SCHEMA;
|
|
private readonly commands = new Map<string, RegisteredCommand>();
|
|
|
|
register(definition: RegisteredCommand): this {
|
|
validateDefinition(definition);
|
|
if (this.commands.has(definition.commandType)) {
|
|
throw new CommandRegistryError("REGISTRY_DUPLICATE", `commandType ${definition.commandType} is already registered`);
|
|
}
|
|
this.commands.set(definition.commandType, {
|
|
commandType: definition.commandType,
|
|
targetKinds: [...definition.targetKinds],
|
|
authorizations: [...definition.authorizations],
|
|
execute: definition.execute,
|
|
});
|
|
return this;
|
|
}
|
|
|
|
has(commandType: string): boolean {
|
|
return this.commands.has(commandType);
|
|
}
|
|
|
|
list(): string[] {
|
|
return [...this.commands.keys()].sort();
|
|
}
|
|
|
|
async dispatch(surface: CaseSurface, options: DispatchOptions): Promise<SurfaceReceipt> {
|
|
const run = surface === "DESKTOP" ? runDesktopCase : runWasmCase;
|
|
return run({
|
|
command: options.command,
|
|
mainRevision: options.mainRevision,
|
|
execute: async (command) => {
|
|
const definition = this.commands.get(command.commandType);
|
|
if (!definition) {
|
|
throw new CorrectiveCaseRunnerError("COMMAND_UNSUPPORTED", `commandType ${command.commandType} is not registered`);
|
|
}
|
|
if (!definition.targetKinds.includes(command.context.targetKind)) {
|
|
throw contextError(command.context, "target is not authorized for this command");
|
|
}
|
|
if (!definition.authorizations.includes(command.context.authorization as Exclude<Authorization, "NONE">)) {
|
|
throw contextError(command.context, "authorization is not allowed for this command");
|
|
}
|
|
return definition.execute(command);
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|