352 lines
16 KiB
TypeScript
352 lines
16 KiB
TypeScript
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,
|
|
};
|
|
}
|