Files
ttstd-infinite-canvas/web/src/services/generation-asset-repository.ts

145 lines
6.9 KiB
TypeScript

export type GenerationAssetRecord = {
id: string;
metadata?: Record<string, unknown>;
};
type AsyncStorageLock = {
request<T>(name: string, callback: () => Promise<T>): Promise<T>;
};
type GenerationStorageLockOptions = {
requireCrossRealmLock?: boolean;
};
const ASSET_STORAGE_LOCK_PREFIX = "infinite-canvas:generation-asset-storage-lock:";
const ARTIFACT_COMMIT_LOCK_PREFIX = "infinite-canvas:generation-artifact-commit-lock:";
const assetStorageTails = new Map<string, Promise<void>>();
const artifactCommitTails = new Map<string, Promise<void>>();
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
}
function isBlobUrl(value: unknown): value is string {
return typeof value === "string" && value.startsWith("blob:");
}
function preserveHydratedPreview<TAsset extends GenerationAssetRecord>(persisted: TAsset, hydrated?: TAsset): TAsset {
if (!hydrated) return persisted;
const durable = persisted as TAsset & { coverUrl?: unknown; data?: Record<string, unknown> };
const current = hydrated as TAsset & { coverUrl?: unknown; data?: Record<string, unknown> };
const durableStorageKey = durable.data?.storageKey;
if (typeof durableStorageKey !== "string" || durableStorageKey !== current.data?.storageKey) return persisted;
const hydratedDataUrl = current.data?.dataUrl;
const hydratedUrl = current.data?.url;
const nextData = durable.data && (isBlobUrl(hydratedDataUrl) || isBlobUrl(hydratedUrl)) ? { ...durable.data } : durable.data;
if (nextData && isBlobUrl(hydratedDataUrl)) nextData.dataUrl = hydratedDataUrl;
if (nextData && isBlobUrl(hydratedUrl)) nextData.url = hydratedUrl;
const hydratedCoverUrl = current.coverUrl;
if (!isBlobUrl(hydratedCoverUrl) && nextData === durable.data) return persisted;
return {
...persisted,
...(isBlobUrl(hydratedCoverUrl) ? { coverUrl: hydratedCoverUrl } : {}),
...(nextData !== durable.data ? { data: nextData } : {}),
} as TAsset;
}
function runWithBrowserStorageLock<T>(scope: string, operation: () => Promise<T>, options: GenerationStorageLockOptions) {
const locks = typeof window !== "undefined" && typeof navigator !== "undefined" ? (navigator.locks as AsyncStorageLock | undefined) : undefined;
if (locks) return locks.request(`${ASSET_STORAGE_LOCK_PREFIX}${scope}`, operation);
if (options.requireCrossRealmLock && typeof window !== "undefined" && typeof document !== "undefined") {
throw new Error("当前浏览器不支持跨标签存储锁,已停止生成素材持久化");
}
return operation();
}
/**
* 串行化同一用户作用域的素材目录写入。
*
* 前一个操作失败不能阻塞后续操作,但当前操作的成功或失败必须原样返回,
* 因此只处理 predecessor 的 rejected 状态,不吞掉当前 `pending` 的错误。
*/
export function withGenerationAssetStorageLock<T>(scope: string, operation: () => Promise<T>, options: GenerationStorageLockOptions = {}): Promise<T> {
const previous = assetStorageTails.get(scope) ?? Promise.resolve();
const pending = previous.then(() => undefined, () => undefined).then(() => runWithBrowserStorageLock(scope, operation, options));
const tail = pending.then(
() => undefined,
() => undefined,
);
assetStorageTails.set(scope, tail);
void tail.finally(() => {
if (assetStorageTails.get(scope) === tail) assetStorageTails.delete(scope);
});
return pending;
}
export function withGenerationArtifactCommitLock<T>(scope: string, operation: () => Promise<T>, options: GenerationStorageLockOptions = {}): Promise<T> {
const previous = artifactCommitTails.get(scope) ?? Promise.resolve();
const pending = previous
.then(() => undefined, () => undefined)
.then(() => {
const locks = typeof window !== "undefined" && typeof navigator !== "undefined" ? (navigator.locks as AsyncStorageLock | undefined) : undefined;
if (locks) return locks.request(`${ARTIFACT_COMMIT_LOCK_PREFIX}${scope}`, operation);
if (options.requireCrossRealmLock && typeof window !== "undefined" && typeof document !== "undefined") {
throw new Error("当前浏览器不支持跨标签存储锁,已停止生成文件提交");
}
return operation();
});
const tail = pending.then(
() => undefined,
() => undefined,
);
artifactCommitTails.set(scope, tail);
void tail.finally(() => {
if (artifactCommitTails.get(scope) === tail) artifactCommitTails.delete(scope);
});
return pending;
}
export async function flushGenerationAssetStorageLocks() {
while (assetStorageTails.size || artifactCommitTails.size) await Promise.all([...assetStorageTails.values(), ...artifactCommitTails.values()]);
}
export async function insertOrReturnGenerationAsset<TAsset extends GenerationAssetRecord>(dependencies: {
storageScope: string;
effectKey: string;
assetId: string;
createAsset: () => TAsset;
updateAssets: (updater: (assets: TAsset[]) => TAsset[]) => void;
readAssets: () => TAsset[];
readPersistedAssets: () => Promise<TAsset[]>;
isAssetDeleted?: () => boolean;
persistAssets: (assets: TAsset[]) => Promise<void>;
requireCrossRealmLock?: boolean;
signal?: AbortSignal;
}): Promise<string> {
return withGenerationAssetStorageLock(
dependencies.storageScope,
async () => {
throwIfAborted(dependencies.signal);
const persistedAssets = await dependencies.readPersistedAssets();
throwIfAborted(dependencies.signal);
if (dependencies.isAssetDeleted?.()) throw new Error("生成素材已被用户删除");
const existing = persistedAssets.find((asset) => asset.metadata?.generationEffectKey === dependencies.effectKey);
if (existing) {
const hydratedExisting = dependencies.readAssets().find((asset) => asset.id === existing.id && asset.metadata?.generationEffectKey === dependencies.effectKey);
dependencies.updateAssets(() => persistedAssets.map((asset) => (asset.id === existing.id ? preserveHydratedPreview(asset, hydratedExisting) : asset)));
return existing.id;
}
if (persistedAssets.some((asset) => asset.id === dependencies.assetId)) {
throw new Error("生成素材幂等键冲突");
}
const assets = [dependencies.createAsset(), ...persistedAssets];
throwIfAborted(dependencies.signal);
dependencies.updateAssets(() => assets);
throwIfAborted(dependencies.signal);
await dependencies.persistAssets(assets);
throwIfAborted(dependencies.signal);
return dependencies.assetId;
},
{ requireCrossRealmLock: dependencies.requireCrossRealmLock },
);
}