Advance Blender 5.2 web parity through M12-03D
This commit is contained in:
184
web/app/src/storage/asset-preview-reference-gc.ts
Normal file
184
web/app/src/storage/asset-preview-reference-gc.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user