Advance M8-M11 parity workflows
This commit is contained in:
@@ -1,19 +1,59 @@
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import type { StorageAssetListResult, StorageAssetPutResult, StorageAssetReadResult, StorageBudgetResult, StorageInfoResult, StorageLODManifestListResult, StorageLODManifestResult, StorageLODPruneResult, StorageLODReadResult, StorageLODResult, StorageOperationListResult, StorageOperationPruneResult, StorageOperationResult, StorageProjectCleanupResult, StorageProjectReadResult, StorageProjectResult, StorageRecoveryResult, StorageRecentProjectsResult, StorageRequest, StorageResponse, StorageSaveResult, StorageSimulationCacheFrameReadResult, StorageSimulationCacheListResult, StorageSimulationCachePlaybackReadyResult, StorageSimulationCachePlaybackReleaseResult, StorageSimulationCachePruneResult, StorageSimulationCacheReadResult, StorageSimulationCacheResult, StorageSmokeResult, StorageSnapshotListResult, StorageSnapshotReadResult, StorageSnapshotResult } from "../../../protocol/storage";
|
||||
import type { LODCacheRecord } from "../../../protocol/lod";
|
||||
import type { SimulationCacheManifestIR } from "../../../protocol/simulation-cache";
|
||||
import type { RecentProjectRecord } from "../../../protocol/recent-projects";
|
||||
import { createWorkerFault, type WorkerFault } from "../../../protocol/worker-fault";
|
||||
import type { TexturePaintTileBindingRequestIR, TexturePaintTileBindingResultIR, TexturePaintTileCommitIR, TexturePaintTileCommitResultIR } from "../../../protocol/texture-paint-asset";
|
||||
|
||||
export interface StorageClientOptions {
|
||||
workerFactory?: () => Worker;
|
||||
onWorkerFault?: (fault: WorkerFault) => void;
|
||||
}
|
||||
|
||||
const defaultWorkerFactory = () => new Worker(new URL("../workers/storage.worker.ts", import.meta.url), { type: "module" });
|
||||
|
||||
export class StorageClient {
|
||||
private readonly worker: Worker;
|
||||
private readonly workerFactory: () => Worker;
|
||||
private readonly onWorkerFault?: (fault: WorkerFault) => void;
|
||||
private worker: Worker | null = null;
|
||||
private workerFaulted = false;
|
||||
private lastWorkerFault: WorkerFault | null = null;
|
||||
private counter = 0;
|
||||
private readonly pending = new Map<string, { resolve: (result: StorageResponse["result"]) => void; reject: (error: Error) => void; timeout: number }>();
|
||||
private readonly pending = new Map<string, { resolve: (result: StorageResponse["result"]) => void; reject: (error: Error) => void; timeout: number; cleanupAbort?: () => void }>();
|
||||
|
||||
constructor() {
|
||||
this.worker = new Worker(new URL("../workers/storage.worker.ts", import.meta.url), { type: "module" });
|
||||
this.worker.onmessage = (event: MessageEvent<StorageResponse>) => {
|
||||
constructor(options: StorageClientOptions = {}) {
|
||||
this.workerFactory = options.workerFactory ?? defaultWorkerFactory;
|
||||
this.onWorkerFault = options.onWorkerFault;
|
||||
this.start();
|
||||
}
|
||||
|
||||
async restart(): Promise<StorageInfoResult> {
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.workerFaulted = false;
|
||||
this.lastWorkerFault = null;
|
||||
return this.info();
|
||||
}
|
||||
|
||||
crashForTest(): void {
|
||||
this.worker?.postMessage({
|
||||
requestId: `storage-crash-${++this.counter}`,
|
||||
command: { type: "crashForTest" },
|
||||
} satisfies StorageRequest);
|
||||
}
|
||||
|
||||
private start(): Worker {
|
||||
if (this.workerFaulted) {
|
||||
throw this.lastWorkerFault ? this.toError(this.lastWorkerFault) : new Error("StorageWorker 需要重启");
|
||||
}
|
||||
if (this.worker) return this.worker;
|
||||
const worker = this.workerFactory();
|
||||
worker.onmessage = (event: MessageEvent<StorageResponse>) => {
|
||||
const pending = this.pending.get(event.data.requestId);
|
||||
if (!pending) return;
|
||||
this.pending.delete(event.data.requestId);
|
||||
window.clearTimeout(pending.timeout);
|
||||
pending.cleanupAbort?.();
|
||||
if (event.data.ok && event.data.result) pending.resolve(event.data.result);
|
||||
else {
|
||||
const error = new Error(event.data.error ?? "StorageWorker failed") as Error & { code?: string };
|
||||
@@ -21,14 +61,10 @@ export class StorageClient {
|
||||
pending.reject(error);
|
||||
}
|
||||
};
|
||||
this.worker.onerror = (event) => {
|
||||
const error = new Error(event.message || "StorageWorker error");
|
||||
for (const pending of this.pending.values()) {
|
||||
window.clearTimeout(pending.timeout);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
};
|
||||
worker.onerror = (event) => this.handleWorkerFault(event.message || "StorageWorker error");
|
||||
worker.onmessageerror = () => this.handleWorkerFault("StorageWorker 消息无法解析");
|
||||
this.worker = worker;
|
||||
return worker;
|
||||
}
|
||||
|
||||
smoke(): Promise<StorageSmokeResult> {
|
||||
@@ -39,10 +75,30 @@ export class StorageClient {
|
||||
return this.request({ type: "info" }) as Promise<StorageInfoResult>;
|
||||
}
|
||||
|
||||
getBudget(projectId: string): Promise<StorageBudgetResult> {
|
||||
return this.request({ type: "getBudget", projectId }) as Promise<StorageBudgetResult>;
|
||||
}
|
||||
|
||||
listRecentProjects(): Promise<StorageRecentProjectsResult> {
|
||||
return this.request({ type: "listRecentProjects" }) as Promise<StorageRecentProjectsResult>;
|
||||
}
|
||||
|
||||
touchRecentProject(project: RecentProjectRecord): Promise<StorageRecentProjectsResult> {
|
||||
return this.request({ type: "touchRecentProject", project }) as Promise<StorageRecentProjectsResult>;
|
||||
}
|
||||
|
||||
removeRecentProject(projectId: string): Promise<StorageRecentProjectsResult> {
|
||||
return this.request({ type: "removeRecentProject", projectId }) as Promise<StorageRecentProjectsResult>;
|
||||
}
|
||||
|
||||
ensureProject(projectId: string): Promise<StorageProjectResult> {
|
||||
return this.request({ type: "ensureProject", projectId }) as Promise<StorageProjectResult>;
|
||||
}
|
||||
|
||||
cleanupProject(projectId: string): Promise<StorageProjectCleanupResult> {
|
||||
return this.request({ type: "cleanupProject", projectId }) as Promise<StorageProjectCleanupResult>;
|
||||
}
|
||||
|
||||
saveProject(projectId: string, revision: number, buffer: ArrayBuffer, faultAt?: "after-stage" | "after-scene-commit" | "before-metadata-commit" | "quota"): Promise<StorageSaveResult> {
|
||||
return this.request({ type: "saveProject", projectId, revision, buffer, faultAt }, [buffer]) as Promise<StorageSaveResult>;
|
||||
}
|
||||
@@ -91,6 +147,14 @@ export class StorageClient {
|
||||
return this.request({ type: "listAssets", projectId }) as Promise<StorageAssetListResult>;
|
||||
}
|
||||
|
||||
commitTexturePaintTile(commit: TexturePaintTileCommitIR): Promise<TexturePaintTileCommitResultIR> {
|
||||
return this.request({ type: "commitTexturePaintTile", commit }) as Promise<TexturePaintTileCommitResultIR>;
|
||||
}
|
||||
|
||||
readTexturePaintTileBinding(request: TexturePaintTileBindingRequestIR): Promise<TexturePaintTileBindingResultIR> {
|
||||
return this.request({ type: "readTexturePaintTileBinding", request }) as Promise<TexturePaintTileBindingResultIR>;
|
||||
}
|
||||
|
||||
saveLOD(projectId: string, cacheKey: string, data: ArrayBuffer): Promise<StorageLODResult> {
|
||||
return this.request({ type: "saveLOD", projectId, cacheKey, data }, [data]) as Promise<StorageLODResult>;
|
||||
}
|
||||
@@ -119,45 +183,111 @@ export class StorageClient {
|
||||
return this.request({ type: "pruneLOD", projectId, maxBytes }) as Promise<StorageLODPruneResult>;
|
||||
}
|
||||
|
||||
putSimulationCache(projectId: string, manifest: SimulationCacheManifestIR, data: ArrayBuffer): Promise<StorageSimulationCacheResult> {
|
||||
return this.request({ type: "putSimulationCache", projectId, manifest, data }, [data]) as Promise<StorageSimulationCacheResult>;
|
||||
putSimulationCache(projectId: string, manifest: SimulationCacheManifestIR, data: ArrayBuffer, signal?: AbortSignal): Promise<StorageSimulationCacheResult> {
|
||||
return this.request({ type: "putSimulationCache", projectId, manifest, data }, [data], signal) as Promise<StorageSimulationCacheResult>;
|
||||
}
|
||||
|
||||
readSimulationCache(projectId: string, cacheKey: string): Promise<StorageSimulationCacheReadResult> {
|
||||
return this.request({ type: "readSimulationCache", projectId, cacheKey }) as Promise<StorageSimulationCacheReadResult>;
|
||||
prepareSimulationCachePlayback(projectId: string, cacheKey: string, signal?: AbortSignal): Promise<StorageSimulationCachePlaybackReadyResult> {
|
||||
return this.request({ type: "prepareSimulationCachePlayback", projectId, cacheKey }, [], signal) as Promise<StorageSimulationCachePlaybackReadyResult>;
|
||||
}
|
||||
|
||||
readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number): Promise<StorageSimulationCacheFrameReadResult> {
|
||||
return this.request({ type: "readSimulationCacheFrame", projectId, cacheKey, frame }) as Promise<StorageSimulationCacheFrameReadResult>;
|
||||
releaseSimulationCachePlayback(projectId: string, cacheKey: string): Promise<StorageSimulationCachePlaybackReleaseResult> {
|
||||
return this.request({ type: "releaseSimulationCachePlayback", projectId, cacheKey }) as Promise<StorageSimulationCachePlaybackReleaseResult>;
|
||||
}
|
||||
|
||||
readSimulationCache(projectId: string, cacheKey: string, signal?: AbortSignal): Promise<StorageSimulationCacheReadResult> {
|
||||
return this.request({ type: "readSimulationCache", projectId, cacheKey }, [], signal) as Promise<StorageSimulationCacheReadResult>;
|
||||
}
|
||||
|
||||
readSimulationCacheFrame(projectId: string, cacheKey: string, frame: number, signal?: AbortSignal): Promise<StorageSimulationCacheFrameReadResult> {
|
||||
return this.request({ type: "readSimulationCacheFrame", projectId, cacheKey, frame }, [], signal) as Promise<StorageSimulationCacheFrameReadResult>;
|
||||
}
|
||||
|
||||
listSimulationCaches(projectId: string): Promise<StorageSimulationCacheListResult> {
|
||||
return this.request({ type: "listSimulationCaches", projectId }) as Promise<StorageSimulationCacheListResult>;
|
||||
}
|
||||
|
||||
pruneSimulationCaches(projectId: string, maxBytes: number, protectedCacheKeys: string[] = []): Promise<StorageSimulationCachePruneResult> {
|
||||
return this.request({ type: "pruneSimulationCaches", projectId, maxBytes, protectedCacheKeys }) as Promise<StorageSimulationCachePruneResult>;
|
||||
}
|
||||
|
||||
getPendingRequestCount(): number {
|
||||
return this.pending.size;
|
||||
}
|
||||
|
||||
private request(command: StorageRequest["command"], transfer: Transferable[] = []): Promise<NonNullable<StorageResponse["result"]>> {
|
||||
private request(command: StorageRequest["command"], transfer: Transferable[] = [], signal?: AbortSignal): Promise<NonNullable<StorageResponse["result"]>> {
|
||||
if (signal?.aborted) return Promise.reject(new DOMException("Storage request aborted", "AbortError"));
|
||||
let worker: Worker;
|
||||
try {
|
||||
worker = this.start();
|
||||
}
|
||||
catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const requestId = `storage-${++this.counter}`;
|
||||
const request: StorageRequest = { requestId, command };
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
const pending = this.pending.get(requestId);
|
||||
if (!pending) return;
|
||||
this.pending.delete(requestId);
|
||||
pending.cleanupAbort?.();
|
||||
worker.postMessage({ requestId: `storage-cancel-${++this.counter}`, command: { type: "cancelRequest", targetRequestId: requestId } } satisfies StorageRequest);
|
||||
reject(new Error("StorageWorker timeout"));
|
||||
}, 10_000);
|
||||
this.pending.set(requestId, { resolve: (result) => resolve(result as NonNullable<StorageResponse["result"]>), reject, timeout });
|
||||
this.worker.postMessage(request, transfer);
|
||||
const abort = () => {
|
||||
const pending = this.pending.get(requestId);
|
||||
if (!pending) return;
|
||||
this.pending.delete(requestId);
|
||||
window.clearTimeout(pending.timeout);
|
||||
pending.cleanupAbort?.();
|
||||
worker.postMessage({ requestId: `storage-cancel-${++this.counter}`, command: { type: "cancelRequest", targetRequestId: requestId } } satisfies StorageRequest);
|
||||
pending.reject(new DOMException("Storage request aborted", "AbortError"));
|
||||
};
|
||||
const cleanupAbort = signal ? () => signal.removeEventListener("abort", abort) : undefined;
|
||||
this.pending.set(requestId, { resolve: (result) => resolve(result as NonNullable<StorageResponse["result"]>), reject, timeout, cleanupAbort });
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
worker.postMessage(request, transfer);
|
||||
});
|
||||
}
|
||||
|
||||
private handleWorkerFault(message: string): void {
|
||||
if (this.workerFaulted) return;
|
||||
const fault = createWorkerFault("storage", message);
|
||||
this.workerFaulted = true;
|
||||
this.lastWorkerFault = fault;
|
||||
const worker = this.worker;
|
||||
this.worker = null;
|
||||
const error = this.toError(fault);
|
||||
for (const pending of this.pending.values()) {
|
||||
window.clearTimeout(pending.timeout);
|
||||
pending.cleanupAbort?.();
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
worker?.terminate();
|
||||
this.onWorkerFault?.(fault);
|
||||
}
|
||||
|
||||
private toError(fault: WorkerFault): Error {
|
||||
const error = new Error(fault.error.message) as Error & { code?: string };
|
||||
error.code = fault.error.code;
|
||||
return error;
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
for (const pending of this.pending.values()) {
|
||||
window.clearTimeout(pending.timeout);
|
||||
pending.cleanupAbort?.();
|
||||
pending.reject(new Error("StorageClient terminated"));
|
||||
}
|
||||
this.pending.clear();
|
||||
this.worker.terminate();
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.workerFaulted = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const STORAGE_DATABASE_NAME = "blender-web-metadata";
|
||||
export const STORAGE_SCHEMA_VERSION = 6;
|
||||
export const STORAGE_SCHEMA_VERSION = 7;
|
||||
|
||||
export const STORAGE_STORES = ["smoke", "project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "migration"] as const;
|
||||
export const STORAGE_STORES = ["smoke", "project", "asset", "snapshot", "operation_log", "operation_quarantine", "setting", "lod_manifest", "simulation_manifest", "simulation_quarantine", "migration"] as const;
|
||||
|
||||
export function upgradeStorageSchema(db: IDBDatabase, transaction: IDBTransaction, oldVersion: number): void {
|
||||
if (oldVersion < 1 && !db.objectStoreNames.contains("smoke")) {
|
||||
@@ -31,4 +31,8 @@ export function upgradeStorageSchema(db: IDBDatabase, transaction: IDBTransactio
|
||||
if (!db.objectStoreNames.contains("simulation_manifest")) db.createObjectStore("simulation_manifest", { keyPath: "id" });
|
||||
transaction.objectStore("migration").put({ id: "schema-6", version: 6, appliedAt: new Date().toISOString() });
|
||||
}
|
||||
if (oldVersion < 7) {
|
||||
if (!db.objectStoreNames.contains("simulation_quarantine")) db.createObjectStore("simulation_quarantine", { keyPath: "id" });
|
||||
transaction.objectStore("migration").put({ id: "schema-7", version: 7, appliedAt: new Date().toISOString() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,32 @@ export async function ensureProjectLayout(projectId: string, storage: StorageMan
|
||||
return layout;
|
||||
}
|
||||
|
||||
export async function measureProjectVDBBytes(projectId: string, storage: StorageManager | undefined = typeof navigator === "undefined" ? undefined : navigator.storage): Promise<number> {
|
||||
const layout = projectLayout(projectId);
|
||||
const manager = storage as OpfsStorage | undefined;
|
||||
if (!manager?.getDirectory) return 0;
|
||||
try {
|
||||
let current = await manager.getDirectory();
|
||||
for (const segment of `${layout.cachePath}/vdb`.split("/")) current = await current.getDirectoryHandle(segment);
|
||||
let total = 0;
|
||||
const entries = (current as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
|
||||
for await (const [, handle] of entries) {
|
||||
if (handle.kind !== "directory") continue;
|
||||
const bundle = handle as FileSystemDirectoryHandle;
|
||||
const files = (bundle as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
|
||||
for await (const [name, fileHandle] of files) {
|
||||
if (fileHandle.kind !== "file" || !name.endsWith(".chunk")) continue;
|
||||
total += (await (fileHandle as FileSystemFileHandle).getFile()).size;
|
||||
}
|
||||
}
|
||||
return Number.isSafeInteger(total) ? total : 0;
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof DOMException && error.name === "NotFoundError") return 0;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeLodCache(projectId: string, cacheKey: string, data: ArrayBuffer, storage?: StorageManager): Promise<OpfsProjectLayout> {
|
||||
const layout = await ensureProjectLayout(projectId, storage);
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(cacheKey)) throw new Error("Invalid LOD cache key");
|
||||
@@ -310,6 +336,66 @@ export async function readProjectBlend(projectId: string, storage?: StorageManag
|
||||
return (await file.getFile()).arrayBuffer();
|
||||
}
|
||||
|
||||
function snapshotFileName(revision: number): string {
|
||||
if (!Number.isInteger(revision) || revision < 0) throw new Error("Invalid snapshot revision");
|
||||
return `${revision}.blend`;
|
||||
}
|
||||
|
||||
export async function writeProjectSnapshot(
|
||||
projectId: string,
|
||||
revision: number,
|
||||
data: ArrayBuffer,
|
||||
storage?: StorageManager,
|
||||
): Promise<{ layout: OpfsProjectLayout; path: string }> {
|
||||
if (data.byteLength === 0) throw new Error("Project snapshot buffer is empty");
|
||||
const fileName = snapshotFileName(revision);
|
||||
const layout = await ensureProjectLayout(projectId, storage);
|
||||
const manager = (storage ?? navigator.storage) as OpfsStorage;
|
||||
const root = await manager.getDirectory!();
|
||||
const snapshots = await ensureDirectory(root, layout.snapshotsPath);
|
||||
const stageName = `${revision}.${crypto.randomUUID()}.tmp`;
|
||||
const sha256 = await sha256Hex(data);
|
||||
try {
|
||||
await writeFile(snapshots, stageName, data);
|
||||
if (!await verifyFile(snapshots, stageName, data.byteLength, sha256)) {
|
||||
throw new Error("SNAPSHOT_STAGE_VERIFY_FAILED: staged snapshot does not match its digest");
|
||||
}
|
||||
const stage = await snapshots.getFileHandle(stageName);
|
||||
const move = (stage as FileSystemFileHandle & { move?: (name: string) => Promise<void> }).move;
|
||||
if (move) await move.call(stage, fileName);
|
||||
else {
|
||||
await writeFile(snapshots, fileName, await (await stage.getFile()).arrayBuffer());
|
||||
await removeFile(snapshots, stageName);
|
||||
}
|
||||
if (!await verifyFile(snapshots, fileName, data.byteLength, sha256)) {
|
||||
throw new Error("SNAPSHOT_COMMIT_VERIFY_FAILED: committed snapshot does not match its digest");
|
||||
}
|
||||
return { layout, path: `${layout.snapshotsPath}/${fileName}` };
|
||||
}
|
||||
catch (error) {
|
||||
await removeFile(snapshots, stageName);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readProjectSnapshot(projectId: string, revision: number, storage?: StorageManager): Promise<ArrayBuffer> {
|
||||
const layout = projectLayout(projectId);
|
||||
const manager = (storage ?? navigator.storage) as OpfsStorage;
|
||||
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
|
||||
const root = await manager.getDirectory();
|
||||
const snapshots = await ensureDirectory(root, layout.snapshotsPath);
|
||||
return readFile(snapshots, snapshotFileName(revision));
|
||||
}
|
||||
|
||||
export async function deleteProjectSnapshot(projectId: string, revision: number, storage?: StorageManager): Promise<void> {
|
||||
const layout = projectLayout(projectId);
|
||||
const manager = (storage ?? navigator.storage) as OpfsStorage;
|
||||
if (!manager.getDirectory) throw new Error("OPFS is unavailable");
|
||||
const root = await manager.getDirectory();
|
||||
const snapshots = await ensureDirectory(root, layout.snapshotsPath);
|
||||
await removeFile(snapshots, snapshotFileName(revision));
|
||||
}
|
||||
|
||||
export async function recoverProjectBlend(projectId: string, storage?: StorageManager): Promise<ProjectBlendRecoveryResult> {
|
||||
const layout = await ensureProjectLayout(projectId, storage);
|
||||
const manager = (storage ?? navigator.storage) as OpfsStorage;
|
||||
@@ -377,6 +463,71 @@ export async function writeContentAsset(projectId: string, sha256: string, data:
|
||||
return { layout, path, deduplicated: false };
|
||||
}
|
||||
|
||||
export interface OrphanContentAssetCleanupResult {
|
||||
removed: number;
|
||||
bytes: number;
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
export async function removeOrphanContentAssets(projectId: string, referencedSha256: ReadonlySet<string>, storage?: StorageManager): Promise<OrphanContentAssetCleanupResult> {
|
||||
const layout = projectLayout(projectId);
|
||||
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
|
||||
if (!manager?.getDirectory) return { removed: 0, bytes: 0, paths: [] };
|
||||
const root = await manager.getDirectory();
|
||||
let assets: FileSystemDirectoryHandle;
|
||||
try {
|
||||
let current = root;
|
||||
for (const segment of layout.assetsPath.split("/")) current = await current.getDirectoryHandle(segment);
|
||||
assets = current;
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof DOMException && error.name === "NotFoundError") return { removed: 0, bytes: 0, paths: [] };
|
||||
throw error;
|
||||
}
|
||||
let removed = 0;
|
||||
let bytes = 0;
|
||||
const paths: string[] = [];
|
||||
const shaRoot = await assets.getDirectoryHandle("sha256", { create: false }).catch((error) => {
|
||||
if (error instanceof DOMException && error.name === "NotFoundError") return undefined;
|
||||
throw error;
|
||||
});
|
||||
if (!shaRoot) return { removed, bytes, paths };
|
||||
const prefixes = (shaRoot as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
|
||||
for await (const [prefix, prefixHandle] of prefixes) {
|
||||
if (prefixHandle.kind !== "directory" || !/^[a-f0-9]{2}$/.test(prefix)) continue;
|
||||
const directory = prefixHandle as FileSystemDirectoryHandle;
|
||||
const files = (directory as unknown as { entries: () => AsyncIterable<[string, FileSystemHandle]> }).entries();
|
||||
for await (const [name, fileHandle] of files) {
|
||||
if (fileHandle.kind !== "file") continue;
|
||||
if (/^[a-f0-9]{64}$/.test(name) && referencedSha256.has(name)) continue;
|
||||
const size = (await (fileHandle as FileSystemFileHandle).getFile()).size;
|
||||
await directory.removeEntry(name);
|
||||
removed += 1;
|
||||
bytes += size;
|
||||
paths.push(`${layout.assetsPath}/sha256/${prefix}/${name}`);
|
||||
}
|
||||
}
|
||||
return { removed, bytes, paths };
|
||||
}
|
||||
|
||||
export async function deleteContentAsset(projectId: string, sha256: string, storage?: StorageManager): Promise<void> {
|
||||
const layout = projectLayout(projectId);
|
||||
validateSha256(sha256);
|
||||
const manager = (storage ?? (typeof navigator === "undefined" ? undefined : navigator.storage)) as OpfsStorage | undefined;
|
||||
if (!manager?.getDirectory) return;
|
||||
const root = await manager.getDirectory();
|
||||
try {
|
||||
let current = root;
|
||||
for (const segment of `${layout.assetsPath}/sha256/${sha256.slice(0, 2)}`.split("/")) {
|
||||
current = await current.getDirectoryHandle(segment);
|
||||
}
|
||||
await current.removeEntry(sha256);
|
||||
}
|
||||
catch (error) {
|
||||
if (!(error instanceof DOMException) || error.name !== "NotFoundError") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readContentAsset(projectId: string, sha256: string, storage?: StorageManager): Promise<ArrayBuffer> {
|
||||
const layout = projectLayout(projectId);
|
||||
validateSha256(sha256);
|
||||
|
||||
Reference in New Issue
Block a user