Advance Blender 5.2 web parity through M12-03D
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-17 17:30:27 -04:00
parent 0fe8d2bb56
commit 5a11045ca5
148 changed files with 11683 additions and 66 deletions

View File

@@ -0,0 +1,236 @@
import {
migrateAssetCatalogV1ToV2,
type AssetCatalogMigrationReportIR,
} from "../../../protocol/asset-catalog-migration";
import {
parseAssetCatalogManifestV2,
type AssetCatalogManifestV2IR,
} from "../../../protocol/asset-catalog-v2";
import type { ErrorCode } from "../../../protocol/error";
export const ASSET_CATALOG_INDEX_V1_ID = "asset-catalog:index:v1" as const;
export const ASSET_CATALOG_INDEX_V2_ID = "asset-catalog:index:v2" as const;
export const ASSET_CATALOG_MIGRATION_ID = "asset-catalog:migration:v1-to-v2" as const;
export type AssetCatalogMigrationFault = "AFTER_TARGET_PUT" | "AFTER_SOURCE_DELETE";
export interface AssetCatalogIndexRowV1 {
id: typeof ASSET_CATALOG_INDEX_V1_ID;
value: unknown;
}
export interface AssetCatalogIndexRowV2 {
id: typeof ASSET_CATALOG_INDEX_V2_ID;
value: AssetCatalogManifestV2IR;
}
export interface AssetCatalogIndexedDBMigrationReceiptIR {
schemaVersion: 1;
id: typeof ASSET_CATALOG_MIGRATION_ID;
task: "M12-01G";
status: "MIGRATED";
sourceRevision: number;
targetRevision: number;
sourceManifestSha256: string;
targetManifestSha256: string;
}
export interface AssetCatalogIndexedDBMigrationResultIR {
status: "MIGRATED" | "ALREADY_MIGRATED";
manifest: AssetCatalogManifestV2IR;
receipt: AssetCatalogIndexedDBMigrationReceiptIR;
migrationReport: AssetCatalogMigrationReportIR | null;
}
export interface AssetCatalogIndexedDBSnapshotIR {
schemaVersion: 1;
revision: number;
manifestSha256: string;
catalogOrder: Array<{ catalogId: string; path: string }>;
assetOrder: string[];
assetIdentities: Array<{
assetId: string;
assetLibraryIdentifier: string | null;
relativeAssetIdentifier: string;
}>;
}
export class AssetCatalogIndexedDBMigrationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string, options?: ErrorOptions) {
super(`${code}: ${message}`, options);
this.name = "AssetCatalogIndexedDBMigrationError";
this.code = code;
}
}
interface StoredRow {
id: string;
value?: unknown;
[key: string]: unknown;
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
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 sha256(value: unknown): Promise<string> {
const bytes = new TextEncoder().encode(stableJSON(value));
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function transactionComplete(transaction: IDBTransaction, failure: () => Error | undefined): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(failure() ?? transaction.error ?? new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Catalog migration transaction failed"));
transaction.onabort = () => reject(failure() ?? transaction.error ?? new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Catalog migration transaction aborted"));
});
}
function readRow(store: IDBObjectStore, id: string): Promise<StoredRow | undefined> {
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result as StoredRow | undefined);
request.onerror = () => reject(request.error ?? new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", `Catalog index lookup failed for ${id}`));
});
}
function assertStores(database: IDBDatabase, settingStore: string, migrationStore: string): void {
if (!database.objectStoreNames.contains(settingStore) || !database.objectStoreNames.contains(migrationStore)) {
throw new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Catalog migration stores are unavailable");
}
}
function parseReceipt(value: unknown): AssetCatalogIndexedDBMigrationReceiptIR {
const digest = /^[a-f0-9]{64}$/;
if (!record(value) || value.schemaVersion !== 1 || value.id !== ASSET_CATALOG_MIGRATION_ID || value.task !== "M12-01G" || value.status !== "MIGRATED" ||
!Number.isSafeInteger(value.sourceRevision) || Number(value.sourceRevision) < 0 ||
!Number.isSafeInteger(value.targetRevision) || Number(value.targetRevision) < 0 ||
typeof value.sourceManifestSha256 !== "string" || !digest.test(value.sourceManifestSha256) ||
typeof value.targetManifestSha256 !== "string" || !digest.test(value.targetManifestSha256)) {
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Stored catalog migration receipt is invalid");
}
return value as unknown as AssetCatalogIndexedDBMigrationReceiptIR;
}
export async function readAssetCatalogIndexedDBIndex(
database: IDBDatabase,
settingStore = "setting",
migrationStore = "migration",
): Promise<{ source: StoredRow | undefined; target: StoredRow | undefined; receipt: StoredRow | undefined }> {
assertStores(database, settingStore, migrationStore);
const transaction = database.transaction([settingStore, migrationStore], "readonly");
const sourcePromise = readRow(transaction.objectStore(settingStore), ASSET_CATALOG_INDEX_V1_ID);
const targetPromise = readRow(transaction.objectStore(settingStore), ASSET_CATALOG_INDEX_V2_ID);
const receiptPromise = readRow(transaction.objectStore(migrationStore), ASSET_CATALOG_MIGRATION_ID);
const result = await Promise.all([sourcePromise, targetPromise, receiptPromise]);
await transactionComplete(transaction, () => undefined);
return { source: result[0], target: result[1], receipt: result[2] };
}
export async function migrateAssetCatalogIndexedDB(
database: IDBDatabase,
options: {
settingStore?: string;
migrationStore?: string;
faultAt?: AssetCatalogMigrationFault;
} = {},
): Promise<AssetCatalogIndexedDBMigrationResultIR> {
const settingStore = options.settingStore ?? "setting";
const migrationStore = options.migrationStore ?? "migration";
assertStores(database, settingStore, migrationStore);
const before = await readAssetCatalogIndexedDBIndex(database, settingStore, migrationStore);
if (!before.source) {
if (!before.target || !before.receipt) {
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Catalog index has neither a complete v1 source nor a complete v2 migration");
}
const manifest = await parseAssetCatalogManifestV2(before.target.value);
const receipt = parseReceipt(before.receipt);
if (receipt.targetRevision !== manifest.revision || await sha256(manifest) !== receipt.targetManifestSha256) {
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Catalog migration receipt does not match the stored v2 index");
}
return { status: "ALREADY_MIGRATED", manifest, receipt, migrationReport: null };
}
if (before.target || before.receipt?.id === ASSET_CATALOG_MIGRATION_ID) {
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Catalog index contains a partial v1 to v2 migration");
}
const prepared = await migrateAssetCatalogV1ToV2(before.source.value);
const receipt: AssetCatalogIndexedDBMigrationReceiptIR = {
schemaVersion: 1,
id: ASSET_CATALOG_MIGRATION_ID,
task: "M12-01G",
status: "MIGRATED",
sourceRevision: prepared.report.sourceRevision,
targetRevision: prepared.report.targetRevision,
sourceManifestSha256: prepared.report.sourceManifestSha256,
targetManifestSha256: prepared.report.targetManifestSha256,
};
let migrationFailure: Error | undefined;
const transaction = database.transaction([settingStore, migrationStore], "readwrite");
const completion = transactionComplete(transaction, () => migrationFailure);
const settings = transaction.objectStore(settingStore);
const migrations = transaction.objectStore(migrationStore);
const sourceRequest = settings.get(ASSET_CATALOG_INDEX_V1_ID);
sourceRequest.onerror = () => {
migrationFailure = sourceRequest.error ?? new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Catalog source recheck failed");
};
sourceRequest.onsuccess = () => {
const current = sourceRequest.result as StoredRow | undefined;
if (!current || stableJSON(current.value) !== stableJSON(before.source?.value)) {
migrationFailure = new AssetCatalogIndexedDBMigrationError("REVISION_CONFLICT", "Catalog v1 index changed while migration was prepared");
transaction.abort();
return;
}
settings.put({ id: ASSET_CATALOG_INDEX_V2_ID, value: prepared.manifest } satisfies AssetCatalogIndexRowV2);
if (options.faultAt === "AFTER_TARGET_PUT") {
migrationFailure = new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Injected catalog migration failure after target write");
transaction.abort();
return;
}
migrations.put(receipt);
settings.delete(ASSET_CATALOG_INDEX_V1_ID);
if (options.faultAt === "AFTER_SOURCE_DELETE") {
migrationFailure = new AssetCatalogIndexedDBMigrationError("STORAGE_TRANSACTION", "Injected catalog migration failure after source delete");
transaction.abort();
}
};
await completion;
return { status: "MIGRATED", manifest: prepared.manifest, receipt, migrationReport: prepared.report };
}
export async function loadAssetCatalogIndexedDBSnapshot(
database: IDBDatabase,
options: { settingStore?: string; migrationStore?: string } = {},
): Promise<AssetCatalogIndexedDBSnapshotIR> {
const loaded = await migrateAssetCatalogIndexedDB(database, options);
const manifestSha256 = await sha256(loaded.manifest);
if (manifestSha256 !== loaded.receipt.targetManifestSha256) {
throw new AssetCatalogIndexedDBMigrationError("ASSET_MANIFEST_INVALID", "Loaded catalog index does not match its migration receipt");
}
return {
schemaVersion: 1,
revision: loaded.manifest.revision,
manifestSha256,
catalogOrder: loaded.manifest.catalogs.map(({ catalogId, path }) => ({ catalogId, path })),
assetOrder: loaded.manifest.assets.map(({ assetId }) => assetId),
assetIdentities: loaded.manifest.assets.map(({ assetId, assetLibraryIdentifier, relativeAssetIdentifier }) => ({
assetId,
assetLibraryIdentifier,
relativeAssetIdentifier,
})),
};
}

