import type { ErrorCode } from "./error"; import { parseLibraryOperationBinding, type LibraryOperationBindingIR, } from "./library-operation-identity"; export const LIBRARY_MAIN_APPEND_SCHEMA = 1 as const; export const LIBRARY_MAIN_APPEND_MAX_SOURCE_BYTES = 64 * 1024 * 1024; export interface LibraryAppendClosureIR { object: string; mesh: string; material: string; image: string; } export interface LibraryMainAppendRequestIR { schemaVersion: typeof LIBRARY_MAIN_APPEND_SCHEMA; baseRevision: number; binding: LibraryOperationBindingIR; expectedClosure: LibraryAppendClosureIR; } export interface LibraryMainAppendMappingIR { source: string; local: string; owner: "LOCAL_MAIN"; readOnly: false; } export interface LibraryMainAppendReceiptIR { schemaVersion: typeof LIBRARY_MAIN_APPEND_SCHEMA; operation: "APPEND"; sourceLibraryId: string; sourceDataBlockId: string; dependencyClosureSha256: string; baseRevision: number; nextRevision: number; transactionCount: 1; mapping: LibraryMainAppendMappingIR[]; } export class LibraryMainAppendError extends Error { readonly code: ErrorCode; constructor(code: ErrorCode, message: string) { super(`${code}: ${message}`); this.name = "LibraryMainAppendError"; this.code = code; } } const encoder = new TextEncoder(); const PREFIXES = Object.freeze({ object: "Object/", mesh: "Mesh/", material: "Material/", image: "Image/", }); function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function exactKeys(value: Record, keys: readonly string[], path: string): void { const allowed = new Set(keys); if (Object.keys(value).length !== keys.length || Object.keys(value).some((key) => !allowed.has(key))) { throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", `${path} fields are not exact`); } } function stableJSON(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJSON).join(",")}]`; if (record(value)) { return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJSON(value[key])}`).join(",")}}`; } return JSON.stringify(value); } async function sha256Text(value: string): Promise { const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value)); return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); } export async function sha256LibraryBytes(value: ArrayBuffer): Promise { if (!(value instanceof ArrayBuffer) || value.byteLength === 0 || value.byteLength > LIBRARY_MAIN_APPEND_MAX_SOURCE_BYTES) { throw new LibraryMainAppendError("ASSET_BUDGET_EXCEEDED", "library source must contain 1..64 MiB"); } const digest = await crypto.subtle.digest("SHA-256", value); return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); } export function parseLibraryAppendClosure(value: unknown): LibraryAppendClosureIR { if (!record(value)) throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", "expectedClosure must be an object"); exactKeys(value, Object.keys(PREFIXES), "expectedClosure"); const result = {} as Record; for (const key of Object.keys(PREFIXES) as Array) { const prefix = PREFIXES[key]; const item = value[key]; if (typeof item !== "string" || !item.startsWith(prefix) || item.length === prefix.length || encoder.encode(item.slice(prefix.length)).byteLength > 63) { throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", `expectedClosure.${key} is invalid`); } result[key] = item; } if (new Set(Object.values(result)).size !== 4) { throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", "append closure IDs must be unique"); } return result; } export async function computeLibraryAppendClosureSha256(value: unknown): Promise { const closure = parseLibraryAppendClosure(value); return sha256Text(stableJSON({ schema: "BLENDER_APPEND_OBJECT_CLOSURE_V1", ...closure })); } export async function parseLibraryMainAppendRequest( value: unknown, currentRevision?: number, ): Promise { if (!record(value) || value.schemaVersion !== LIBRARY_MAIN_APPEND_SCHEMA) { throw new LibraryMainAppendError("PROTOCOL_MISMATCH", "unsupported library Main append schema"); } exactKeys(value, ["schemaVersion", "baseRevision", "binding", "expectedClosure"], "request"); if (typeof value.baseRevision !== "number" || !Number.isSafeInteger(value.baseRevision) || value.baseRevision < 0) { throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", "baseRevision must be a non-negative safe integer"); } if (currentRevision !== undefined && value.baseRevision !== currentRevision) { throw new LibraryMainAppendError("REVISION_CONFLICT", "library append base revision is stale"); } const binding = await parseLibraryOperationBinding(value.binding); const expectedClosure = parseLibraryAppendClosure(value.expectedClosure); if (binding.operation !== "APPEND" || binding.owner.kind !== "LOCAL_MAIN" || binding.readOnly || binding.referenceReadOnly) { throw new LibraryMainAppendError("ASSET_MANIFEST_INVALID", "library Main append requires a writable LOCAL_MAIN binding"); } if (binding.sourceDataBlockId !== expectedClosure.object || binding.owner.localDataBlockId !== expectedClosure.object) { throw new LibraryMainAppendError("ASSET_SOURCE_HASH_MISMATCH", "append root and local owner do not match the expected Object"); } if (binding.dependencyClosureSha256 !== await computeLibraryAppendClosureSha256(expectedClosure)) { throw new LibraryMainAppendError("REVISION_CONFLICT", "append dependency closure binding is stale"); } return { schemaVersion: LIBRARY_MAIN_APPEND_SCHEMA, baseRevision: value.baseRevision, binding, expectedClosure, }; } export function createLibraryMainAppendReceipt( request: LibraryMainAppendRequestIR, nextRevision: number, ): LibraryMainAppendReceiptIR { if (!Number.isSafeInteger(nextRevision) || nextRevision !== request.baseRevision + 1) { throw new LibraryMainAppendError("REVISION_CONFLICT", "library append must advance exactly one Main revision"); } return { schemaVersion: LIBRARY_MAIN_APPEND_SCHEMA, operation: "APPEND", sourceLibraryId: request.binding.source.sourceLibraryId, sourceDataBlockId: request.binding.sourceDataBlockId, dependencyClosureSha256: request.binding.dependencyClosureSha256, baseRevision: request.baseRevision, nextRevision, transactionCount: 1, mapping: Object.values(request.expectedClosure).map((source) => ({ source, local: source, owner: "LOCAL_MAIN", readOnly: false, })), }; }