import { blockedGate, capabilityIssue, type CapabilityGateResult } from "./capability-gates"; import type { ErrorCode } from "./error"; export const LIBRARY_LINKED_MUTATION_SCHEMA = 1 as const; export const LINKED_DATA_WRITER_OPERATIONS = [ "OBJECT_TRANSFORM", "MESH_GEOMETRY", "MESH_MATERIAL_SLOT", "MATERIAL_PROPERTIES", "MATERIAL_IMAGE_NODE", "IMAGE_PACKED_DATA", ] as const; export type LinkedDataWriterOperation = typeof LINKED_DATA_WRITER_OPERATIONS[number]; export interface LinkedDataMutationIR { schemaVersion: typeof LIBRARY_LINKED_MUTATION_SCHEMA; operation: LinkedDataWriterOperation; dataBlockId: string; baseRevision: number; owner: "SOURCE_LIBRARY"; linkedLibrary: true; readOnly: true; } export class LinkedDataMutationError extends Error { readonly code: ErrorCode; readonly path?: string; constructor(code: ErrorCode, message: string, path?: string) { super(message); this.name = "LinkedDataMutationError"; this.code = code; this.path = path; } } function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function exactKeys(value: Record): void { const expected = ["schemaVersion", "operation", "dataBlockId", "baseRevision", "owner", "linkedLibrary", "readOnly"]; const actual = Object.keys(value).sort(); if (actual.length !== expected.length || actual.some((key, index) => key !== expected.slice().sort()[index])) { throw new LinkedDataMutationError("TASK_VALIDATION_FAILED", "linked data mutation contains unsupported fields"); } } export function parseLinkedDataMutation(value: unknown): LinkedDataMutationIR { if (!record(value) || value.schemaVersion !== LIBRARY_LINKED_MUTATION_SCHEMA) { throw new LinkedDataMutationError("PROTOCOL_MISMATCH", "Unsupported linked data mutation schema"); } exactKeys(value); if (!LINKED_DATA_WRITER_OPERATIONS.includes(value.operation as LinkedDataWriterOperation)) { throw new LinkedDataMutationError("TASK_VALIDATION_FAILED", "linked data mutation operation is unsupported", "operation"); } if (typeof value.dataBlockId !== "string" || value.dataBlockId.length === 0 || value.dataBlockId.length > 256) { throw new LinkedDataMutationError("TASK_VALIDATION_FAILED", "dataBlockId is invalid", "dataBlockId"); } if (typeof value.baseRevision !== "number" || !Number.isSafeInteger(value.baseRevision) || value.baseRevision < 0) { throw new LinkedDataMutationError("TASK_VALIDATION_FAILED", "baseRevision is invalid", "baseRevision"); } if (value.owner !== "SOURCE_LIBRARY" || value.linkedLibrary !== true || value.readOnly !== true) { throw new LinkedDataMutationError("LINKED_DATA_MUTATION_BLOCKED", "linked data must remain SOURCE_LIBRARY/readOnly", "ownership"); } return { schemaVersion: LIBRARY_LINKED_MUTATION_SCHEMA, operation: value.operation as LinkedDataWriterOperation, dataBlockId: value.dataBlockId, baseRevision: value.baseRevision, owner: "SOURCE_LIBRARY", linkedLibrary: true, readOnly: true, }; } export function gateLinkedDataMutation(value: unknown, currentRevision: number): CapabilityGateResult { let request: LinkedDataMutationIR; try { request = parseLinkedDataMutation(value); } catch (error) { const code = error instanceof LinkedDataMutationError ? error.code : "TASK_VALIDATION_FAILED"; const message = error instanceof Error ? error.message : "linked data mutation is invalid"; return blockedGate("N-023", "LINKED_DATA_WRITER", [capabilityIssue(code, message, error instanceof LinkedDataMutationError ? error.path : undefined, false)]); } if (!Number.isSafeInteger(currentRevision) || currentRevision < 0 || request.baseRevision !== currentRevision) { return blockedGate("N-023", `LINKED_${request.operation}`, [capabilityIssue("REVISION_CONFLICT", "linked data mutation revision is stale", "baseRevision", false)]); } return blockedGate("N-023", `LINKED_${request.operation}`, [capabilityIssue("LINKED_DATA_MUTATION_BLOCKED", "linked-library data is read-only", "readOnly", false)]); }