263 lines
12 KiB
TypeScript
263 lines
12 KiB
TypeScript
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 };
|
|
}
|