mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
* fix(studio): enforce optimistic file concurrency * fix(studio): harden conditional file writes * fix(studio): honor explicit file preconditions * test(producer): allow zero-ms encode timing
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
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<string, { before: string; after: string }>;
|
|
}) => Promise<void>;
|
|
};
|
|
/**
|
|
* 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<void>;
|
|
reloadPreview: () => void;
|
|
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
refresh?: (after: string) => void;
|
|
compositionPath?: string | null;
|
|
readProjectFile?: (path: string) => Promise<string>;
|
|
/**
|
|
* 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<Composition>;
|
|
}
|
|
|
|
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<string> {
|
|
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<Composition> {
|
|
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<CandidateEdit | CutoverResult> {
|
|
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<Error> {
|
|
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<Error | null> {
|
|
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<CutoverResult> {
|
|
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<CutoverResult> {
|
|
return serializeStudioFileMutation(
|
|
deps.writeProjectFile,
|
|
targetPath,
|
|
async (): Promise<CutoverResult> => {
|
|
// 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<string>,
|
|
targetPath: string,
|
|
originalContent: string,
|
|
deps: CutoverDeps,
|
|
options?: CutoverOptions,
|
|
): Promise<void> {
|
|
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);
|
|
});
|
|
}
|