View File

@@ -0,0 +1,262 @@
import {
parseAssetCatalogManifestV2,
type AssetCatalogManifestV2IR,
type AssetCatalogV2PreviewIR,
} from "../../../protocol/asset-catalog-v2";
import { planAssetPreviewDecode } from "../../../protocol/asset-preview-decode";
import {
createAssetPreviewIdentity,
parseAssetPreviewIdentity,
type AssetPreviewIdentityIR,
} from "../../../protocol/asset-preview";
import type { ErrorCode } from "../../../protocol/error";
import {
ASSET_CATALOG_INDEX_V2_ID,
type AssetCatalogIndexRowV2,
} from "./asset-catalog-indexeddb";
import { readContentAsset, writeContentAsset } from "./opfs-files";
export const ASSET_PREVIEW_CATALOG_HEAD_ID = "asset-catalog:preview-head:v1" as const;
export { createAssetPreviewIdentity, parseAssetCatalogManifestV2 };
export type AssetPreviewCatalogCommitFault =
| "BEFORE_OPFS_WRITE"
| "AFTER_OPFS_WRITE"
| "AFTER_CATALOG_PUT";
export interface AssetPreviewCatalogCommitReceiptIR {
schemaVersion: 1;
id: typeof ASSET_PREVIEW_CATALOG_HEAD_ID;
task: "M12-02D";
status: "COMMITTED";
projectId: string;
assetId: string;
baseRevision: number;
committedRevision: number;
baseManifestSha256: string;
committedManifestSha256: string;
previewIdentitySha256: string;
contentSha256: string;
contentByteLength: number;
opfsPath: string;
}
export interface AssetPreviewCatalogCommitResultIR {
manifest: AssetCatalogManifestV2IR;
receipt: AssetPreviewCatalogCommitReceiptIR;
preview: AssetCatalogV2PreviewIR;
deduplicated: boolean;
}
export class AssetPreviewCatalogCommitError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string, options?: ErrorOptions) {
super(`${code}: ${message}`, options);
this.name = "AssetPreviewCatalogCommitError";
this.code = code;
}
}
interface StoredRow {
id: string;
value?: unknown;
[key: string]: unknown;
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
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 sha256(value: unknown): Promise<string> {
const bytes = new TextEncoder().encode(stableJSON(value));
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
async function sha256Bytes(value: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", value);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function readRow(store: IDBObjectStore, id: string): Promise<StoredRow | undefined> {
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result as StoredRow | undefined);
request.onerror = () => reject(request.error ?? new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", `Catalog row lookup failed for ${id}`));
});
}
function transactionComplete(transaction: IDBTransaction, failure: () => Error | undefined): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(failure() ?? transaction.error ?? new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Preview catalog transaction failed"));
transaction.onabort = () => reject(failure() ?? transaction.error ?? new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Preview catalog transaction aborted"));
});
}
async function readCatalog(database: IDBDatabase, settingStore: string): Promise<{ row: StoredRow; manifest: AssetCatalogManifestV2IR }> {
if (!database.objectStoreNames.contains(settingStore)) {
throw new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Catalog setting store is unavailable");
}
const transaction = database.transaction(settingStore, "readonly");
const row = await readRow(transaction.objectStore(settingStore), ASSET_CATALOG_INDEX_V2_ID);
await transactionComplete(transaction, () => undefined);
if (!row) throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Catalog v2 index is missing");
return { row, manifest: await parseAssetCatalogManifestV2(row.value) };
}
function parseReceipt(value: unknown): AssetPreviewCatalogCommitReceiptIR {
const digest = /^[a-f0-9]{64}$/;
if (!record(value) || value.schemaVersion !== 1 || value.id !== ASSET_PREVIEW_CATALOG_HEAD_ID ||
value.task !== "M12-02D" || value.status !== "COMMITTED" || typeof value.projectId !== "string" ||
typeof value.assetId !== "string" || !Number.isSafeInteger(value.baseRevision) || Number(value.baseRevision) < 0 ||
!Number.isSafeInteger(value.committedRevision) || Number(value.committedRevision) !== Number(value.baseRevision) + 1 ||
typeof value.baseManifestSha256 !== "string" || !digest.test(value.baseManifestSha256) ||
typeof value.committedManifestSha256 !== "string" || !digest.test(value.committedManifestSha256) ||
typeof value.previewIdentitySha256 !== "string" || !digest.test(value.previewIdentitySha256) ||
typeof value.contentSha256 !== "string" || !digest.test(value.contentSha256) ||
!Number.isSafeInteger(value.contentByteLength) || Number(value.contentByteLength) <= 0 ||
typeof value.opfsPath !== "string" || value.opfsPath.length === 0) {
throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Preview catalog commit receipt is invalid");
}
return value as unknown as AssetPreviewCatalogCommitReceiptIR;
}
function previewFromIdentity(identity: AssetPreviewIdentityIR): AssetCatalogV2PreviewIR {
return {
sha256: identity.content.sha256,
mimeType: identity.content.mimeType,
width: identity.content.width,
height: identity.content.height,
byteLength: identity.content.byteLength,
};
}
export async function commitAssetPreviewToOPFS(
database: IDBDatabase,
projectId: string,
expectedRevision: number,
identityValue: unknown,
encodedBytes: ArrayBuffer,
options: {
settingStore?: string;
storage?: StorageManager;
faultAt?: AssetPreviewCatalogCommitFault;
} = {},
): Promise<AssetPreviewCatalogCommitResultIR> {
const settingStore = options.settingStore ?? "setting";
const identity = await parseAssetPreviewIdentity(identityValue);
await planAssetPreviewDecode(identity, encodedBytes);
const before = await readCatalog(database, settingStore);
if (before.manifest.revision !== expectedRevision) {
throw new AssetPreviewCatalogCommitError("REVISION_CONFLICT", "Catalog revision changed before preview storage");
}
const assetIndex = before.manifest.assets.findIndex((asset) => asset.assetId === identity.assetId);
if (assetIndex === -1) {
throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Preview identity does not belong to a catalog asset");
}
if (expectedRevision === Number.MAX_SAFE_INTEGER) {
throw new AssetPreviewCatalogCommitError("ASSET_BUDGET_EXCEEDED", "Catalog revision cannot advance safely");
}
const preview = previewFromIdentity(identity);
const candidate = await parseAssetCatalogManifestV2({
...before.manifest,
revision: expectedRevision + 1,
assets: before.manifest.assets.map((asset, index) => index === assetIndex ? { ...asset, preview } : asset),
});
const baseManifestSha256 = await sha256(before.manifest);
const committedManifestSha256 = await sha256(candidate);
if (options.faultAt === "BEFORE_OPFS_WRITE") {
throw new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Injected failure before preview OPFS write");
}
let written: Awaited<ReturnType<typeof writeContentAsset>>;
try {
written = await writeContentAsset(projectId, identity.content.sha256, encodedBytes, options.storage);
const persisted = await readContentAsset(projectId, identity.content.sha256, options.storage);
await planAssetPreviewDecode(identity, persisted);
}
catch (error) {
if (error instanceof AssetPreviewCatalogCommitError || (error instanceof Error && "code" in error)) throw error;
throw new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Preview OPFS write or readback verification failed", { cause: error });
}
if (options.faultAt === "AFTER_OPFS_WRITE") {
throw new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Injected failure after preview OPFS write");
}
const receipt: AssetPreviewCatalogCommitReceiptIR = {
schemaVersion: 1,
id: ASSET_PREVIEW_CATALOG_HEAD_ID,
task: "M12-02D",
status: "COMMITTED",
projectId,
assetId: identity.assetId,
baseRevision: expectedRevision,
committedRevision: candidate.revision,
baseManifestSha256,
committedManifestSha256,
previewIdentitySha256: identity.identitySha256,
contentSha256: identity.content.sha256,
contentByteLength: identity.content.byteLength,
opfsPath: written.path,
};
let commitFailure: Error | undefined;
const transaction = database.transaction(settingStore, "readwrite");
const completion = transactionComplete(transaction, () => commitFailure);
const store = transaction.objectStore(settingStore);
const request = store.get(ASSET_CATALOG_INDEX_V2_ID);
request.onerror = () => {
commitFailure = request.error ?? new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Catalog source recheck failed");
};
request.onsuccess = () => {
const current = request.result as StoredRow | undefined;
if (!current || stableJSON(current.value) !== stableJSON(before.row.value)) {
commitFailure = new AssetPreviewCatalogCommitError("REVISION_CONFLICT", "Catalog index changed while preview storage was prepared");
transaction.abort();
return;
}
store.put({ id: ASSET_CATALOG_INDEX_V2_ID, value: candidate } satisfies AssetCatalogIndexRowV2);
store.put(receipt);
if (options.faultAt === "AFTER_CATALOG_PUT") {
commitFailure = new AssetPreviewCatalogCommitError("STORAGE_TRANSACTION", "Injected failure after preview catalog writes");
transaction.abort();
}
};
await completion;
return { manifest: candidate, receipt, preview, deduplicated: written.deduplicated };
}
export async function loadCommittedAssetPreviewCatalog(
database: IDBDatabase,
options: { settingStore?: string; storage?: StorageManager } = {},
): Promise<AssetPreviewCatalogCommitResultIR> {
const settingStore = options.settingStore ?? "setting";
const catalog = await readCatalog(database, settingStore);
const transaction = database.transaction(settingStore, "readonly");
const row = await readRow(transaction.objectStore(settingStore), ASSET_PREVIEW_CATALOG_HEAD_ID);
await transactionComplete(transaction, () => undefined);
if (!row) throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Preview catalog commit receipt is missing");
const receipt = parseReceipt(row);
if (catalog.manifest.revision !== receipt.committedRevision || await sha256(catalog.manifest) !== receipt.committedManifestSha256) {
throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Preview catalog receipt does not match the current catalog");
}
const asset = catalog.manifest.assets.find((candidate) => candidate.assetId === receipt.assetId);
if (!asset?.preview || asset.preview.sha256 !== receipt.contentSha256 || asset.preview.byteLength !== receipt.contentByteLength) {
throw new AssetPreviewCatalogCommitError("ASSET_MANIFEST_INVALID", "Committed catalog preview does not match its receipt");
}
const persisted = await readContentAsset(receipt.projectId, receipt.contentSha256, options.storage);
if (persisted.byteLength !== receipt.contentByteLength || await sha256Bytes(persisted) !== receipt.contentSha256) {
throw new AssetPreviewCatalogCommitError("ASSET_SOURCE_HASH_MISMATCH", "Committed preview payload identity changed");
}
return { manifest: catalog.manifest, receipt, preview: asset.preview, deduplicated: false };
}

