mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): enforce optimistic file concurrency (#2156)
* 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
This commit is contained in:
@@ -64,7 +64,6 @@ import {
|
||||
} from "./utils/studioUrlState";
|
||||
import { trackStudioSessionStart } from "./telemetry/events";
|
||||
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
|
||||
|
||||
type CanvasRect = { left: number; top: number; width: number; height: number };
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioApp() {
|
||||
@@ -166,6 +165,7 @@ export function StudioApp() {
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile: fileManager.writeProjectFile,
|
||||
observeProjectFileVersion: fileManager.observeProjectFileVersion,
|
||||
recordEdit: editHistory.recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
|
||||
@@ -30,6 +30,7 @@ export function FileManagerProvider({
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
readOptionalProjectFile,
|
||||
observeProjectFileVersion,
|
||||
updateEditingFileContent,
|
||||
revealSourceOffset,
|
||||
openSourceForSelection,
|
||||
@@ -69,6 +70,7 @@ export function FileManagerProvider({
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
readOptionalProjectFile,
|
||||
observeProjectFileVersion,
|
||||
updateEditingFileContent,
|
||||
revealSourceOffset,
|
||||
openSourceForSelection,
|
||||
@@ -102,6 +104,7 @@ export function FileManagerProvider({
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
readOptionalProjectFile,
|
||||
observeProjectFileVersion,
|
||||
updateEditingFileContent,
|
||||
revealSourceOffset,
|
||||
openSourceForSelection,
|
||||
|
||||
@@ -262,7 +262,7 @@ export interface PersistTimelineEditInput {
|
||||
activeCompPath: string | null;
|
||||
label: string;
|
||||
buildPatches: (original: string, target: PatchTarget) => string;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
||||
@@ -308,7 +308,7 @@ export interface PersistTimelineBatchEditInput {
|
||||
activeCompPath: string | null;
|
||||
label: string;
|
||||
changes: PersistTimelineBatchChange[];
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
||||
|
||||
@@ -34,7 +34,7 @@ interface RenderedDomEditCommits {
|
||||
|
||||
interface RenderDomEditCommitsOptions {
|
||||
importedFontAssets?: ImportedFontAsset[];
|
||||
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
||||
writeProjectFile?: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
type FetchHandler = (
|
||||
@@ -1050,6 +1050,46 @@ describe("useDomEditCommits style persist handling", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the patched server content as the custom-font write precondition", async () => {
|
||||
const patchedContent =
|
||||
'<!doctype html><html><head></head><body><div data-hf-id="hf-card">Card</div></body></html>';
|
||||
stubPatchFetch(
|
||||
{ ok: true, changed: true, matched: true, content: patchedContent },
|
||||
patchedContent,
|
||||
);
|
||||
const { iframe, element } = createPreviewElement();
|
||||
const selection = createSelection(element, {
|
||||
textFields: [textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
||||
});
|
||||
const writeProjectFile = vi.fn(async () => {});
|
||||
const rendered = renderDomEditCommits(selection, iframe, { writeProjectFile });
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
await rendered.hook.commitDomTextFields(
|
||||
selection,
|
||||
[textField({ key: "self", value: "Card", source: "self", tagName: "div" })],
|
||||
{
|
||||
importedFont: {
|
||||
family: "Imported",
|
||||
path: "fonts/Imported.woff2",
|
||||
url: "/api/projects/p1/preview/fonts/Imported.woff2",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(writeProjectFile).toHaveBeenCalledWith(
|
||||
"index.html",
|
||||
expect.stringContaining("@font-face"),
|
||||
patchedContent,
|
||||
);
|
||||
expect(rendered.showToast).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rendered.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a rejected patch request (HTTP error) to one toast", async () => {
|
||||
const { rendered, cleanup } = renderStyleCommitWithFetch(async (input) => {
|
||||
const url = requestUrl(input);
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface UseDomEditCommitsParams {
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
||||
fileTree: string[];
|
||||
@@ -245,7 +245,7 @@ export function useDomEditCommits({
|
||||
const preparedContent = options.prepareContent(patchedContent, targetPath);
|
||||
if (preparedContent !== patchedContent) {
|
||||
try {
|
||||
await writeProjectFile(targetPath, preparedContent);
|
||||
await writeProjectFile(targetPath, preparedContent, patchedContent);
|
||||
finalContent = preparedContent;
|
||||
} catch (error) {
|
||||
// The patch above already landed on disk — only the prepareContent
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface UseDomEditSessionParams {
|
||||
refreshPreviewDocumentVersion: () => void;
|
||||
queueDomEditSave: <T>(save: () => Promise<T>) => Promise<T>;
|
||||
readProjectFile: (path: string) => Promise<string>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
updateEditingFileContent: (path: string, content: string) => void;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
||||
|
||||
@@ -14,7 +14,7 @@ interface UseEditorSaveOptions {
|
||||
editingPathRef: React.RefObject<string | undefined>;
|
||||
projectIdRef: React.RefObject<string | null>;
|
||||
readProjectFile: (path: string) => Promise<string>;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
||||
|
||||
@@ -38,14 +38,17 @@ describe("useFileManager project ownership", () => {
|
||||
resolveProjectARead = resolve;
|
||||
});
|
||||
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
|
||||
if (url.endsWith("/files/missing.html") && !init?.method) {
|
||||
return Promise.resolve({ ok: false, status: 404 } as Response);
|
||||
}
|
||||
if (url.includes("project-a") && !init?.method) return projectARead;
|
||||
if (!init?.method) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ content: "PROJECT_B" }),
|
||||
json: async () => ({ content: "PROJECT_B", version: "b-v1" }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true } as Response);
|
||||
return Promise.resolve({ ok: true, json: async () => ({ version: "a-v2" }) } as Response);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
@@ -74,10 +77,11 @@ describe("useFileManager project ownership", () => {
|
||||
|
||||
resolveProjectARead?.({
|
||||
ok: true,
|
||||
json: async () => ({ content: "PROJECT_A" }),
|
||||
json: async () => ({ content: "PROJECT_A", version: "a-v1" }),
|
||||
} as Response);
|
||||
await expect(delayedRead).resolves.toBe("PROJECT_A");
|
||||
await managerA.writeProjectFile("index.html", "A_AFTER");
|
||||
await managerA.writeProjectFile("missing.html", "A_NEW");
|
||||
await expect(managerB.readProjectFile("index.html")).resolves.toBe("PROJECT_B");
|
||||
await expect(managerB.readOptionalProjectFile("index.html")).resolves.toBe("PROJECT_B");
|
||||
|
||||
@@ -88,6 +92,13 @@ describe("useFileManager project ownership", () => {
|
||||
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/index.html",
|
||||
expect.objectContaining({ method: "PUT", body: "A_AFTER" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/projects/project-a%2F..%2Fother%3Fx%3D1/files/missing.html",
|
||||
expect.objectContaining({ method: "PUT", body: "A_NEW" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/projects/project-b%23fragment/files/index.html");
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/projects/project-b%23fragment/files/index.html?optional=1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import type { EditingFile } from "../utils/studioHelpers";
|
||||
import { FONT_EXT, isMediaFile } from "../utils/mediaTypes";
|
||||
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
||||
@@ -7,8 +7,10 @@ import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
|
||||
import {
|
||||
createStudioSaveHttpError,
|
||||
retryStudioSave,
|
||||
StudioFileConflictError,
|
||||
StudioSaveNetworkError,
|
||||
} from "../utils/studioSaveDiagnostics";
|
||||
import { createStudioWriteToken, studioExpectedFileVersion } from "../utils/studioFileVersion";
|
||||
import { useFileTree } from "./useFileTree";
|
||||
import { useEditorSave } from "./useEditorSave";
|
||||
|
||||
@@ -50,6 +52,17 @@ export function useFileManager({
|
||||
projectIdRef.current = projectId;
|
||||
|
||||
const importedFontAssetsRef = useRef<ImportedFontAsset[]>([]);
|
||||
const fileVersionScope = useMemo(
|
||||
() => ({ projectId, versions: new Map<string, string | null>() }),
|
||||
[projectId],
|
||||
);
|
||||
const fileVersions = fileVersionScope.versions;
|
||||
const observeProjectFileVersion = useCallback(
|
||||
(path: string, version: string | null) => {
|
||||
fileVersions.set(path, version);
|
||||
},
|
||||
[fileVersions],
|
||||
);
|
||||
|
||||
// ── File tree ──
|
||||
|
||||
@@ -73,17 +86,38 @@ export function useFileManager({
|
||||
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}`,
|
||||
);
|
||||
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
||||
const data = (await response.json()) as { content?: string };
|
||||
const data = (await response.json()) as { content?: string; version?: string };
|
||||
if (typeof data.content !== "string") throw new Error(`Missing file contents for ${path}`);
|
||||
fileVersions.set(path, data.version ?? response.headers.get("etag"));
|
||||
return data.content;
|
||||
},
|
||||
[projectId],
|
||||
[fileVersions, projectId],
|
||||
);
|
||||
|
||||
const writeProjectFile = useCallback(
|
||||
async (path: string, content: string): Promise<void> => {
|
||||
async (path: string, content: string, expectedContent?: string): Promise<void> => {
|
||||
if (!projectId) throw new Error("No active project");
|
||||
const writeProjectId = projectId;
|
||||
let expectedVersion = await studioExpectedFileVersion(fileVersions, path, expectedContent);
|
||||
if (expectedVersion === undefined) {
|
||||
const preflight = await fetch(
|
||||
`/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
|
||||
);
|
||||
if (preflight.ok) {
|
||||
const data = (await preflight.json()) as { content?: string; version?: string };
|
||||
throw new StudioFileConflictError({
|
||||
filePath: path,
|
||||
currentVersion: data.version ?? preflight.headers.get("etag"),
|
||||
currentContent: data.content ?? null,
|
||||
attemptedContent: content,
|
||||
});
|
||||
} else if (preflight.status === 404) {
|
||||
expectedVersion = null;
|
||||
} else {
|
||||
throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
|
||||
}
|
||||
}
|
||||
const writeToken = createStudioWriteToken();
|
||||
await retryStudioSave(async () => {
|
||||
let response: Response;
|
||||
try {
|
||||
@@ -91,7 +125,11 @@ export function useFileManager({
|
||||
`/api/projects/${encodeURIComponent(writeProjectId)}/files/${encodeURIComponent(path)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"X-Hyperframes-Write-Token": writeToken,
|
||||
...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
|
||||
},
|
||||
body: content,
|
||||
},
|
||||
);
|
||||
@@ -100,13 +138,35 @@ export function useFileManager({
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (response.status === 409) {
|
||||
const conflict = (await response.json().catch(() => null)) as {
|
||||
currentVersion?: string | null;
|
||||
currentContent?: string | null;
|
||||
} | null;
|
||||
const currentVersion = conflict?.currentVersion ?? null;
|
||||
if (currentVersion && conflict?.currentContent === content) {
|
||||
fileVersions.set(path, currentVersion);
|
||||
return;
|
||||
}
|
||||
throw new StudioFileConflictError({
|
||||
filePath: path,
|
||||
currentVersion,
|
||||
currentContent: conflict?.currentContent ?? null,
|
||||
attemptedContent: content,
|
||||
});
|
||||
}
|
||||
if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
|
||||
const result = (await response.json()) as { version?: string };
|
||||
const version = result.version ?? response.headers.get("etag");
|
||||
if (!version)
|
||||
throw new Error(`Save response for ${path} did not include a content version`);
|
||||
fileVersions.set(path, version);
|
||||
});
|
||||
if (projectIdRef.current === writeProjectId && editingPathRef.current === path) {
|
||||
setEditingFile({ path, content });
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
[fileVersions, projectId],
|
||||
);
|
||||
|
||||
const updateEditingFileContent = useCallback((path: string, content: string) => {
|
||||
@@ -122,10 +182,11 @@ export function useFileManager({
|
||||
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
||||
);
|
||||
if (!response.ok) throw new Error(`Failed to read ${path}`);
|
||||
const data = (await response.json()) as { content?: string };
|
||||
const data = (await response.json()) as { content?: string; version?: string };
|
||||
fileVersions.set(path, data.version ?? response.headers.get("etag"));
|
||||
return typeof data.content === "string" ? data.content : "";
|
||||
},
|
||||
[projectId],
|
||||
[fileVersions, projectId],
|
||||
);
|
||||
|
||||
// ── Editor save (debounced content change) ──
|
||||
@@ -163,8 +224,9 @@ export function useFileManager({
|
||||
if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
|
||||
return r.json();
|
||||
})
|
||||
.then((data: { content?: string }) => {
|
||||
.then((data: { content?: string; version?: string }) => {
|
||||
if (data.content != null) {
|
||||
fileVersions.set(path, data.version ?? null);
|
||||
setEditingFile({ path, content: data.content });
|
||||
}
|
||||
})
|
||||
@@ -172,7 +234,7 @@ export function useFileManager({
|
||||
showToast(err instanceof Error ? err.message : `Failed to load ${path}`, "error");
|
||||
});
|
||||
},
|
||||
[showToast],
|
||||
[fileVersions, showToast],
|
||||
);
|
||||
|
||||
// ── Click-to-source ──
|
||||
@@ -195,9 +257,10 @@ export function useFileManager({
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data: { content?: string }) => {
|
||||
.then((data: { content?: string; version?: string }) => {
|
||||
if (requestId !== revealRequestIdRef.current) return;
|
||||
if (data.content != null) {
|
||||
fileVersions.set(sourceFile, data.version ?? null);
|
||||
setEditingFile({ path: sourceFile, content: data.content });
|
||||
const match = findTagByTarget(data.content, target);
|
||||
setRevealSourceOffset(match ? match.start : null);
|
||||
@@ -205,7 +268,7 @@ export function useFileManager({
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
[editingFile?.content],
|
||||
[editingFile?.content, fileVersions],
|
||||
);
|
||||
|
||||
// ── Upload ──
|
||||
@@ -434,6 +497,7 @@ export function useFileManager({
|
||||
readProjectFile,
|
||||
writeProjectFile,
|
||||
readOptionalProjectFile,
|
||||
observeProjectFileVersion,
|
||||
updateEditingFileContent,
|
||||
|
||||
// Click-to-source
|
||||
|
||||
@@ -133,7 +133,10 @@ export function usePreviewPersistence({
|
||||
if (!domEditSaveQueueRef.current) {
|
||||
domEditSaveQueueRef.current = createDomEditSaveQueue({
|
||||
onOpen: (event) => {
|
||||
const message = "Auto-save is paused. Check your connection.";
|
||||
const message =
|
||||
event.statusCode === 409
|
||||
? "Save paused: this file changed elsewhere. Reload and review the latest version before reapplying your edit."
|
||||
: "Auto-save is paused. Check your connection.";
|
||||
setDomEditSaveQueuePaused(message);
|
||||
showToastRef.current(message, "error");
|
||||
trackStudioEvent("save_queue_paused", {
|
||||
|
||||
@@ -76,9 +76,10 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
|
||||
// Mirror the server: rewrites the GSAP script for the new id, writes to
|
||||
// disk, returns the final content.
|
||||
disk["index.html"] = SPLIT_GSAP;
|
||||
return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP }), {
|
||||
const version = `"test-gsap-${SPLIT_GSAP.length}"`;
|
||||
return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP, version }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ETag: version },
|
||||
});
|
||||
}
|
||||
// The fixture has no GSAP script — mirror the server's 400 response.
|
||||
@@ -89,9 +90,16 @@ function mountRazorSplit(opts: { gsap?: boolean; previewStamp?: boolean } = {}):
|
||||
}
|
||||
if (u.includes("/file-mutations/split-element/")) {
|
||||
disk["index.html"] = SPLIT;
|
||||
const version = `"test-split-${SPLIT.length}"`;
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, changed: true, content: SPLIT, newId: "clip1-split" }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
changed: true,
|
||||
content: SPLIT,
|
||||
newId: "clip1-split",
|
||||
version,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json", ETag: version } },
|
||||
);
|
||||
}
|
||||
if (u.includes("/files/")) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment happy-dom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TimelineElement } from "../player";
|
||||
import { useRazorSplit } from "./useRazorSplit";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("useRazorSplit mutation versions", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("observes each out-of-band mutation version before the OCC writer runs", async () => {
|
||||
const original = '<div id="clip" data-start="0" data-duration="4">Clip</div>';
|
||||
const htmlSplit =
|
||||
'<div id="clip" data-start="0" data-duration="2">Clip</div><div id="clip-split" data-start="2" data-duration="2">Clip</div>';
|
||||
const final = `${htmlSplit}<script>window.__timelines = {}</script>`;
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/files/")) return jsonResponse({ content: original });
|
||||
if (url.includes("/file-mutations/split-element/")) {
|
||||
return jsonResponse({ ok: true, changed: true, content: htmlSplit, version: '"v-html"' });
|
||||
}
|
||||
if (url.includes("/gsap-mutations/")) {
|
||||
return jsonResponse({ ok: true, changed: true, after: final, version: '"v-gsap"' });
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
});
|
||||
|
||||
const order: string[] = [];
|
||||
const observeProjectFileVersion = vi.fn((path: string, version: string | null) => {
|
||||
order.push(`observe:${path}:${version}`);
|
||||
});
|
||||
const writeProjectFile = vi.fn(async () => {
|
||||
order.push("write");
|
||||
});
|
||||
const recordEdit = vi.fn().mockResolvedValue(undefined);
|
||||
let split: ((element: TimelineElement, splitTime: number) => Promise<void>) | undefined;
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
function Harness() {
|
||||
split = useRazorSplit({
|
||||
projectId: "p1",
|
||||
activeCompPath: "index.html",
|
||||
showToast: vi.fn(),
|
||||
writeProjectFile,
|
||||
observeProjectFileVersion,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef: { current: 0 },
|
||||
reloadPreview: vi.fn(),
|
||||
}).handleRazorSplit;
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => root.render(React.createElement(Harness)));
|
||||
await act(async () => {
|
||||
await split?.(
|
||||
{
|
||||
id: "clip",
|
||||
domId: "clip",
|
||||
hfId: "clip",
|
||||
tag: "div",
|
||||
start: 0,
|
||||
duration: 4,
|
||||
track: 0,
|
||||
timingSource: "authored",
|
||||
},
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
expect(order).toEqual(['observe:index.html:"v-html"', 'observe:index.html:"v-gsap"', "write"]);
|
||||
expect(writeProjectFile).toHaveBeenCalledWith("index.html", final, original);
|
||||
expect(recordEdit).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -36,10 +36,11 @@ export function createSplitFetchMock(
|
||||
onSplit?.(path, JSON.parse(String(init?.body)) as SplitBody);
|
||||
// Return content that differs from the original so `changed` is true.
|
||||
const after = `${disk[path]}<!--split-->`;
|
||||
const version = `"test-${path}-${after.length}"`;
|
||||
disk[path] = after; // server writes the split to disk
|
||||
return new Response(JSON.stringify({ ok: true, changed: true, content: after }), {
|
||||
return new Response(JSON.stringify({ ok: true, changed: true, content: after, version }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: { "Content-Type": "application/json", ETag: version },
|
||||
});
|
||||
}
|
||||
if (u.includes("/files/")) {
|
||||
|
||||
@@ -16,7 +16,8 @@ interface UseRazorSplitOptions {
|
||||
projectId: string | null;
|
||||
activeCompPath: string | null;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
@@ -48,7 +49,7 @@ async function splitHtmlElement(
|
||||
newId: string,
|
||||
elementStart: number,
|
||||
elementDuration: number,
|
||||
): Promise<{ ok: boolean; changed?: boolean; content?: string }> {
|
||||
): Promise<{ ok: boolean; changed?: boolean; content?: string; version: string }> {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
@@ -64,9 +65,18 @@ async function splitHtmlElement(
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error("Split request failed");
|
||||
return (await response.json()) as { ok: boolean; changed?: boolean; content?: string };
|
||||
const data = (await response.json()) as {
|
||||
ok: boolean;
|
||||
changed?: boolean;
|
||||
content?: string;
|
||||
version?: string;
|
||||
};
|
||||
const version = data.version ?? response.headers.get("etag");
|
||||
if (!version) throw new Error("Split response did not include a content version");
|
||||
return { ...data, version };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function splitGsapAnimations(
|
||||
projectId: string,
|
||||
targetPath: string,
|
||||
@@ -75,7 +85,7 @@ async function splitGsapAnimations(
|
||||
splitTime: number,
|
||||
elementStart: number,
|
||||
elementDuration: number,
|
||||
): Promise<{ content: string | null; skippedSelectors?: string[] }> {
|
||||
): Promise<{ content: string | null; version?: string; skippedSelectors?: string[] }> {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
|
||||
{
|
||||
@@ -101,10 +111,12 @@ async function splitGsapAnimations(
|
||||
const data = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
after?: string;
|
||||
version?: string;
|
||||
skippedSelectors?: string[];
|
||||
};
|
||||
return {
|
||||
content: data.ok && data.after ? data.after : null,
|
||||
version: data.version ?? response.headers.get("etag") ?? undefined,
|
||||
skippedSelectors: data.skippedSelectors,
|
||||
};
|
||||
}
|
||||
@@ -119,11 +131,11 @@ function getOriginalContent(originals: ReadonlyMap<string, string>, path: string
|
||||
|
||||
async function restoreFilesToOriginal(
|
||||
originals: ReadonlyMap<string, string>,
|
||||
paths: Iterable<string>,
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>,
|
||||
snapshots: ReadonlyMap<string, { before: string; after: string }>,
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
for (const path of paths) {
|
||||
await writeProjectFile(path, getOriginalContent(originals, path));
|
||||
for (const [path, snapshot] of snapshots) {
|
||||
await writeProjectFile(path, getOriginalContent(originals, path), snapshot.after);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,17 +161,25 @@ async function splitElementsAtTime(
|
||||
activeCompPath: string | null,
|
||||
originals: ReadonlyMap<string, string>,
|
||||
snapshots: Map<string, { before: string; after: string }>,
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>,
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
||||
observeProjectFileVersion?: (path: string, version: string | null) => void,
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
for (const element of elements) {
|
||||
const result = await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
|
||||
const result = await executeSplit(
|
||||
pid,
|
||||
element,
|
||||
splitTime,
|
||||
activeCompPath,
|
||||
writeProjectFile,
|
||||
observeProjectFileVersion,
|
||||
);
|
||||
if (!result.changed) continue;
|
||||
snapshots.set(result.targetPath, {
|
||||
before: getOriginalContent(originals, result.targetPath),
|
||||
after: result.patchedContent,
|
||||
});
|
||||
await writeProjectFile(result.targetPath, result.patchedContent);
|
||||
await writeProjectFile(result.targetPath, result.patchedContent, result.patchedContent);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
@@ -171,7 +191,8 @@ async function executeSplit(
|
||||
element: TimelineElement,
|
||||
splitTime: number,
|
||||
activeCompPath: string | null,
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>,
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>,
|
||||
observeProjectFileVersion?: (path: string, version: string | null) => void,
|
||||
): Promise<{
|
||||
targetPath: string;
|
||||
originalContent: string;
|
||||
@@ -209,6 +230,7 @@ async function executeSplit(
|
||||
if (!splitResult.changed) {
|
||||
return { targetPath, originalContent, patchedContent: originalContent, changed: false };
|
||||
}
|
||||
observeProjectFileVersion?.(targetPath, splitResult.version);
|
||||
|
||||
let patchedContent =
|
||||
typeof splitResult.content === "string" ? splitResult.content : originalContent;
|
||||
@@ -226,11 +248,12 @@ async function executeSplit(
|
||||
element.duration,
|
||||
);
|
||||
if (gsapResult.content) patchedContent = gsapResult.content;
|
||||
if (gsapResult.version) observeProjectFileVersion?.(targetPath, gsapResult.version);
|
||||
if (gsapResult.skippedSelectors?.length) skippedSelectors = gsapResult.skippedSelectors;
|
||||
} catch (gsapError) {
|
||||
// GSAP mutation failed — the HTML split already wrote to disk.
|
||||
// Restore the original content to avoid a corrupt half-split state.
|
||||
await writeProjectFile(targetPath, originalContent);
|
||||
await writeProjectFile(targetPath, originalContent, patchedContent);
|
||||
throw gsapError;
|
||||
}
|
||||
}
|
||||
@@ -243,6 +266,7 @@ export function useRazorSplit({
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
observeProjectFileVersion,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
@@ -265,7 +289,14 @@ export function useRazorSplit({
|
||||
|
||||
try {
|
||||
const { targetPath, originalContent, patchedContent, changed, skippedSelectors } =
|
||||
await executeSplit(pid, element, splitTime, activeCompPath, writeProjectFile);
|
||||
await executeSplit(
|
||||
pid,
|
||||
element,
|
||||
splitTime,
|
||||
activeCompPath,
|
||||
writeProjectFile,
|
||||
observeProjectFileVersion,
|
||||
);
|
||||
|
||||
if (!changed) {
|
||||
showToast("Failed to split clip — playhead may be outside the clip", "error");
|
||||
@@ -305,6 +336,7 @@ export function useRazorSplit({
|
||||
recordEdit,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
observeProjectFileVersion,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
forceReloadSdkSession,
|
||||
@@ -312,8 +344,8 @@ export function useRazorSplit({
|
||||
],
|
||||
);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const handleRazorSplitAll = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
async (splitTime: number) => {
|
||||
if (isRecordingRef?.current) {
|
||||
showToast("Cannot edit timeline while recording", "error");
|
||||
@@ -338,6 +370,7 @@ export function useRazorSplit({
|
||||
originals,
|
||||
finalSnapshots,
|
||||
writeProjectFile,
|
||||
observeProjectFileVersion,
|
||||
);
|
||||
if (splitCount === 0) return;
|
||||
|
||||
@@ -358,7 +391,7 @@ export function useRazorSplit({
|
||||
// Best-effort rollback — a failing restore write must not swallow the
|
||||
// original error's toast, which is what tells the user the split failed.
|
||||
try {
|
||||
await restoreFilesToOriginal(originals, finalSnapshots.keys(), writeProjectFile);
|
||||
await restoreFilesToOriginal(originals, finalSnapshots, writeProjectFile);
|
||||
} catch {
|
||||
/* leave disk as-is; the original failure is reported below */
|
||||
}
|
||||
@@ -371,6 +404,7 @@ export function useRazorSplit({
|
||||
recordEdit,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
observeProjectFileVersion,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
forceReloadSdkSession,
|
||||
|
||||
@@ -46,6 +46,7 @@ export function useTimelineEditing({
|
||||
timelineElements,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
observeProjectFileVersion,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
@@ -503,6 +504,7 @@ export function useTimelineEditing({
|
||||
activeCompPath,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
observeProjectFileVersion,
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
reloadPreview,
|
||||
|
||||
@@ -31,7 +31,8 @@ export interface UseTimelineEditingOptions {
|
||||
activeCompPath: string | null;
|
||||
timelineElements: TimelineElement[];
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
observeProjectFileVersion?: (path: string, version: string | null) => void;
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
reloadPreview: () => void;
|
||||
|
||||
@@ -60,7 +60,7 @@ interface UseTimelineGroupEditingOptions {
|
||||
sdkSession?: Composition | null;
|
||||
publishSdkSession?: PublishSdkSession;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function targetPathFor(element: TimelineElement, activeCompPath: string | null): string {
|
||||
|
||||
@@ -114,4 +114,23 @@ describe("dom edit save queue", () => {
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
queue.destroy();
|
||||
});
|
||||
|
||||
it("pauses immediately on a file conflict instead of retrying stale work", async () => {
|
||||
const onOpen = vi.fn();
|
||||
const queue = createDomEditSaveQueue({ failureThreshold: 5, onOpen });
|
||||
|
||||
await expect(
|
||||
queue.enqueue(async () => {
|
||||
throw new StudioSaveHttpError("File changed elsewhere", 409);
|
||||
}),
|
||||
).rejects.toThrow("File changed elsewhere");
|
||||
|
||||
expect(onOpen).toHaveBeenCalledWith({
|
||||
consecutiveFailures: 1,
|
||||
errorMessage: "File changed elsewhere",
|
||||
statusCode: 409,
|
||||
});
|
||||
await expect(queue.enqueue(async () => {})).rejects.toThrow("Auto-save is paused");
|
||||
queue.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,8 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
|
||||
return result;
|
||||
} catch (error) {
|
||||
consecutiveFailures += 1;
|
||||
if (consecutiveFailures >= failureThreshold) open(error);
|
||||
if (getStudioSaveStatusCode(error) === 409 || consecutiveFailures >= failureThreshold)
|
||||
open(error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -223,7 +223,7 @@ describe("sdkCutoverPersist", () => {
|
||||
target: "hf-abc",
|
||||
styles: { color: "red", opacity: "0.5" },
|
||||
});
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>");
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>", "before");
|
||||
expect(deps.reloadPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -852,7 +852,11 @@ describe("sdkDeletePersist", () => {
|
||||
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
||||
expect(result.status).toBe("committed");
|
||||
expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
||||
"/comp.html",
|
||||
"<html>after</html>",
|
||||
"before",
|
||||
);
|
||||
});
|
||||
|
||||
it("records edit history with before/after diff", async () => {
|
||||
@@ -937,7 +941,11 @@ describe("sdkTimingPersist", () => {
|
||||
duration: 5,
|
||||
trackIndex: 1,
|
||||
});
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
||||
"/comp.html",
|
||||
"<html>after</html>",
|
||||
"<html>before</html>",
|
||||
);
|
||||
});
|
||||
|
||||
it("captures before-state before setTiming dispatch", async () => {
|
||||
@@ -1158,7 +1166,11 @@ describe("sdkGsapTweenPersist", () => {
|
||||
"hf-box",
|
||||
expect.objectContaining({ method: "to" }),
|
||||
);
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
||||
"/comp.html",
|
||||
"<html>after</html>",
|
||||
"<html>before</html>",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for kind=add when element not found", async () => {
|
||||
@@ -1262,7 +1274,11 @@ describe("sdkGsapKeyframePersist", () => {
|
||||
position: 50,
|
||||
value: { opacity: 0.5 },
|
||||
});
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
||||
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
||||
"/comp.html",
|
||||
"<html>after</html>",
|
||||
"<html>before</html>",
|
||||
);
|
||||
expect(deps.reloadPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface CutoverDeps {
|
||||
* 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) => Promise<void>;
|
||||
writeProjectFile: (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
reloadPreview: () => void;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
refresh?: (after: string) => void;
|
||||
@@ -148,13 +148,14 @@ function isCutoverResult(value: CandidateEdit | CutoverResult): value is Cutover
|
||||
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);
|
||||
await deps.writeProjectFile(targetPath, originalContent, expectedCurrentContent);
|
||||
return cause;
|
||||
} catch (rollbackError) {
|
||||
return new AggregateError(
|
||||
@@ -174,7 +175,7 @@ async function writeAndRecord(
|
||||
deps.domEditSaveTimestampRef.current = Date.now();
|
||||
markSelfWrite(targetPath, after);
|
||||
try {
|
||||
await deps.writeProjectFile(targetPath, after);
|
||||
await deps.writeProjectFile(targetPath, after, originalContent);
|
||||
} catch (error) {
|
||||
return asCutoverError(error);
|
||||
}
|
||||
@@ -188,7 +189,7 @@ async function writeAndRecord(
|
||||
});
|
||||
return null;
|
||||
} catch (error) {
|
||||
return rollbackWrite(targetPath, originalContent, deps, asCutoverError(error));
|
||||
return rollbackWrite(targetPath, originalContent, after, deps, asCutoverError(error));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface RecordEditInput {
|
||||
export interface DomEditCommitBaseParams {
|
||||
activeCompPath: string | null;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
writeProjectFile: (path: string, content: string) => Promise<void>;
|
||||
writeProjectFile: ProjectFileWriter;
|
||||
domEditSaveTimestampRef: MutableRefObject<number>;
|
||||
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
||||
projectIdRef: MutableRefObject<string | null>;
|
||||
@@ -22,6 +22,8 @@ export interface DomEditCommitBaseParams {
|
||||
clearDomSelection: () => void;
|
||||
}
|
||||
|
||||
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
||||
|
||||
interface SaveProjectFilesWithHistoryInput {
|
||||
projectId: string;
|
||||
label: string;
|
||||
@@ -30,7 +32,7 @@ interface SaveProjectFilesWithHistoryInput {
|
||||
coalesceMs?: number;
|
||||
files: Record<string, string>;
|
||||
readFile: (path: string) => Promise<string>;
|
||||
writeFile: (path: string, content: string) => Promise<void>;
|
||||
writeFile: ProjectFileWriter;
|
||||
recordEdit: (entry: RecordEditInput) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -71,7 +73,7 @@ export async function saveProjectFilesWithHistory({
|
||||
const writtenPaths: string[] = [];
|
||||
try {
|
||||
for (const path of changedPaths) {
|
||||
await writeFile(path, snapshots[path].after);
|
||||
await writeFile(path, snapshots[path].after, snapshots[path].before);
|
||||
writtenPaths.push(path);
|
||||
}
|
||||
|
||||
@@ -79,7 +81,7 @@ export async function saveProjectFilesWithHistory({
|
||||
} catch (error) {
|
||||
try {
|
||||
for (const path of writtenPaths.reverse()) {
|
||||
await writeFile(path, snapshots[path].before);
|
||||
await writeFile(path, snapshots[path].before, snapshots[path].after);
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError(
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { studioExpectedFileVersion, studioFileContentVersion } from "./studioFileVersion";
|
||||
|
||||
describe("studioFileContentVersion", () => {
|
||||
it("matches the strong SHA-256 ETag format used by studio-server", async () => {
|
||||
await expect(studioFileContentVersion("abc")).resolves.toBe(
|
||||
'"sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an explicit content precondition authoritative over cached state", async () => {
|
||||
const versions = new Map<string, string | null>([
|
||||
["stale.html", await studioFileContentVersion("stale")],
|
||||
["newer.html", await studioFileContentVersion("newer")],
|
||||
["missing.html", null],
|
||||
]);
|
||||
const expectedVersion = await studioFileContentVersion("expected");
|
||||
|
||||
expect(await studioExpectedFileVersion(versions, "stale.html", "expected")).toBe(
|
||||
expectedVersion,
|
||||
);
|
||||
expect(await studioExpectedFileVersion(versions, "newer.html", "expected")).toBe(
|
||||
expectedVersion,
|
||||
);
|
||||
expect(await studioExpectedFileVersion(versions, "missing.html", "expected")).toBe(
|
||||
expectedVersion,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps known-missing and untracked files distinct without explicit content", async () => {
|
||||
const versions = new Map<string, string | null>([["missing.html", null]]);
|
||||
|
||||
expect(await studioExpectedFileVersion(versions, "missing.html")).toBeNull();
|
||||
expect(await studioExpectedFileVersion(versions, "untracked.html")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Browser-safe SHA-256 version matching studio-server's strong ETag format. */
|
||||
export async function studioFileContentVersion(content: string): Promise<string> {
|
||||
const bytes = new TextEncoder().encode(content);
|
||||
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
||||
const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
return `"sha256:${hex}"`;
|
||||
}
|
||||
|
||||
/** Prefer an explicit content precondition, then the version observed during the read. */
|
||||
export async function studioExpectedFileVersion(
|
||||
versions: ReadonlyMap<string, string | null>,
|
||||
path: string,
|
||||
expectedContent?: string,
|
||||
): Promise<string | null | undefined> {
|
||||
if (expectedContent !== undefined) return studioFileContentVersion(expectedContent);
|
||||
return versions.get(path);
|
||||
}
|
||||
|
||||
export function createStudioWriteToken(): string {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
StudioFileConflictError,
|
||||
StudioSaveHttpError,
|
||||
StudioSaveNetworkError,
|
||||
buildStudioSaveFailureProperties,
|
||||
@@ -8,6 +9,23 @@ import {
|
||||
} from "./studioSaveDiagnostics";
|
||||
|
||||
describe("studio save diagnostics", () => {
|
||||
it("preserves conflict versions and both sides for explicit recovery UI", () => {
|
||||
const error = new StudioFileConflictError({
|
||||
filePath: "index.html",
|
||||
currentVersion: '"sha256:new"',
|
||||
currentContent: "external",
|
||||
attemptedContent: "local",
|
||||
});
|
||||
|
||||
expect(getStudioSaveStatusCode(error)).toBe(409);
|
||||
expect(error).toMatchObject({
|
||||
filePath: "index.html",
|
||||
currentVersion: '"sha256:new"',
|
||||
currentContent: "external",
|
||||
attemptedContent: "local",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds save_failure properties with stable diagnostics", () => {
|
||||
const error = new StudioSaveHttpError("Failed to save index.html (503)", 503);
|
||||
|
||||
|
||||
@@ -35,6 +35,27 @@ export class StudioSaveNetworkError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class StudioFileConflictError extends StudioSaveHttpError {
|
||||
readonly filePath: string;
|
||||
readonly currentVersion: string | null;
|
||||
readonly currentContent: string | null;
|
||||
readonly attemptedContent: string;
|
||||
|
||||
constructor(input: {
|
||||
filePath: string;
|
||||
currentVersion: string | null;
|
||||
currentContent: string | null;
|
||||
attemptedContent: string;
|
||||
}) {
|
||||
super(`Save conflict: ${input.filePath} changed outside this Studio session`, 409);
|
||||
this.name = "StudioFileConflictError";
|
||||
this.filePath = input.filePath;
|
||||
this.currentVersion = input.currentVersion;
|
||||
this.currentContent = input.currentContent;
|
||||
this.attemptedContent = input.attemptedContent;
|
||||
}
|
||||
}
|
||||
|
||||
function readNumericProperty(value: object, key: string): number | undefined {
|
||||
const record = value as Record<string, unknown>;
|
||||
const property = record[key];
|
||||
|
||||
@@ -63,9 +63,20 @@ function devProjectApi(): Plugin {
|
||||
name: "studio-dev-api",
|
||||
configureServer(server): void {
|
||||
let _api: { fetch: (req: Request) => Promise<Response> } | null = null;
|
||||
let _studioServerModule: {
|
||||
createStudioApi: (adapter: ReturnType<typeof createViteAdapter>) => {
|
||||
fetch: (req: Request) => Promise<Response>;
|
||||
};
|
||||
consumeFileWriteReceipt?: (path: string) => {
|
||||
path: string;
|
||||
version: string;
|
||||
writeToken: string;
|
||||
} | null;
|
||||
} | null = null;
|
||||
const getApi = async () => {
|
||||
if (!_api) {
|
||||
const mod = await server.ssrLoadModule("@hyperframes/studio-server");
|
||||
_studioServerModule = mod as typeof _studioServerModule;
|
||||
const adapter = createViteAdapter(dataDir, server);
|
||||
_api = mod.createStudioApi(adapter);
|
||||
}
|
||||
@@ -159,7 +170,12 @@ function devProjectApi(): Plugin {
|
||||
filePath.endsWith(".json"))
|
||||
) {
|
||||
console.log(`[Studio] File changed: ${filePath}`);
|
||||
server.ws.send({ type: "custom", event: "hf:file-change", data: { path: filePath } });
|
||||
const receipt = _studioServerModule?.consumeFileWriteReceipt?.(filePath) ?? null;
|
||||
server.ws.send({
|
||||
type: "custom",
|
||||
event: "hf:file-change",
|
||||
data: receipt ?? { path: filePath },
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user