import type { MutableRefObject } from "react"; import { openComposition, type Composition } from "@hyperframes/sdk"; import type { EditHistoryKind } from "./editHistory"; import { hashContent, markSelfWrite } from "../hooks/sdkSelfWriteRegistry"; import { trackStudioEvent } from "./studioTelemetry"; import { serializeStudioFileMutation } from "./studioFileMutationCoordinator"; export type CutoverResult = | { status: "declined"; reason: string } | { status: "committed"; version: string } | { status: "failed"; error: Error }; export interface SdkSessionPublication { candidate: Composition; expectedSession: Composition; targetPath: string; } export type SdkSessionPublicationResult = | "published" | "rejected-active-target" | "rejected-inactive-target"; export type PublishSdkSession = (publication: SdkSessionPublication) => SdkSessionPublicationResult; export interface CutoverDeps { editHistory: { recordEdit: (entry: { label: string; kind: EditHistoryKind; coalesceKey?: string; coalesceMs?: number; files: Record; }) => Promise; }; /** * Must be bound to one project. Its identity plus path scopes the shared * mutation queue used by every whole-file writer in that project. */ writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise; reloadPreview: () => void; domEditSaveTimestampRef: MutableRefObject; refresh?: (after: string) => void; compositionPath?: string | null; readProjectFile?: (path: string) => Promise; /** * Takes ownership only when it returns `published`. Rejection identifies * whether target-sensitive preview refresh is still safe. MUST NOT throw * after installation; rejection leaves ownership with the caller. */ publishSession?: PublishSdkSession; /** Test seam; production clones with openComposition. */ createCandidateSession?: (serialized: string, live: Composition) => Promise; } export interface CutoverOptions { label?: string; coalesceKey?: string; /** Coalesce window (ms); Infinity folds across a slow round-trip. */ coalesceMs?: number; skipRefresh?: boolean; } interface CandidateEdit { live: Composition; candidate: Composition; serializedBefore: string; after: string; } export function declinedCutover(reason: string): CutoverResult { return { status: "declined", reason }; } export function asCutoverError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } /** Only an explicit decline may enter the legacy mutation backend. */ export function cutoverCommittedOrThrow(result: CutoverResult): boolean { if (result.status === "failed") throw result.error; return result.status === "committed"; } /** A supplied authoritative reader fails closed; only its absence uses the snapshot fallback. */ async function captureOnDiskBefore( deps: CutoverDeps, targetPath: string, serializedFallback: string, ): Promise { if (!deps.readProjectFile) return serializedFallback; return deps.readProjectFile(targetPath); } function disposeCandidate(candidate: Composition | undefined, live: Composition): void { if (candidate && candidate !== live) candidate.dispose(); } function createCandidateSession( serializedBefore: string, live: Composition, deps: CutoverDeps, ): Promise { return deps.createCandidateSession ? deps.createCandidateSession(serializedBefore, live) : openComposition(serializedBefore, { history: false }); } function candidatePublisherAvailable( candidate: Composition, live: Composition, deps: CutoverDeps, ): boolean { return candidate === live || deps.publishSession !== undefined; } async function buildCandidateEdit( live: Composition, deps: CutoverDeps, mutate: (candidate: Composition) => void, sourceSnapshot?: string, ): Promise { let candidate: Composition | undefined; try { const serializedBefore = sourceSnapshot ?? live.serialize(); candidate = await createCandidateSession(serializedBefore, live, deps); candidate.batch(() => mutate(candidate!)); const after = candidate.serialize(); if (after === serializedBefore) { disposeCandidate(candidate, live); return declinedCutover("no_change"); } if (!candidatePublisherAvailable(candidate, live, deps)) { disposeCandidate(candidate, live); return { status: "failed", error: new Error("SDK candidate publisher is unavailable") }; } return { live, candidate, serializedBefore, after }; } catch (error) { disposeCandidate(candidate, live); return { status: "failed", error: asCutoverError(error) }; } } function isCutoverResult(value: CandidateEdit | CutoverResult): value is CutoverResult { return "status" in value; } async function rollbackWrite( targetPath: string, originalContent: string, expectedCurrentContent: string, deps: CutoverDeps, cause: Error, ): Promise { try { deps.domEditSaveTimestampRef.current = Date.now(); markSelfWrite(targetPath, originalContent); await deps.writeProjectFile(targetPath, originalContent, expectedCurrentContent); return cause; } catch (rollbackError) { return new AggregateError( [cause, asCutoverError(rollbackError)], `SDK edit failed and rollback could not restore ${targetPath}`, ); } } async function writeAndRecord( after: string, targetPath: string, originalContent: string, deps: CutoverDeps, options?: CutoverOptions, ): Promise { deps.domEditSaveTimestampRef.current = Date.now(); markSelfWrite(targetPath, after); try { await deps.writeProjectFile(targetPath, after, originalContent); } catch (error) { return asCutoverError(error); } try { await deps.editHistory.recordEdit({ label: options?.label ?? "Edit layer", kind: "manual", ...(options?.coalesceKey ? { coalesceKey: options.coalesceKey } : {}), ...(options?.coalesceMs != null ? { coalesceMs: options.coalesceMs } : {}), files: { [targetPath]: { before: originalContent, after } }, }); return null; } catch (error) { return rollbackWrite(targetPath, originalContent, after, deps, asCutoverError(error)); } } function refreshCommittedEdit(after: string, deps: CutoverDeps, options?: CutoverOptions): void { try { if (deps.refresh) deps.refresh(after); else if (!options?.skipRefresh) deps.reloadPreview(); } catch (error) { trackStudioEvent("sdk_cutover_refresh_failed", { error: asCutoverError(error).message }); } } async function commitCandidateEdit( edit: CandidateEdit, targetPath: string, originalContent: string, deps: CutoverDeps, options?: CutoverOptions, ): Promise { const writeError = await writeAndRecord(edit.after, targetPath, originalContent, deps, options); if (writeError) { if (edit.candidate !== edit.live) edit.candidate.dispose(); return { status: "failed", error: writeError }; } let refreshTarget = true; try { if (edit.candidate !== edit.live) { const publication = deps.publishSession!({ candidate: edit.candidate, expectedSession: edit.live, targetPath, }); if (publication !== "published") edit.candidate.dispose(); if (publication === "rejected-inactive-target") refreshTarget = false; } } catch (error) { // Persistence and history are already committed. A publisher can throw // after installing the candidate, so rolling back disk or disposing the // candidate here can make all three authorities disagree (or dispose the // now-live session). Production publishers are non-throwing; keep the // durable commit authoritative and surface this post-commit fault. trackStudioEvent("sdk_cutover_publish_failed", { path: targetPath, error: asCutoverError(error).message, }); } if (refreshTarget) refreshCommittedEdit(edit.after, deps, options); return { status: "committed", version: hashContent(edit.after) }; } export async function persistSdkCandidateMutation( live: Composition, targetPath: string, originalContent: string, deps: CutoverDeps, mutate: (candidate: Composition) => void, options?: CutoverOptions, sourceSnapshot?: string, ): Promise { return serializeStudioFileMutation( deps.writeProjectFile, targetPath, async (): Promise => { // Re-read only after acquiring the path queue. This makes each candidate // clone the latest committed bytes, even when React has not yet re-rendered // and the caller still holds the preceding live-session object. const serializedFallback = sourceSnapshot ?? originalContent; let onDiskBefore: string; try { onDiskBefore = await captureOnDiskBefore(deps, targetPath, serializedFallback); } catch (error) { return { status: "failed", error: asCutoverError(error) }; } // Preserve the live-session path for adapters without a reader (notably // isolated consumers/tests). Production Studio supplies a reader so // queued edits always clone the latest durable source. const candidateSource = deps.readProjectFile ? onDiskBefore : sourceSnapshot; const candidate = await buildCandidateEdit(live, deps, mutate, candidateSource); if (isCutoverResult(candidate)) return candidate; return commitCandidateEdit(candidate, targetPath, onDiskBefore, deps, options); }, ); } /** Transactional writer for non-SDK islands and throwaway composition sessions. */ export async function persistSdkSerialize( buildAfter: (onDiskBefore: string) => string | Promise, targetPath: string, originalContent: string, deps: CutoverDeps, options?: CutoverOptions, ): Promise { await serializeStudioFileMutation(deps.writeProjectFile, targetPath, async () => { const onDiskBefore = await captureOnDiskBefore(deps, targetPath, originalContent); const after = await buildAfter(onDiskBefore); if (after === onDiskBefore) return; const error = await writeAndRecord(after, targetPath, onDiskBefore, deps, options); if (error) throw error; refreshCommittedEdit(after, deps, options); }); }