Reorganize Blender Web execution around capabilities
This commit is contained in:
61
web/protocol/corrective-asset-catalog-command.ts
Normal file
61
web/protocol/corrective-asset-catalog-command.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { CorrectiveCommandRegistry } from "./corrective-command-registry";
|
||||
import { CorrectiveCaseRunnerError, MainMutationCommand, MainMutationResult } from "./corrective-case-runner";
|
||||
import { InMemoryMainStore, runAtomicMutation } from "./corrective-main-transaction";
|
||||
import { AssetCatalogResourceResolver, CorrectiveResourceError } from "./corrective-resource-provider";
|
||||
|
||||
export interface AssetCatalogEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceId: string;
|
||||
sourceSha256: string;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
export interface AssetCatalogMain {
|
||||
entries: AssetCatalogEntry[];
|
||||
}
|
||||
|
||||
const ENTRY_ID = /^[A-Za-z0-9._:-]{1,128}$/u;
|
||||
const ENTRY_NAME = /^.{1,128}$/u;
|
||||
|
||||
function payload(command: MainMutationCommand): { id: string; name: string; resource: Record<string, unknown> } {
|
||||
const input = command.payload;
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "catalog payload must be an object");
|
||||
const id = input.id;
|
||||
const name = input.name;
|
||||
const resource = input.resource;
|
||||
if (typeof id !== "string" || !ENTRY_ID.test(id) || typeof name !== "string" || !ENTRY_NAME.test(name) || !resource || typeof resource !== "object" || Array.isArray(resource)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "catalog entry payload is invalid");
|
||||
}
|
||||
return { id, name, resource: resource as Record<string, unknown> };
|
||||
}
|
||||
|
||||
export function createAssetCatalogRegistry(main: InMemoryMainStore<AssetCatalogMain>, resources: AssetCatalogResourceResolver): CorrectiveCommandRegistry {
|
||||
return new CorrectiveCommandRegistry().register({
|
||||
commandType: "assetCatalogAppend",
|
||||
targetKinds: ["ASSET_LIBRARY"],
|
||||
authorizations: ["USER_EDIT", "SYSTEM"],
|
||||
execute: async (command) => {
|
||||
const entry = payload(command);
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await resources.resolve(entry.resource);
|
||||
} catch (error) {
|
||||
if (error instanceof CorrectiveResourceError && error.code === "RESOURCE_REQUIRED") {
|
||||
throw new CorrectiveCaseRunnerError("RESOURCE_REQUIRED", error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return runAtomicMutation(main, command, (draft) => {
|
||||
if (draft.entries.some((item) => item.id === entry.id)) throw new CorrectiveCaseRunnerError("COMMAND_UNSUPPORTED", `catalog entry ${entry.id} already exists`);
|
||||
draft.entries.push({ id: entry.id, name: entry.name, sourceId: resolved.sourceId, sourceSha256: resolved.sha256, byteLength: resolved.bytes.byteLength });
|
||||
return { delta: { addedId: entry.id, count: draft.entries.length }, persistence: { dirty: true } };
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function catalogFailureCode(error: unknown): string {
|
||||
if (error instanceof CorrectiveResourceError) return error.code;
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
351
web/protocol/corrective-case-runner.ts
Normal file
351
web/protocol/corrective-case-runner.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
export const CORRECTIVE_CASE_SCHEMA = 1 as const;
|
||||
|
||||
export type CaseSurface = "DESKTOP" | "WASM";
|
||||
export type CommandStatus = "FINISHED" | "BLOCKED" | "CANCELLED" | "UNSUPPORTED" | "MALFORMED" | "EXTERNAL_REQUIRED";
|
||||
export type MainMutation = "CHANGED" | "UNCHANGED";
|
||||
export type TargetKind = "ASSET_LIBRARY" | "DATABLOCK" | "MESH" | "MODIFIER" | "SCENE";
|
||||
export type EditMode = "READ_ONLY" | "EDITABLE";
|
||||
export type Authorization = "NONE" | "USER_EDIT" | "SYSTEM";
|
||||
export type SourceKind = "LOCAL" | "EXTERNAL" | "PACKED";
|
||||
export type ResourceProvider = "NONE" | "OPFS" | "EXTERNAL_LIBRARY" | "PACKED_BLEND";
|
||||
|
||||
export interface CommandContext {
|
||||
mainId: string;
|
||||
targetKind: TargetKind;
|
||||
targetId?: string;
|
||||
editMode: EditMode;
|
||||
authorization: Authorization;
|
||||
source: { kind: SourceKind; sha256: string };
|
||||
resourceProvider?: ResourceProvider;
|
||||
}
|
||||
|
||||
export interface MainMutationCommand {
|
||||
schemaVersion: typeof CORRECTIVE_CASE_SCHEMA;
|
||||
requestId: string;
|
||||
commandType: string;
|
||||
baseRevision: number;
|
||||
context: CommandContext;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MainMutationResult {
|
||||
schemaVersion: typeof CORRECTIVE_CASE_SCHEMA;
|
||||
requestId: string;
|
||||
status: CommandStatus;
|
||||
code: string;
|
||||
baseRevision: number;
|
||||
mainRevisionBefore: number;
|
||||
mainRevisionAfter: number;
|
||||
mainMutation: MainMutation;
|
||||
delta: Record<string, unknown> | null;
|
||||
persistence: Record<string, unknown> | null;
|
||||
error: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface SurfaceReceipt {
|
||||
schemaVersion: typeof CORRECTIVE_CASE_SCHEMA;
|
||||
surface: CaseSurface;
|
||||
requestId: string;
|
||||
commandType: string;
|
||||
context: CommandContext | null;
|
||||
result: MainMutationResult;
|
||||
}
|
||||
|
||||
export interface SharedCaseReceipt {
|
||||
schemaVersion: typeof CORRECTIVE_CASE_SCHEMA;
|
||||
requestId: string;
|
||||
commandType: string;
|
||||
desktop: SurfaceReceipt;
|
||||
wasm: SurfaceReceipt;
|
||||
parity: "MATCH" | "MISMATCH";
|
||||
differences: string[];
|
||||
}
|
||||
|
||||
export interface SurfaceCaseOptions {
|
||||
command: unknown;
|
||||
mainRevision: number;
|
||||
execute: (command: MainMutationCommand) => MainMutationResult | Promise<MainMutationResult>;
|
||||
}
|
||||
|
||||
export class CorrectiveCaseRunnerError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "CorrectiveCaseRunnerError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/u;
|
||||
const REQUEST_ID = /^[A-Za-z0-9._:-]{1,256}$/u;
|
||||
const COMMAND_TYPE = /^[a-z][A-Za-z0-9]{0,127}$/u;
|
||||
const STATUS_CODES: Readonly<Record<CommandStatus, string>> = {
|
||||
FINISHED: "OK",
|
||||
BLOCKED: "REVISION_CONFLICT",
|
||||
CANCELLED: "CANCELLED",
|
||||
UNSUPPORTED: "COMMAND_UNSUPPORTED",
|
||||
MALFORMED: "COMMAND_MALFORMED",
|
||||
EXTERNAL_REQUIRED: "RESOURCE_REQUIRED",
|
||||
};
|
||||
const STATUS_CODE_PAIRS = new Set([
|
||||
"FINISHED:OK",
|
||||
"BLOCKED:REVISION_CONFLICT",
|
||||
"BLOCKED:INVALID_CONTEXT",
|
||||
"CANCELLED:CANCELLED",
|
||||
"UNSUPPORTED:COMMAND_UNSUPPORTED",
|
||||
"MALFORMED:COMMAND_MALFORMED",
|
||||
"EXTERNAL_REQUIRED:RESOURCE_REQUIRED",
|
||||
]);
|
||||
const STATUSES = new Set<CommandStatus>(Object.keys(STATUS_CODES) as CommandStatus[]);
|
||||
const TARGET_KINDS = new Set<TargetKind>(["ASSET_LIBRARY", "DATABLOCK", "MESH", "MODIFIER", "SCENE"]);
|
||||
const EDIT_MODES = new Set<EditMode>(["READ_ONLY", "EDITABLE"]);
|
||||
const AUTHORIZATIONS = new Set<Authorization>(["NONE", "USER_EDIT", "SYSTEM"]);
|
||||
const SOURCE_KINDS = new Set<SourceKind>(["LOCAL", "EXTERNAL", "PACKED"]);
|
||||
const RESOURCE_PROVIDERS = new Set<ResourceProvider>(["NONE", "OPFS", "EXTERNAL_LIBRARY", "PACKED_BLEND"]);
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>, label: string): void {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", `${label}.${key} is not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, label: string, pattern: RegExp, maximum = 256): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > maximum || !pattern.test(value)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", `${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", `${label} must be a non-negative integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function commandContext(value: unknown): CommandContext {
|
||||
if (!record(value)) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context must be an object");
|
||||
exactKeys(value, new Set(["mainId", "targetKind", "targetId", "editMode", "authorization", "source", "resourceProvider"]), "context");
|
||||
const mainId = boundedText(value.mainId, "context.mainId", /^.{1,256}$/u);
|
||||
if (typeof value.targetKind !== "string" || !TARGET_KINDS.has(value.targetKind as TargetKind)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context.targetKind is invalid");
|
||||
}
|
||||
const targetId = value.targetId === undefined ? undefined : boundedText(value.targetId, "context.targetId", /^.{1,256}$/u);
|
||||
if (typeof value.editMode !== "string" || !EDIT_MODES.has(value.editMode as EditMode)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context.editMode is invalid");
|
||||
}
|
||||
if (typeof value.authorization !== "string" || !AUTHORIZATIONS.has(value.authorization as Authorization)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context.authorization is invalid");
|
||||
}
|
||||
if (!record(value.source) || Object.keys(value.source).some((key) => !["kind", "sha256"].includes(key))) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context.source is invalid");
|
||||
}
|
||||
if (typeof value.source.kind !== "string" || !SOURCE_KINDS.has(value.source.kind as SourceKind) ||
|
||||
typeof value.source.sha256 !== "string" || !SHA256.test(value.source.sha256)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context.source provenance is invalid");
|
||||
}
|
||||
const resourceProvider = value.resourceProvider === undefined ? undefined : value.resourceProvider;
|
||||
if (resourceProvider !== undefined && (typeof resourceProvider !== "string" || !RESOURCE_PROVIDERS.has(resourceProvider as ResourceProvider))) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "context.resourceProvider is invalid");
|
||||
}
|
||||
return {
|
||||
mainId,
|
||||
targetKind: value.targetKind as TargetKind,
|
||||
...(targetId === undefined ? {} : { targetId }),
|
||||
editMode: value.editMode as EditMode,
|
||||
authorization: value.authorization as Authorization,
|
||||
source: { kind: value.source.kind as SourceKind, sha256: value.source.sha256 },
|
||||
...(resourceProvider === undefined ? {} : { resourceProvider: resourceProvider as ResourceProvider }),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCommand(value: unknown): MainMutationCommand {
|
||||
if (!record(value)) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "command must be an object");
|
||||
exactKeys(value, new Set(["schemaVersion", "requestId", "commandType", "baseRevision", "context", "payload"]), "command");
|
||||
if (value.schemaVersion !== CORRECTIVE_CASE_SCHEMA) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "schemaVersion is unsupported");
|
||||
const requestId = boundedText(value.requestId, "requestId", REQUEST_ID);
|
||||
const commandType = boundedText(value.commandType, "commandType", COMMAND_TYPE);
|
||||
const baseRevision = nonNegativeInteger(value.baseRevision, "baseRevision");
|
||||
const context = commandContext(value.context);
|
||||
if (!record(value.payload)) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "payload must be an object");
|
||||
for (const key of ["requestId", "baseRevision", "context"]) {
|
||||
if (Object.prototype.hasOwnProperty.call(value.payload, key)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", `payload cannot override ${key}`);
|
||||
}
|
||||
}
|
||||
return { schemaVersion: CORRECTIVE_CASE_SCHEMA, requestId, commandType, baseRevision, context, payload: { ...value.payload } };
|
||||
}
|
||||
|
||||
function resultObject(value: unknown): Record<string, unknown> {
|
||||
if (!record(value)) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "result must be an object");
|
||||
exactKeys(value, new Set(["schemaVersion", "requestId", "status", "code", "baseRevision", "mainRevisionBefore", "mainRevisionAfter", "mainMutation", "delta", "persistence", "error"]), "result");
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalObject(value: unknown, label: string): Record<string, unknown> | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (!record(value)) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", `${label} must be an object or null`);
|
||||
return { ...value };
|
||||
}
|
||||
|
||||
export function normalizeResult(value: unknown, command: MainMutationCommand, mainRevisionBefore: number): MainMutationResult {
|
||||
const result = resultObject(value);
|
||||
if (result.schemaVersion !== CORRECTIVE_CASE_SCHEMA || result.requestId !== command.requestId || result.baseRevision !== command.baseRevision) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "result identity is not bound to the command");
|
||||
}
|
||||
nonNegativeInteger(result.mainRevisionBefore, "mainRevisionBefore");
|
||||
nonNegativeInteger(result.mainRevisionAfter, "mainRevisionAfter");
|
||||
if (result.mainRevisionBefore !== mainRevisionBefore) throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "result before revision is not current");
|
||||
if (typeof result.status !== "string" || !STATUSES.has(result.status as CommandStatus) || typeof result.code !== "string" || !STATUS_CODE_PAIRS.has(`${result.status}:${result.code}`)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "status and code are not a closed pair");
|
||||
}
|
||||
const status = result.status as CommandStatus;
|
||||
const mutation = result.mainMutation;
|
||||
const expectedMutation: MainMutation = status === "FINISHED" ? "CHANGED" : "UNCHANGED";
|
||||
if (mutation !== expectedMutation || result.mainRevisionAfter !== mainRevisionBefore + (status === "FINISHED" ? 1 : 0)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "result revision or mutation invariant failed");
|
||||
}
|
||||
const delta = optionalObject(result.delta, "delta");
|
||||
const persistence = optionalObject(result.persistence, "persistence");
|
||||
const error = optionalObject(result.error, "error");
|
||||
if (status !== "FINISHED" && (delta !== null || persistence !== null)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "failure result cannot claim delta or persistence");
|
||||
}
|
||||
return {
|
||||
schemaVersion: CORRECTIVE_CASE_SCHEMA,
|
||||
requestId: command.requestId,
|
||||
status,
|
||||
code: STATUS_CODES[status],
|
||||
baseRevision: command.baseRevision,
|
||||
mainRevisionBefore,
|
||||
mainRevisionAfter: result.mainRevisionAfter,
|
||||
mainMutation: expectedMutation,
|
||||
delta,
|
||||
persistence,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function failure(command: MainMutationCommand | null, status: Exclude<CommandStatus, "FINISHED">, before: number, message: string, code = STATUS_CODES[status]): MainMutationResult {
|
||||
const requestId = command?.requestId ?? "invalid-request";
|
||||
const baseRevision = command?.baseRevision ?? before;
|
||||
return {
|
||||
schemaVersion: CORRECTIVE_CASE_SCHEMA,
|
||||
requestId,
|
||||
status,
|
||||
code,
|
||||
baseRevision,
|
||||
mainRevisionBefore: before,
|
||||
mainRevisionAfter: before,
|
||||
mainMutation: "UNCHANGED",
|
||||
delta: null,
|
||||
persistence: null,
|
||||
error: { code, message },
|
||||
};
|
||||
}
|
||||
|
||||
function commandIdentity(value: unknown): { requestId: string; commandType: string } {
|
||||
const input = record(value) ? value : {};
|
||||
return {
|
||||
requestId: typeof input.requestId === "string" && REQUEST_ID.test(input.requestId) ? input.requestId : "invalid-request",
|
||||
commandType: typeof input.commandType === "string" && COMMAND_TYPE.test(input.commandType) ? input.commandType : "malformed",
|
||||
};
|
||||
}
|
||||
|
||||
function receipt(surface: CaseSurface, command: MainMutationCommand | null, result: MainMutationResult): SurfaceReceipt {
|
||||
const identity = command ? { requestId: command.requestId, commandType: command.commandType } : commandIdentity(result);
|
||||
return {
|
||||
schemaVersion: CORRECTIVE_CASE_SCHEMA,
|
||||
surface,
|
||||
requestId: identity.requestId,
|
||||
commandType: identity.commandType,
|
||||
context: command?.context ?? null,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
async function runSurface(surface: CaseSurface, options: SurfaceCaseOptions): Promise<SurfaceReceipt> {
|
||||
const before = nonNegativeInteger(options.mainRevision, "mainRevision");
|
||||
let command: MainMutationCommand;
|
||||
try {
|
||||
command = validateCommand(options.command);
|
||||
}
|
||||
catch (error) {
|
||||
const identity = commandIdentity(options.command);
|
||||
const malformed = failure(null, "MALFORMED", before, error instanceof Error ? error.message : String(error));
|
||||
malformed.requestId = identity.requestId;
|
||||
return receipt(surface, null, malformed);
|
||||
}
|
||||
if (command.baseRevision !== before) return receipt(surface, command, failure(command, "BLOCKED", before, "base revision does not match current Main"));
|
||||
if (command.context.editMode !== "EDITABLE" || command.context.authorization === "NONE") {
|
||||
return receipt(surface, command, failure(command, "BLOCKED", before, "context cannot authorize a Main mutation", "INVALID_CONTEXT"));
|
||||
}
|
||||
if (command.context.source.kind === "EXTERNAL" && (!command.context.resourceProvider || command.context.resourceProvider === "NONE")) {
|
||||
return receipt(surface, command, failure(command, "EXTERNAL_REQUIRED", before, "external source requires a resource provider"));
|
||||
}
|
||||
try {
|
||||
return receipt(surface, command, normalizeResult(await options.execute(command), command, before));
|
||||
}
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const code = error instanceof CorrectiveCaseRunnerError ? error.code : "COMMAND_UNSUPPORTED";
|
||||
const status = code === "RESOURCE_REQUIRED" ? "EXTERNAL_REQUIRED" : code === "CANCELLED" ? "CANCELLED" : code === "REVISION_CONFLICT" || code === "INVALID_CONTEXT" ? "BLOCKED" : code === "COMMAND_MALFORMED" ? "MALFORMED" : "UNSUPPORTED";
|
||||
return receipt(surface, command, failure(command, status, before, message, code === "INVALID_CONTEXT" ? code : STATUS_CODES[status]));
|
||||
}
|
||||
}
|
||||
|
||||
export function runDesktopCase(options: SurfaceCaseOptions): Promise<SurfaceReceipt> {
|
||||
return runSurface("DESKTOP", options);
|
||||
}
|
||||
|
||||
export function runWasmCase(options: SurfaceCaseOptions): Promise<SurfaceReceipt> {
|
||||
return runSurface("WASM", options);
|
||||
}
|
||||
|
||||
function coreReceipt(receiptValue: SurfaceReceipt): Record<string, unknown> {
|
||||
const result = receiptValue.result;
|
||||
return {
|
||||
requestId: receiptValue.requestId,
|
||||
commandType: receiptValue.commandType,
|
||||
status: result.status,
|
||||
code: result.code,
|
||||
baseRevision: result.baseRevision,
|
||||
mainRevisionBefore: result.mainRevisionBefore,
|
||||
mainRevisionAfter: result.mainRevisionAfter,
|
||||
mainMutation: result.mainMutation,
|
||||
delta: result.delta,
|
||||
persistence: result.persistence,
|
||||
};
|
||||
}
|
||||
|
||||
function stable(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stable);
|
||||
if (record(value)) return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
|
||||
return value;
|
||||
}
|
||||
|
||||
export function compareReceipts(desktop: SurfaceReceipt, wasm: SurfaceReceipt): { parity: "MATCH" | "MISMATCH"; differences: string[] } {
|
||||
const left = coreReceipt(desktop);
|
||||
const right = coreReceipt(wasm);
|
||||
const differences = Object.keys(left).filter((key) => JSON.stringify(stable(left[key])) !== JSON.stringify(stable(right[key]))).map((key) => `${key}: desktop=${JSON.stringify(stable(left[key]))} wasm=${JSON.stringify(stable(right[key]))}`);
|
||||
return { parity: differences.length === 0 ? "MATCH" : "MISMATCH", differences };
|
||||
}
|
||||
|
||||
export async function runSharedCase(command: unknown, mainRevision: number, executors: { desktop: SurfaceCaseOptions["execute"]; wasm: SurfaceCaseOptions["execute"] }): Promise<SharedCaseReceipt> {
|
||||
const desktop = await runDesktopCase({ command, mainRevision, execute: executors.desktop });
|
||||
const wasm = await runWasmCase({ command, mainRevision, execute: executors.wasm });
|
||||
const comparison = compareReceipts(desktop, wasm);
|
||||
return {
|
||||
schemaVersion: CORRECTIVE_CASE_SCHEMA,
|
||||
requestId: desktop.requestId,
|
||||
commandType: desktop.commandType,
|
||||
desktop,
|
||||
wasm,
|
||||
...comparison,
|
||||
};
|
||||
}
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
45
web/protocol/corrective-family-runner.ts
Normal file
45
web/protocol/corrective-family-runner.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { CorrectiveCommandRegistry } from "./corrective-command-registry";
|
||||
import { CaseSurface, SurfaceReceipt } from "./corrective-case-runner";
|
||||
|
||||
export type CorrectiveFamily = "ASSET_CATALOG" | "MODIFIER" | "MESH";
|
||||
|
||||
export interface CorrectiveFamilyCase {
|
||||
id: string;
|
||||
family: CorrectiveFamily;
|
||||
registry: CorrectiveCommandRegistry;
|
||||
surface: CaseSurface;
|
||||
command: unknown;
|
||||
mainRevision: number;
|
||||
}
|
||||
|
||||
export interface CorrectiveFamilyResult extends CorrectiveFamilyCase {
|
||||
receipt: SurfaceReceipt;
|
||||
}
|
||||
|
||||
const CASE_ID = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u;
|
||||
|
||||
export async function runCorrectiveFamilyCases(cases: readonly CorrectiveFamilyCase[]): Promise<CorrectiveFamilyResult[]> {
|
||||
const ids = new Set<string>();
|
||||
for (const testCase of cases) {
|
||||
if (!testCase || !CASE_ID.test(testCase.id) || ids.has(testCase.id)) throw new Error(`family case id is invalid or duplicated: ${testCase?.id ?? "unknown"}`);
|
||||
ids.add(testCase.id);
|
||||
if (!["ASSET_CATALOG", "MODIFIER", "MESH"].includes(testCase.family)) throw new Error(`unsupported corrective family: ${testCase.family}`);
|
||||
if (!(testCase.registry instanceof CorrectiveCommandRegistry)) throw new Error(`family case registry is invalid: ${testCase.id}`);
|
||||
if (!Number.isSafeInteger(testCase.mainRevision) || testCase.mainRevision < 0) throw new Error(`family case revision is invalid: ${testCase.id}`);
|
||||
}
|
||||
return Promise.all(cases.map(async (testCase) => ({ ...testCase, receipt: await testCase.registry.dispatch(testCase.surface, { command: testCase.command, mainRevision: testCase.mainRevision }) })));
|
||||
}
|
||||
|
||||
export function summarizeCorrectiveFamilyResults(results: readonly CorrectiveFamilyResult[]): Record<CorrectiveFamily, { total: number; finished: number; unchanged: number }> {
|
||||
const summary: Record<CorrectiveFamily, { total: number; finished: number; unchanged: number }> = {
|
||||
ASSET_CATALOG: { total: 0, finished: 0, unchanged: 0 },
|
||||
MODIFIER: { total: 0, finished: 0, unchanged: 0 },
|
||||
MESH: { total: 0, finished: 0, unchanged: 0 },
|
||||
};
|
||||
for (const result of results) {
|
||||
const bucket = summary[result.family]; bucket.total += 1;
|
||||
if (result.receipt.result.status === "FINISHED") bucket.finished += 1;
|
||||
if (result.receipt.result.mainMutation === "UNCHANGED") bucket.unchanged += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
70
web/protocol/corrective-history-store.ts
Normal file
70
web/protocol/corrective-history-store.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { InMemoryMainStore, MainSnapshot } from "./corrective-main-transaction";
|
||||
|
||||
export interface HistoryState<T> extends MainSnapshot<T> {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
savedRevision: number;
|
||||
}
|
||||
|
||||
export class CorrectiveHistoryStore<T> extends InMemoryMainStore<T> {
|
||||
private readonly capacity: number;
|
||||
private undoStack: MainSnapshot<T>[] = [];
|
||||
private redoStack: MainSnapshot<T>[] = [];
|
||||
private saved: MainSnapshot<T>;
|
||||
|
||||
constructor(value: T, revision: number, dirty: boolean, clone: (value: T) => T, capacity = 16) {
|
||||
super(value, revision, dirty, clone);
|
||||
if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity > 256) throw new Error("history capacity must be between 1 and 256");
|
||||
this.capacity = capacity;
|
||||
this.saved = this.snapshot();
|
||||
}
|
||||
|
||||
override commit(value: T, revision: number, dirty: boolean): void {
|
||||
const before = this.snapshot();
|
||||
super.commit(value, revision, dirty);
|
||||
if (before.revision !== revision || JSON.stringify(before.value) !== JSON.stringify(value) || before.dirty !== dirty) {
|
||||
this.undoStack.push(before);
|
||||
if (this.undoStack.length > this.capacity) this.undoStack.shift();
|
||||
this.redoStack = [];
|
||||
}
|
||||
}
|
||||
|
||||
save(): MainSnapshot<T> {
|
||||
const current = this.snapshot();
|
||||
this.saved = current;
|
||||
super.commit(current.value, current.revision, false);
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
reopen(): MainSnapshot<T> {
|
||||
const before = this.snapshot();
|
||||
super.commit(this.saved.value, this.saved.revision, false);
|
||||
this.undoStack = [];
|
||||
this.redoStack = [];
|
||||
return before;
|
||||
}
|
||||
|
||||
undo(): MainSnapshot<T> {
|
||||
const previous = this.undoStack.pop();
|
||||
if (!previous) return this.snapshot();
|
||||
const current = this.snapshot();
|
||||
this.redoStack.push(current);
|
||||
super.commit(previous.value, previous.revision, previous.dirty);
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
redo(): MainSnapshot<T> {
|
||||
const next = this.redoStack.pop();
|
||||
if (!next) return this.snapshot();
|
||||
const current = this.snapshot();
|
||||
this.undoStack.push(current);
|
||||
super.commit(next.value, next.revision, next.dirty);
|
||||
return this.snapshot();
|
||||
}
|
||||
|
||||
historyState(): HistoryState<T> {
|
||||
const current = this.snapshot();
|
||||
return { ...current, canUndo: this.undoStack.length > 0, canRedo: this.redoStack.length > 0, savedRevision: this.saved.revision };
|
||||
}
|
||||
}
|
||||
|
||||
92
web/protocol/corrective-main-transaction.ts
Normal file
92
web/protocol/corrective-main-transaction.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
CorrectiveCaseRunnerError,
|
||||
MainMutationCommand,
|
||||
MainMutationResult,
|
||||
} from "./corrective-case-runner";
|
||||
|
||||
export interface MainSnapshot<T> {
|
||||
value: T;
|
||||
revision: number;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export interface AtomicMutationOutcome {
|
||||
delta: Record<string, unknown>;
|
||||
persistence?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export type DraftMutation<T> = (draft: T, command: MainMutationCommand) => AtomicMutationOutcome | Promise<AtomicMutationOutcome>;
|
||||
|
||||
export class InMemoryMainStore<T> {
|
||||
private value: T;
|
||||
private currentRevision: number;
|
||||
private currentDirty: boolean;
|
||||
private readonly clone: (value: T) => T;
|
||||
|
||||
constructor(value: T, revision: number, dirty: boolean, clone: (value: T) => T) {
|
||||
if (!Number.isSafeInteger(revision) || revision < 0) throw new Error("revision must be a non-negative integer");
|
||||
this.clone = clone;
|
||||
this.value = clone(value);
|
||||
this.currentRevision = revision;
|
||||
this.currentDirty = dirty;
|
||||
}
|
||||
|
||||
snapshot(): MainSnapshot<T> {
|
||||
return { value: this.clone(this.value), revision: this.currentRevision, dirty: this.currentDirty };
|
||||
}
|
||||
|
||||
commit(value: T, revision: number, dirty: boolean): void {
|
||||
if (!Number.isSafeInteger(revision) || revision < 0) throw new Error("revision must be a non-negative integer");
|
||||
this.value = this.clone(value);
|
||||
this.currentRevision = revision;
|
||||
this.currentDirty = dirty;
|
||||
}
|
||||
|
||||
restore(snapshot: MainSnapshot<T>): void {
|
||||
this.commit(snapshot.value, snapshot.revision, snapshot.dirty);
|
||||
}
|
||||
}
|
||||
|
||||
function validOutcome(value: unknown): value is AtomicMutationOutcome {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (!candidate.delta || typeof candidate.delta !== "object" || Array.isArray(candidate.delta)) return false;
|
||||
return candidate.persistence === undefined || candidate.persistence === null ||
|
||||
(typeof candidate.persistence === "object" && !Array.isArray(candidate.persistence));
|
||||
}
|
||||
|
||||
export async function runAtomicMutation<T>(
|
||||
store: InMemoryMainStore<T>,
|
||||
command: MainMutationCommand,
|
||||
mutate: DraftMutation<T>,
|
||||
): Promise<MainMutationResult> {
|
||||
const before = store.snapshot();
|
||||
if (command.baseRevision !== before.revision) {
|
||||
throw new CorrectiveCaseRunnerError("REVISION_CONFLICT", "base revision does not match current Main");
|
||||
}
|
||||
try {
|
||||
const draft = store.snapshot().value;
|
||||
const outcome = await mutate(draft, command);
|
||||
if (!validOutcome(outcome)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "atomic mutation outcome is invalid");
|
||||
}
|
||||
store.commit(draft, before.revision + 1, true);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
requestId: command.requestId,
|
||||
status: "FINISHED",
|
||||
code: "OK",
|
||||
baseRevision: command.baseRevision,
|
||||
mainRevisionBefore: before.revision,
|
||||
mainRevisionAfter: before.revision + 1,
|
||||
mainMutation: "CHANGED",
|
||||
delta: { ...outcome.delta },
|
||||
persistence: outcome.persistence === undefined ? { dirty: true } : outcome.persistence,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
store.restore(before);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
72
web/protocol/corrective-mesh-command.ts
Normal file
72
web/protocol/corrective-mesh-command.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { CorrectiveCommandRegistry } from "./corrective-command-registry";
|
||||
import { CorrectiveCaseRunnerError, MainMutationCommand } from "./corrective-case-runner";
|
||||
import { InMemoryMainStore, runAtomicMutation } from "./corrective-main-transaction";
|
||||
|
||||
export interface MeshVertexState {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface MeshMain {
|
||||
vertices: MeshVertexState[];
|
||||
}
|
||||
|
||||
const VERTEX_ID = /^[A-Za-z0-9._:-]{1,128}$/u;
|
||||
const COORDINATE_LIMIT = 1_000_000;
|
||||
|
||||
function coordinate(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > COORDINATE_LIMIT) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", `${label} must be a finite coordinate within bounds`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function meshPayload(command: MainMutationCommand): { vertexId: string; delta: { x: number; y: number; z: number } } {
|
||||
const input = command.payload;
|
||||
if (!input || typeof input !== "object" || Array.isArray(input) ||
|
||||
typeof input.vertexId !== "string" || !VERTEX_ID.test(input.vertexId) ||
|
||||
!input.delta || typeof input.delta !== "object" || Array.isArray(input.delta) ||
|
||||
Object.keys(input).some((key) => !["vertexId", "delta"].includes(key))) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "mesh payload must contain only vertexId and delta");
|
||||
}
|
||||
const delta = input.delta as Record<string, unknown>;
|
||||
if (Object.keys(delta).some((key) => !["x", "y", "z"].includes(key)) ||
|
||||
!Object.prototype.hasOwnProperty.call(delta, "x") || !Object.prototype.hasOwnProperty.call(delta, "y") || !Object.prototype.hasOwnProperty.call(delta, "z")) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "mesh delta must contain only x, y, and z");
|
||||
}
|
||||
return { vertexId: input.vertexId, delta: { x: coordinate(delta.x, "delta.x"), y: coordinate(delta.y, "delta.y"), z: coordinate(delta.z, "delta.z") } };
|
||||
}
|
||||
|
||||
function cloneMeshMain(value: MeshMain): MeshMain {
|
||||
return { vertices: value.vertices.map((vertex) => ({ ...vertex })) };
|
||||
}
|
||||
|
||||
export function createMeshRegistry(main: InMemoryMainStore<MeshMain>): CorrectiveCommandRegistry {
|
||||
return new CorrectiveCommandRegistry().register({
|
||||
commandType: "meshTranslateVertex",
|
||||
targetKinds: ["MESH"],
|
||||
authorizations: ["USER_EDIT", "SYSTEM"],
|
||||
execute: async (command) => {
|
||||
const change = meshPayload(command);
|
||||
return runAtomicMutation(main, command, (draft) => {
|
||||
const vertex = draft.vertices.find((candidate) => candidate.id === change.vertexId);
|
||||
if (!vertex) throw new CorrectiveCaseRunnerError("COMMAND_UNSUPPORTED", `vertex ${change.vertexId} does not exist`);
|
||||
const before = { x: vertex.x, y: vertex.y, z: vertex.z };
|
||||
const after = { x: before.x + change.delta.x, y: before.y + change.delta.y, z: before.z + change.delta.z };
|
||||
if ([after.x, after.y, after.z].some((value) => !Number.isFinite(value) || Math.abs(value) > COORDINATE_LIMIT)) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "mesh translation exceeds coordinate bounds");
|
||||
}
|
||||
vertex.x = after.x;
|
||||
vertex.y = after.y;
|
||||
vertex.z = after.z;
|
||||
return { delta: { vertexId: vertex.id, before, after }, persistence: { dirty: true } };
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createMeshMain(vertices: readonly MeshVertexState[], revision = 0, dirty = false): InMemoryMainStore<MeshMain> {
|
||||
return new InMemoryMainStore({ vertices: vertices.map((vertex) => ({ ...vertex })) }, revision, dirty, cloneMeshMain);
|
||||
}
|
||||
59
web/protocol/corrective-modifier-command.ts
Normal file
59
web/protocol/corrective-modifier-command.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { CorrectiveCommandRegistry } from "./corrective-command-registry";
|
||||
import { CorrectiveCaseRunnerError, MainMutationCommand } from "./corrective-case-runner";
|
||||
import { InMemoryMainStore, runAtomicMutation } from "./corrective-main-transaction";
|
||||
|
||||
export interface ModifierState {
|
||||
uuid: string;
|
||||
name: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
showViewport: boolean;
|
||||
showRender: boolean;
|
||||
}
|
||||
|
||||
export interface ModifierMain {
|
||||
modifiers: ModifierState[];
|
||||
}
|
||||
|
||||
const UUID = /^[A-Za-z0-9._:-]{1,128}$/u;
|
||||
|
||||
function modifierPayload(command: MainMutationCommand): { modifierUuid: string; enabled: boolean } {
|
||||
const input = command.payload;
|
||||
if (!input || typeof input !== "object" || Array.isArray(input) ||
|
||||
typeof input.modifierUuid !== "string" || !UUID.test(input.modifierUuid) ||
|
||||
typeof input.enabled !== "boolean" || Object.keys(input).some((key) => !["modifierUuid", "enabled"].includes(key))) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_MALFORMED", "modifier payload must contain only modifierUuid and enabled");
|
||||
}
|
||||
return { modifierUuid: input.modifierUuid, enabled: input.enabled };
|
||||
}
|
||||
|
||||
function cloneModifierMain(value: ModifierMain): ModifierMain {
|
||||
return { modifiers: value.modifiers.map((modifier) => ({ ...modifier })) };
|
||||
}
|
||||
|
||||
export function createModifierRegistry(main: InMemoryMainStore<ModifierMain>): CorrectiveCommandRegistry {
|
||||
return new CorrectiveCommandRegistry().register({
|
||||
commandType: "modifierSetEnabled",
|
||||
targetKinds: ["MODIFIER"],
|
||||
authorizations: ["USER_EDIT", "SYSTEM"],
|
||||
execute: async (command) => {
|
||||
const change = modifierPayload(command);
|
||||
return runAtomicMutation(main, command, (draft) => {
|
||||
const modifier = draft.modifiers.find((candidate) => candidate.uuid === change.modifierUuid);
|
||||
if (!modifier) {
|
||||
throw new CorrectiveCaseRunnerError("COMMAND_UNSUPPORTED", `modifier ${change.modifierUuid} does not exist`);
|
||||
}
|
||||
const previousEnabled = modifier.enabled;
|
||||
modifier.enabled = change.enabled;
|
||||
return {
|
||||
delta: { modifierUuid: modifier.uuid, previousEnabled, enabled: modifier.enabled },
|
||||
persistence: { dirty: true },
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createModifierMain(modifiers: readonly ModifierState[], revision = 0, dirty = false): InMemoryMainStore<ModifierMain> {
|
||||
return new InMemoryMainStore({ modifiers: modifiers.map((modifier) => ({ ...modifier })) }, revision, dirty, cloneModifierMain);
|
||||
}
|
||||
153
web/protocol/corrective-resource-provider.ts
Normal file
153
web/protocol/corrective-resource-provider.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
export const CORRECTIVE_RESOURCE_SCHEMA = 1 as const;
|
||||
|
||||
export type CatalogSourceKind = "LOCAL" | "EXTERNAL" | "PACKED";
|
||||
export type ResourceProviderKind = "LOCAL" | "EXTERNAL_LIBRARY" | "PACKED_BLEND";
|
||||
|
||||
export interface ActiveLibraryContext {
|
||||
libraryId: string;
|
||||
sourceKind: CatalogSourceKind;
|
||||
sourceId: string;
|
||||
generation: number;
|
||||
revision: number;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export interface ResourceRequest {
|
||||
schemaVersion: typeof CORRECTIVE_RESOURCE_SCHEMA;
|
||||
requestId: string;
|
||||
sourceKind: CatalogSourceKind;
|
||||
sourceId: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface ResourceRecord {
|
||||
schemaVersion: typeof CORRECTIVE_RESOURCE_SCHEMA;
|
||||
provider: ResourceProviderKind;
|
||||
sourceKind: CatalogSourceKind;
|
||||
sourceId: string;
|
||||
sha256: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export class CorrectiveResourceError extends Error {
|
||||
readonly code: "RESOURCE_MALFORMED" | "RESOURCE_REQUIRED" | "RESOURCE_NOT_FOUND" | "RESOURCE_HASH_MISMATCH" | "CANCELLED" | "CONTEXT_INVALID";
|
||||
|
||||
constructor(code: CorrectiveResourceError["code"], message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "CorrectiveResourceError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/u;
|
||||
const REQUEST_ID = /^[A-Za-z0-9._:-]{1,256}$/u;
|
||||
const SOURCE_ID = /^[A-Za-z0-9._:/-]{1,256}$/u;
|
||||
const LIBRARY_ID = /^[A-Za-z0-9._:/-]{1,256}$/u;
|
||||
const SOURCE_KINDS = new Set<CatalogSourceKind>(["LOCAL", "EXTERNAL", "PACKED"]);
|
||||
|
||||
function assertText(value: unknown, pattern: RegExp, label: string): string {
|
||||
if (typeof value !== "string" || !pattern.test(value)) throw new CorrectiveResourceError("RESOURCE_MALFORMED", `${label} is invalid`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertInteger(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new CorrectiveResourceError("RESOURCE_MALFORMED", `${label} is invalid`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function bytesCopy(value: Uint8Array): Uint8Array {
|
||||
return new Uint8Array(value);
|
||||
}
|
||||
|
||||
async function digest(bytes: Uint8Array): Promise<string> {
|
||||
const hash = await globalThis.crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>);
|
||||
return [...new Uint8Array(hash)].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export function validateActiveLibraryContext(value: unknown): ActiveLibraryContext {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new CorrectiveResourceError("CONTEXT_INVALID", "active library context must be an object");
|
||||
const input = value as Record<string, unknown>;
|
||||
const keys = Object.keys(input);
|
||||
if (keys.some((key) => !["libraryId", "sourceKind", "sourceId", "generation", "revision", "readOnly"].includes(key))) throw new CorrectiveResourceError("CONTEXT_INVALID", "active library context has unknown fields");
|
||||
const libraryId = assertText(input.libraryId, LIBRARY_ID, "libraryId");
|
||||
const sourceKind = input.sourceKind;
|
||||
if (typeof sourceKind !== "string" || !SOURCE_KINDS.has(sourceKind as CatalogSourceKind)) throw new CorrectiveResourceError("CONTEXT_INVALID", "sourceKind is invalid");
|
||||
const sourceId = assertText(input.sourceId, SOURCE_ID, "sourceId");
|
||||
const generation = assertInteger(input.generation, "generation");
|
||||
const revision = assertInteger(input.revision, "revision");
|
||||
if (typeof input.readOnly !== "boolean") throw new CorrectiveResourceError("CONTEXT_INVALID", "readOnly is invalid");
|
||||
return { libraryId, sourceKind: sourceKind as CatalogSourceKind, sourceId, generation, revision, readOnly: input.readOnly };
|
||||
}
|
||||
|
||||
export function validateResourceRequest(value: unknown): ResourceRequest {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new CorrectiveResourceError("RESOURCE_MALFORMED", "resource request must be an object");
|
||||
const input = value as Record<string, unknown>;
|
||||
if (Object.keys(input).some((key) => !["schemaVersion", "requestId", "sourceKind", "sourceId", "sha256"].includes(key))) throw new CorrectiveResourceError("RESOURCE_MALFORMED", "resource request has unknown fields");
|
||||
if (input.schemaVersion !== CORRECTIVE_RESOURCE_SCHEMA) throw new CorrectiveResourceError("RESOURCE_MALFORMED", "schemaVersion is unsupported");
|
||||
const requestId = assertText(input.requestId, REQUEST_ID, "requestId");
|
||||
if (typeof input.sourceKind !== "string" || !SOURCE_KINDS.has(input.sourceKind as CatalogSourceKind)) throw new CorrectiveResourceError("RESOURCE_MALFORMED", "sourceKind is invalid");
|
||||
const sourceId = assertText(input.sourceId, SOURCE_ID, "sourceId");
|
||||
const sha256 = assertText(input.sha256, SHA256, "sha256");
|
||||
return { schemaVersion: CORRECTIVE_RESOURCE_SCHEMA, requestId, sourceKind: input.sourceKind as CatalogSourceKind, sourceId, sha256 };
|
||||
}
|
||||
|
||||
export class InMemoryCatalogProvider {
|
||||
readonly provider: ResourceProviderKind;
|
||||
private readonly records = new Map<string, Uint8Array>();
|
||||
|
||||
constructor(provider: ResourceProviderKind) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
async put(sourceId: string, bytes: Uint8Array): Promise<string> {
|
||||
assertText(sourceId, SOURCE_ID, "sourceId");
|
||||
if (!(bytes instanceof Uint8Array)) throw new CorrectiveResourceError("RESOURCE_MALFORMED", "bytes must be Uint8Array");
|
||||
const copy = bytesCopy(bytes);
|
||||
const hash = await digest(copy);
|
||||
this.records.set(sourceId, copy);
|
||||
return hash;
|
||||
}
|
||||
|
||||
async load(sourceId: string, signal?: AbortSignal): Promise<Uint8Array> {
|
||||
if (signal?.aborted) throw new CorrectiveResourceError("CANCELLED", "resource lookup cancelled");
|
||||
const value = this.records.get(sourceId);
|
||||
if (!value) throw new CorrectiveResourceError("RESOURCE_NOT_FOUND", `resource ${sourceId} is not available`);
|
||||
await Promise.resolve();
|
||||
if (signal?.aborted) throw new CorrectiveResourceError("CANCELLED", "resource lookup cancelled");
|
||||
return bytesCopy(value);
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetCatalogResourceResolver {
|
||||
private readonly providers = new Map<ResourceProviderKind, InMemoryCatalogProvider>();
|
||||
private active: ActiveLibraryContext | null = null;
|
||||
|
||||
setActiveLibraryContext(value: unknown): ActiveLibraryContext {
|
||||
this.active = validateActiveLibraryContext(value);
|
||||
return { ...this.active };
|
||||
}
|
||||
|
||||
registerProvider(provider: InMemoryCatalogProvider): this {
|
||||
this.providers.set(provider.provider, provider);
|
||||
return this;
|
||||
}
|
||||
|
||||
activeLibraryContext(): ActiveLibraryContext | null {
|
||||
return this.active ? { ...this.active } : null;
|
||||
}
|
||||
|
||||
async resolve(value: unknown, signal?: AbortSignal): Promise<ResourceRecord> {
|
||||
const request = validateResourceRequest(value);
|
||||
if (signal?.aborted) throw new CorrectiveResourceError("CANCELLED", "resource lookup cancelled");
|
||||
if (!this.active || this.active.sourceKind !== request.sourceKind || this.active.sourceId !== request.sourceId) {
|
||||
throw new CorrectiveResourceError("CONTEXT_INVALID", "request does not match active library context");
|
||||
}
|
||||
const providerKind: ResourceProviderKind = request.sourceKind === "LOCAL" ? "LOCAL" : request.sourceKind === "EXTERNAL" ? "EXTERNAL_LIBRARY" : "PACKED_BLEND";
|
||||
const provider = this.providers.get(providerKind);
|
||||
if (!provider) throw new CorrectiveResourceError("RESOURCE_REQUIRED", `${providerKind} provider is required`);
|
||||
const bytes = await provider.load(request.sourceId, signal);
|
||||
const observed = await digest(bytes);
|
||||
if (observed !== request.sha256) throw new CorrectiveResourceError("RESOURCE_HASH_MISMATCH", `resource hash ${observed} does not match request`);
|
||||
return { schemaVersion: CORRECTIVE_RESOURCE_SCHEMA, provider: providerKind, sourceKind: request.sourceKind, sourceId: request.sourceId, sha256: observed, bytes };
|
||||
}
|
||||
}
|
||||
@@ -46,9 +46,22 @@ export interface SceneNodeIR {
|
||||
localMatrix: number[];
|
||||
worldMatrix: number[];
|
||||
transform: SceneTransformIR;
|
||||
assetData?: AssetMetadataIR;
|
||||
constraints?: ConstraintIR[];
|
||||
}
|
||||
|
||||
export interface AssetMetadataIR {
|
||||
marked: true;
|
||||
author: string;
|
||||
description: string;
|
||||
copyright: string;
|
||||
license: string;
|
||||
catalogSimpleName: string;
|
||||
activeTag: number;
|
||||
preferredImportMethod: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ConstraintIR {
|
||||
name: string;
|
||||
typeCode: number;
|
||||
|
||||
Reference in New Issue
Block a user