Reorganize Blender Web execution around capabilities
This commit is contained in:
116
web/protocol/corrective-command-registry.ts
Normal file
116
web/protocol/corrective-command-registry.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user