174 lines
6.7 KiB
TypeScript
174 lines
6.7 KiB
TypeScript
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<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function exactKeys(value: Record<string, unknown>, 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<string> {
|
|
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<string> {
|
|
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<keyof LibraryAppendClosureIR, string>;
|
|
for (const key of Object.keys(PREFIXES) as Array<keyof LibraryAppendClosureIR>) {
|
|
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<string> {
|
|
const closure = parseLibraryAppendClosure(value);
|
|
return sha256Text(stableJSON({ schema: "BLENDER_APPEND_OBJECT_CLOSURE_V1", ...closure }));
|
|
}
|
|
|
|
export async function parseLibraryMainAppendRequest(
|
|
value: unknown,
|
|
currentRevision?: number,
|
|
): Promise<LibraryMainAppendRequestIR> {
|
|
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,
|
|
})),
|
|
};
|
|
}
|