View File

@@ -0,0 +1,226 @@
import {
parseAssetCatalogManifestV2,
type AssetCatalogV2EntryIR,
type AssetCatalogV2PreviewIR,
} from "../../../protocol/asset-catalog-v2";
import type { ErrorCode } from "../../../protocol/error";
import { ASSET_CATALOG_INDEX_V2_ID } from "./asset-catalog-indexeddb";
import { projectLayout } from "./opfs-files";
export const ASSET_PREVIEW_QUARANTINE_SCHEMA = 1 as const;
export const ASSET_PREVIEW_QUARANTINE_PREFIX = "asset-preview:quarantine:v1:" as const;
export interface AssetPreviewQuarantineReceiptIR {
schemaVersion: typeof ASSET_PREVIEW_QUARANTINE_SCHEMA;
id: string;
task: "M12-02F";
status: "QUARANTINED";
projectId: string;
assetId: string;
catalogRevision: number;
expected: AssetCatalogV2PreviewIR;
actualByteLength: number | null;
actualSha256: string | null;
code: "ASSET_SOURCE_HASH_MISMATCH";
quarantinePath: string | null;
}
export type AssetPreviewInspectionIR =
| {
status: "READY";
asset: AssetCatalogV2EntryIR;
preview: AssetCatalogV2PreviewIR;
data: ArrayBuffer;
receipt: null;
}
| {
status: "QUARANTINED";
asset: AssetCatalogV2EntryIR;
preview: null;
data: null;
receipt: AssetPreviewQuarantineReceiptIR;
};
export class AssetPreviewQuarantineError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string, options?: ErrorOptions) {
super(`${code}: ${message}`, options);
this.name = "AssetPreviewQuarantineError";
this.code = code;
}
}
interface StoredRow {
id: string;
value?: unknown;
[key: string]: unknown;
}
type OpfsStorage = StorageManager & { getDirectory?: () => Promise<FileSystemDirectoryHandle> };
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
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 sha256Bytes(value: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", value);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function transactionComplete(transaction: IDBTransaction, failure: () => Error | undefined): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(failure() ?? transaction.error ?? new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Preview quarantine transaction failed"));
transaction.onabort = () => reject(failure() ?? transaction.error ?? new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Preview quarantine transaction aborted"));
});
}
function readRow(store: IDBObjectStore, id: string): Promise<StoredRow | undefined> {
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result as StoredRow | undefined);
request.onerror = () => reject(request.error ?? new AssetPreviewQuarantineError("STORAGE_TRANSACTION", `Preview quarantine lookup failed for ${id}`));
});
}
function receiptId(assetId: string): string {
return `${ASSET_PREVIEW_QUARANTINE_PREFIX}${assetId}`;
}
function parseReceipt(value: unknown, asset: AssetCatalogV2EntryIR, projectId: string, revision: number): AssetPreviewQuarantineReceiptIR | null {
const digest = /^[a-f0-9]{64}$/;
if (!record(value) || value.schemaVersion !== ASSET_PREVIEW_QUARANTINE_SCHEMA || value.id !== receiptId(asset.assetId) ||
value.task !== "M12-02F" || value.status !== "QUARANTINED" || value.projectId !== projectId ||
value.assetId !== asset.assetId || value.catalogRevision !== revision || value.code !== "ASSET_SOURCE_HASH_MISMATCH" ||
!record(value.expected) || !asset.preview || stableJSON(value.expected) !== stableJSON(asset.preview) ||
(value.actualByteLength !== null && (!Number.isSafeInteger(value.actualByteLength) || Number(value.actualByteLength) < 0)) ||
(value.actualSha256 !== null && (typeof value.actualSha256 !== "string" || !digest.test(value.actualSha256))) ||
(value.quarantinePath !== null && (typeof value.quarantinePath !== "string" || value.quarantinePath.length === 0))) {
return null;
}
return value as unknown as AssetPreviewQuarantineReceiptIR;
}
async function directory(root: FileSystemDirectoryHandle, path: string, create = false): Promise<FileSystemDirectoryHandle> {
let current = root;
for (const segment of path.split("/")) current = await current.getDirectoryHandle(segment, { create });
return current;
}
async function readPayload(projectId: string, sha256: string, storage?: StorageManager): Promise<ArrayBuffer | null> {
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
if (!manager?.getDirectory) throw new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "OPFS is unavailable for preview inspection");
try {
const layout = projectLayout(projectId);
const source = await directory(await manager.getDirectory(), `${layout.assetsPath}/sha256/${sha256.slice(0, 2)}`);
return (await (await source.getFileHandle(sha256)).getFile()).arrayBuffer();
}
catch (error) {
if (error instanceof DOMException && error.name === "NotFoundError") return null;
throw error;
}
}
async function quarantinePayload(
projectId: string,
expectedSha256: string,
data: ArrayBuffer | null,
actualSha256: string | null,
storage?: StorageManager,
): Promise<string | null> {
if (!data || !actualSha256) return null;
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
if (!manager?.getDirectory) throw new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "OPFS is unavailable for preview quarantine");
const layout = projectLayout(projectId);
const root = await manager.getDirectory();
const source = await directory(root, `${layout.assetsPath}/sha256/${expectedSha256.slice(0, 2)}`);
const quarantine = await directory(root, `${layout.assetsPath}/quarantine`, true);
const name = `${expectedSha256}.${actualSha256.slice(0, 12)}.corrupt`;
const handle = await quarantine.getFileHandle(name, { create: true });
const writable = await handle.createWritable();
await writable.write(data);
await writable.close();
const copied = await (await handle.getFile()).arrayBuffer();
if (copied.byteLength !== data.byteLength || await sha256Bytes(copied) !== actualSha256) {
await quarantine.removeEntry(name).catch(() => undefined);
throw new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Preview quarantine copy verification failed");
}
await source.removeEntry(expectedSha256);
return `${layout.assetsPath}/quarantine/${name}`;
}
export async function inspectAssetPreviewWithQuarantine(
database: IDBDatabase,
projectId: string,
assetId: string,
options: { settingStore?: string; storage?: StorageManager } = {},
): Promise<AssetPreviewInspectionIR> {
const settingStore = options.settingStore ?? "setting";
if (!database.objectStoreNames.contains(settingStore)) {
throw new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Catalog setting store is unavailable");
}
const readTransaction = database.transaction(settingStore, "readonly");
const store = readTransaction.objectStore(settingStore);
const [catalogRow, quarantineRow] = await Promise.all([
readRow(store, ASSET_CATALOG_INDEX_V2_ID),
readRow(store, receiptId(assetId)),
]);
await transactionComplete(readTransaction, () => undefined);
if (!catalogRow) throw new AssetPreviewQuarantineError("ASSET_MANIFEST_INVALID", "Catalog v2 index is missing");
const manifest = await parseAssetCatalogManifestV2(catalogRow.value);
const asset = manifest.assets.find((candidate) => candidate.assetId === assetId);
if (!asset) throw new AssetPreviewQuarantineError("ASSET_MANIFEST_INVALID", "Preview asset is not in the catalog");
if (!asset.preview) throw new AssetPreviewQuarantineError("ASSET_MANIFEST_INVALID", "Catalog asset has no preview reference");
const existingReceipt = parseReceipt(quarantineRow, asset, projectId, manifest.revision);
if (existingReceipt) return { status: "QUARANTINED", asset, preview: null, data: null, receipt: existingReceipt };
const data = await readPayload(projectId, asset.preview.sha256, options.storage);
const actualSha256 = data ? await sha256Bytes(data) : null;
if (data && data.byteLength === asset.preview.byteLength && actualSha256 === asset.preview.sha256) {
return { status: "READY", asset, preview: asset.preview, data, receipt: null };
}
const quarantinePath = await quarantinePayload(projectId, asset.preview.sha256, data, actualSha256, options.storage);
const receipt: AssetPreviewQuarantineReceiptIR = {
schemaVersion: ASSET_PREVIEW_QUARANTINE_SCHEMA,
id: receiptId(asset.assetId),
task: "M12-02F",
status: "QUARANTINED",
projectId,
assetId: asset.assetId,
catalogRevision: manifest.revision,
expected: asset.preview,
actualByteLength: data?.byteLength ?? null,
actualSha256,
code: "ASSET_SOURCE_HASH_MISMATCH",
quarantinePath,
};
let failure: Error | undefined;
const transaction = database.transaction(settingStore, "readwrite");
const completion = transactionComplete(transaction, () => failure);
const writeStore = transaction.objectStore(settingStore);
const request = writeStore.get(ASSET_CATALOG_INDEX_V2_ID);
request.onerror = () => {
failure = request.error ?? new AssetPreviewQuarantineError("STORAGE_TRANSACTION", "Catalog recheck failed during preview quarantine");
};
request.onsuccess = () => {
const current = request.result as StoredRow | undefined;
if (!current || stableJSON(current.value) !== stableJSON(catalogRow.value)) {
failure = new AssetPreviewQuarantineError("REVISION_CONFLICT", "Catalog changed while corrupt preview was quarantined");
transaction.abort();
return;
}
writeStore.put(receipt);
};
await completion;
return { status: "QUARANTINED", asset, preview: null, data: null, receipt };
}

