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 { 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 { 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 { 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 { 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 { 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 { 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>; 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 { 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 }; }