View File

@@ -0,0 +1,184 @@
import {
parseAssetCatalogManifestV2,
type AssetCatalogManifestV2IR,
type AssetCatalogV2EntryIR,
type AssetCatalogV2PreviewIR,
} from "../../../protocol/asset-catalog-v2";
import type { ErrorCode } from "../../../protocol/error";
import { ASSET_CATALOG_INDEX_V2_ID, type AssetCatalogIndexRowV2 } from "./asset-catalog-indexeddb";
import { ASSET_PREVIEW_CATALOG_HEAD_ID } from "./asset-preview-opfs-commit";
import { deleteContentAsset } from "./opfs-files";
export const ASSET_PREVIEW_REFERENCE_GC_PREFIX = "asset-preview:reference-gc:v1:" as const;
export interface AssetPreviewReferenceGCReceiptIR {
schemaVersion: 1;
id: string;
task: "M12-02G";
status: "RETAINED" | "COLLECTED";
projectId: string;
assetId: string;
baseRevision: number;
committedRevision: number;
baseManifestSha256: string;
committedManifestSha256: string;
removedPreview: AssetCatalogV2PreviewIR;
remainingProjectReferences: number;
payloadReclaimed: boolean;
}
export interface AssetPreviewReferenceGCResultIR {
manifest: AssetCatalogManifestV2IR;
asset: AssetCatalogV2EntryIR;
receipt: AssetPreviewReferenceGCReceiptIR;
}
export class AssetPreviewReferenceGCError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string, options?: ErrorOptions) {
super(`${code}: ${message}`, options);
this.name = "AssetPreviewReferenceGCError";
this.code = code;
}
}
interface StoredRow {
id: string;
value?: unknown;
[key: string]: unknown;
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
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 sha256(value: unknown): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(stableJSON(value)));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function receiptId(assetId: string): string {
return `${ASSET_PREVIEW_REFERENCE_GC_PREFIX}${assetId}`;
}
function readRow(store: IDBObjectStore, id: string): Promise<StoredRow | undefined> {
return new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result as StoredRow | undefined);
request.onerror = () => reject(request.error ?? new AssetPreviewReferenceGCError("STORAGE_TRANSACTION", `Preview reference lookup failed for ${id}`));
});
}
function transactionComplete(transaction: IDBTransaction, failure: () => Error | undefined): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(failure() ?? transaction.error ?? new AssetPreviewReferenceGCError("STORAGE_TRANSACTION", "Preview reference transaction failed"));
transaction.onabort = () => reject(failure() ?? transaction.error ?? new AssetPreviewReferenceGCError("STORAGE_TRANSACTION", "Preview reference transaction aborted"));
});
}
async function loadCatalog(database: IDBDatabase, settingStore: string): Promise<{ row: StoredRow; manifest: AssetCatalogManifestV2IR }> {
if (!database.objectStoreNames.contains(settingStore)) {
throw new AssetPreviewReferenceGCError("STORAGE_TRANSACTION", "Catalog setting store is unavailable");
}
const transaction = database.transaction(settingStore, "readonly");
const row = await readRow(transaction.objectStore(settingStore), ASSET_CATALOG_INDEX_V2_ID);
await transactionComplete(transaction, () => undefined);
if (!row) throw new AssetPreviewReferenceGCError("ASSET_MANIFEST_INVALID", "Catalog v2 index is missing");
return { row, manifest: await parseAssetCatalogManifestV2(row.value) };
}
export async function removeAssetPreviewReference(
database: IDBDatabase,
projectId: string,
expectedRevision: number,
assetId: string,
options: { settingStore?: string; storage?: StorageManager } = {},
): Promise<AssetPreviewReferenceGCResultIR> {
const settingStore = options.settingStore ?? "setting";
const before = await loadCatalog(database, settingStore);
if (before.manifest.revision !== expectedRevision) {
throw new AssetPreviewReferenceGCError("REVISION_CONFLICT", "Catalog revision changed before preview reference removal");
}
const assetIndex = before.manifest.assets.findIndex((asset) => asset.assetId === assetId);
const sourceAsset = before.manifest.assets[assetIndex];
if (assetIndex === -1 || !sourceAsset?.preview) {
throw new AssetPreviewReferenceGCError("ASSET_MANIFEST_INVALID", "Catalog asset has no removable preview reference");
}
if (expectedRevision === Number.MAX_SAFE_INTEGER) {
throw new AssetPreviewReferenceGCError("ASSET_BUDGET_EXCEEDED", "Catalog revision cannot advance safely");
}
const removedPreview = sourceAsset.preview;
const candidate = await parseAssetCatalogManifestV2({
...before.manifest,
revision: expectedRevision + 1,
assets: before.manifest.assets.map((asset, index) => index === assetIndex ? { ...asset, preview: null } : asset),
});
const remainingProjectReferences = candidate.assets.filter((asset) => asset.preview?.sha256 === removedPreview.sha256).length;
const baseManifestSha256 = await sha256(before.manifest);
const committedManifestSha256 = await sha256(candidate);
const pendingReceipt: AssetPreviewReferenceGCReceiptIR = {
schemaVersion: 1,
id: receiptId(assetId),
task: "M12-02G",
status: "RETAINED",
projectId,
assetId,
baseRevision: expectedRevision,
committedRevision: candidate.revision,
baseManifestSha256,
committedManifestSha256,
removedPreview,
remainingProjectReferences,
payloadReclaimed: false,
};
let commitFailure: Error | undefined;
const transaction = database.transaction(settingStore, "readwrite");
const completion = transactionComplete(transaction, () => commitFailure);
const store = transaction.objectStore(settingStore);
const request = store.get(ASSET_CATALOG_INDEX_V2_ID);
request.onerror = () => {
commitFailure = request.error ?? new AssetPreviewReferenceGCError("STORAGE_TRANSACTION", "Catalog recheck failed during preview reference removal");
};
request.onsuccess = () => {
const current = request.result as StoredRow | undefined;
if (!current || stableJSON(current.value) !== stableJSON(before.row.value)) {
commitFailure = new AssetPreviewReferenceGCError("REVISION_CONFLICT", "Catalog changed while preview reference removal was prepared");
transaction.abort();
return;
}
store.put({ id: ASSET_CATALOG_INDEX_V2_ID, value: candidate } satisfies AssetCatalogIndexRowV2);
store.delete(ASSET_PREVIEW_CATALOG_HEAD_ID);
store.put(pendingReceipt);
};
await completion;
const payloadReclaimed = remainingProjectReferences === 0;
if (payloadReclaimed) {
try {
await deleteContentAsset(projectId, removedPreview.sha256, options.storage);
}
catch (error) {
throw new AssetPreviewReferenceGCError("STORAGE_TRANSACTION", "Preview reference was removed but payload reclamation failed", { cause: error });
}
}
const receipt: AssetPreviewReferenceGCReceiptIR = {
...pendingReceipt,
status: payloadReclaimed ? "COLLECTED" : "RETAINED",
payloadReclaimed,
};
const receiptTransaction = database.transaction(settingStore, "readwrite");
receiptTransaction.objectStore(settingStore).put(receipt);
await transactionComplete(receiptTransaction, () => undefined);
return { manifest: candidate, asset: candidate.assets[assetIndex], receipt };
}