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:
James Russo
2026-07-15 18:07:04 -04:00
committed by GitHub
parent 35e623b4f3
commit 2417293dab
34 changed files with 838 additions and 72 deletions
+5 -1
View File
@@ -24,6 +24,7 @@ import {
createStudioApi,
createProjectSignature,
createBackgroundRemovalJob,
consumeFileWriteReceipt,
getMimeType,
type StudioApiAdapter,
type ResolvedProject,
@@ -637,7 +638,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
app.get("/api/events", (c) => {
return streamSSE(c, async (stream) => {
const listener = (path: string) => {
stream.writeSSE({ event: "file-change", data: JSON.stringify({ path }) }).catch(() => {});
const receipt = consumeFileWriteReceipt(resolve(projectDir, path));
stream
.writeSSE({ event: "file-change", data: JSON.stringify(receipt ?? { path }) })
.catch(() => {});
};
watcher.addListener(listener);
while (true) {
@@ -201,7 +201,7 @@ describe("renderChunk()", () => {
expect(a.planHashMs).toBeGreaterThanOrEqual(0);
expect(a.sessionBootMs).toBeGreaterThanOrEqual(0);
expect(a.captureStageMs).toBeGreaterThan(0);
expect(a.encodeStageMs).toBeGreaterThan(0);
expect(a.encodeStageMs).toBeGreaterThanOrEqual(0);
expect(a.workers).toBeGreaterThanOrEqual(1);
expect(a.captureStageMs + a.encodeStageMs).toBeLessThanOrEqual(a.durationMs);
const perf = JSON.parse(readFileSync(a.perfPath, "utf-8"));
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it } from "vitest";
import {
consumeFileWriteReceipt,
fileContentVersion,
recordFileWriteReceipt,
resetFileWriteReceipts,
} from "./fileVersion";
afterEach(resetFileWriteReceipts);
describe("file versions and write receipts", () => {
it("produces a strong quoted SHA-256 ETag", () => {
expect(fileContentVersion("abc")).toBe(
'"sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"',
);
});
it("attaches each API write identity to exactly one watcher echo", () => {
const receipt = {
path: "index.html",
version: fileContentVersion("after"),
writeToken: "write-1",
};
recordFileWriteReceipt("/project/index.html", receipt);
expect(consumeFileWriteReceipt("/project/index.html")).toEqual(receipt);
expect(consumeFileWriteReceipt("/project/index.html")).toBeNull();
});
});
@@ -0,0 +1,51 @@
import { createHash, randomUUID } from "node:crypto";
export interface FileWriteReceipt {
path: string;
version: string;
writeToken: string;
}
interface StoredReceipt extends FileWriteReceipt {
recordedAt: number;
}
const RECEIPT_TTL_MS = 10_000;
const receipts = new Map<string, StoredReceipt[]>();
/** Strong content version used as both the JSON version and HTTP ETag. */
export function fileContentVersion(content: string): string {
return `"sha256:${createHash("sha256").update(content, "utf8").digest("hex")}"`;
}
export function createWriteToken(requestToken?: string): string {
const token = requestToken?.trim();
return token && token.length <= 200 ? token : randomUUID();
}
export function recordFileWriteReceipt(absPath: string, receipt: FileWriteReceipt): void {
const now = Date.now();
const current = (receipts.get(absPath) ?? []).filter(
(entry) => now - entry.recordedAt < RECEIPT_TTL_MS,
);
current.push({ ...receipt, recordedAt: now });
receipts.set(absPath, current);
}
/** Attach one API write's identity to the corresponding filesystem-watch echo. */
export function consumeFileWriteReceipt(absPath: string): FileWriteReceipt | null {
const now = Date.now();
const current = (receipts.get(absPath) ?? []).filter(
(entry) => now - entry.recordedAt < RECEIPT_TTL_MS,
);
const receipt = current.shift() ?? null;
if (current.length > 0) receipts.set(absPath, current);
else receipts.delete(absPath);
if (!receipt) return null;
const { path, version, writeToken } = receipt;
return { path, version, writeToken };
}
export function resetFileWriteReceipts(): void {
receipts.clear();
}
+5
View File
@@ -12,6 +12,11 @@ export type {
} from "./types.js";
export { isSafePath, walkDir } from "./helpers/safePath.js";
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
export {
consumeFileWriteReceipt,
fileContentVersion,
type FileWriteReceipt,
} from "./helpers/fileVersion.js";
export { buildSubCompositionHtml } from "./helpers/subComposition.js";
export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screenshotClip.js";
export {
+154 -1
View File
@@ -13,6 +13,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { commitElementPatchBatches, registerFileRoutes } from "./files";
import type { StudioApiAdapter } from "../types";
import {
consumeFileWriteReceipt,
fileContentVersion,
resetFileWriteReceipts,
} from "../helpers/fileVersion";
const recastImportGate = vi.hoisted<{
wait: Promise<void> | null;
@@ -30,6 +35,7 @@ const tempDirs: string[] = [];
afterEach(() => {
recastImportGate.wait = null;
recastImportGate.onEnter = null;
resetFileWriteReceipts();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
@@ -104,6 +110,98 @@ describe("registerFileRoutes", () => {
expect(response.status).toBe(404);
});
it("returns the same strong content version in JSON and ETag", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/files/index.html");
const payload = (await response.json()) as { content?: string; version?: string };
expect(payload.version).toBe(fileContentVersion(payload.content!));
expect(response.headers.get("etag")).toBe(payload.version);
});
it("requires If-Match for updates and preserves the current bytes", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/files/index.html", {
method: "PUT",
body: "stale overwrite",
});
expect(response.status).toBe(428);
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(
"<html><body>Preview</body></html>",
);
});
it("requires an explicit create precondition for missing files", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request("http://localhost/projects/demo/files/new.html", {
method: "PUT",
body: "new bytes",
});
expect(response.status).toBe(428);
expect(() => readFileSync(join(projectDir, "new.html"), "utf-8")).toThrow();
});
it("creates a missing file only when it is still missing", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const created = await app.request("http://localhost/projects/demo/files/new.html", {
method: "PUT",
headers: { "If-None-Match": "*" },
body: "new bytes",
});
expect(created.status).toBe(200);
expect(readFileSync(join(projectDir, "new.html"), "utf-8")).toBe("new bytes");
const raced = await app.request("http://localhost/projects/demo/files/new.html", {
method: "PUT",
headers: { "If-None-Match": "*" },
body: "overwrite",
});
const payload = (await raced.json()) as { currentContent?: string; currentVersion?: string };
expect(raced.status).toBe(409);
expect(payload.currentContent).toBe("new bytes");
expect(payload.currentVersion).toBe(fileContentVersion("new bytes"));
expect(readFileSync(join(projectDir, "new.html"), "utf-8")).toBe("new bytes");
});
it("returns 409 with the current version/content for a stale writer", async () => {
const projectDir = createProjectDir();
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const current = "newer external bytes";
writeFileSync(join(projectDir, "index.html"), current);
const response = await app.request("http://localhost/projects/demo/files/index.html", {
method: "PUT",
headers: { "If-Match": fileContentVersion("older bytes") },
body: "stale overwrite",
});
const payload = (await response.json()) as {
currentVersion?: string;
currentContent?: string;
};
expect(response.status).toBe(409);
expect(payload.currentVersion).toBe(fileContentVersion(current));
expect(payload.currentContent).toBe(current);
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe(current);
});
it("backs up the previous file content before PUT overwrite", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "index.html"), "before");
@@ -112,12 +210,29 @@ describe("registerFileRoutes", () => {
const response = await app.request("http://localhost/projects/demo/files/index.html", {
method: "PUT",
headers: {
"If-Match": fileContentVersion("before"),
"X-Hyperframes-Write-Token": "studio-write-1",
},
body: "after",
});
const payload = (await response.json()) as { path?: string; backupPath?: string };
const payload = (await response.json()) as {
path?: string;
version?: string;
writeToken?: string;
backupPath?: string;
};
expect(response.status).toBe(200);
expect(payload.path).toBe("index.html");
expect(payload.version).toBe(fileContentVersion("after"));
expect(payload.writeToken).toBe("studio-write-1");
expect(response.headers.get("etag")).toBe(payload.version);
expect(consumeFileWriteReceipt(join(projectDir, "index.html"))).toEqual({
path: "index.html",
version: payload.version,
writeToken: "studio-write-1",
});
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before");
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe("after");
@@ -404,6 +519,41 @@ describe("registerFileRoutes", () => {
expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false);
});
it("returns the new strong version after a split-element mutation", async () => {
const projectDir = createProjectDir();
writeFileSync(
join(projectDir, "index.html"),
'<div id="clip" data-start="0" data-duration="4">Clip</div>',
);
const app = new Hono();
registerFileRoutes(app, createAdapter(projectDir));
const response = await app.request(
"http://localhost/projects/demo/file-mutations/split-element/index.html",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
target: { id: "clip" },
splitTime: 2,
newId: "clip-split",
elementStart: 0,
elementDuration: 4,
}),
},
);
const payload = (await response.json()) as {
changed?: boolean;
content?: string;
version?: string;
};
expect(response.status).toBe(200);
expect(payload.changed).toBe(true);
expect(payload.version).toBe(fileContentVersion(payload.content!));
expect(response.headers.get("etag")).toBe(payload.version);
});
// A realistic sub-composition: markup + GSAP wrapped in a <template>, tweens
// targeting element variables resolved from querySelector, with interleaved
// gsap.set() calls. This is the shape every scaffolded composition uses.
@@ -710,6 +860,7 @@ const tl = gsap.timeline({ paused: true });
ok: boolean;
mutated?: boolean;
after: string;
version?: string;
parsed: { animations: Array<{ fromProperties?: Record<string, number | string> }> };
};
@@ -717,6 +868,8 @@ const tl = gsap.timeline({ paused: true });
expect(result.ok).toBe(true);
expect(result.mutated).toBe(true);
expect(result.after).toContain("opacity: 0.2");
expect(result.version).toBe(fileContentVersion(result.after));
expect(res.headers.get("etag")).toBe(result.version);
expect(result.parsed.animations[0].fromProperties?.opacity).toBe(0.2);
// x unchanged
expect(result.parsed.animations[0].fromProperties?.x).toBe(-50);
+122 -6
View File
@@ -5,9 +5,13 @@
import type { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import {
closeSync,
existsSync,
ftruncateSync,
openSync,
readFileSync,
writeFileSync,
writeSync,
mkdirSync,
unlinkSync,
rmSync,
@@ -22,6 +26,11 @@ import { generateWaveformCache } from "../helpers/waveform.js";
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
import { isSafePath, resolveWithinProject } from "../helpers/safePath.js";
import { backupPathForResponse, snapshotBeforeWrite } from "../helpers/backupJournal.js";
import {
createWriteToken,
fileContentVersion,
recordFileWriteReceipt,
} from "../helpers/fileVersion.js";
import {
findUnsafeDomPatchValues,
findUnsafeMutationValues,
@@ -103,6 +112,7 @@ interface RouteContext {
path: string;
query: (name: string) => string | undefined;
};
header: (name: string, value: string) => void;
json: (data: unknown, status?: number) => Response;
}
@@ -1158,9 +1168,11 @@ async function applyGsapMutations(
after: newHtml,
scriptText: block.scriptText,
path: res.filePath,
version: fileContentVersion(newHtml),
backupPath,
};
if (skippedSelectors.size > 0) responsePayload.skippedSelectors = [...skippedSelectors];
c.header("ETag", responsePayload.version as string);
return c.json(responsePayload);
}
@@ -1939,7 +1951,9 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
}
const content = readFileSync(res.absPath, "utf-8");
return c.json({ filename: res.filePath, content });
const version = fileContentVersion(content);
c.header("ETag", version);
return c.json({ filename: res.filePath, content, version });
});
// ── Write (overwrite) ──
@@ -1948,15 +1962,106 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
const res = await resolveProjectFile(c, adapter);
if ("error" in res) return res.error;
ensureDir(res.absPath);
const body = await c.req.text();
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
writeFileSync(res.absPath, body, "utf-8");
const expectedVersion = c.req.header("If-Match")?.trim() ?? null;
const createOnly = c.req.header("If-None-Match")?.trim() === "*";
if (expectedVersion === null && !createOnly) {
let currentContent: string | null = null;
try {
currentContent = readFileSync(res.absPath, "utf-8");
} catch (error) {
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
throw error;
}
}
return c.json(
{
error: "precondition required",
path: res.filePath,
currentVersion: currentContent === null ? null : fileContentVersion(currentContent),
currentContent,
},
428,
);
}
let backup: ReturnType<typeof snapshotBeforeWrite> = { backupPath: null };
if (createOnly) {
ensureDir(res.absPath);
let fd: number;
try {
fd = openSync(res.absPath, "wx");
} catch (error) {
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
throw error;
}
const currentContent = readFileSync(res.absPath, "utf-8");
return c.json(
{
error: "file conflict",
path: res.filePath,
currentVersion: fileContentVersion(currentContent),
currentContent,
},
409,
);
}
try {
writeSync(fd, body, 0, "utf-8");
} finally {
closeSync(fd);
}
} else {
let fd: number;
try {
fd = openSync(res.absPath, "r+");
} catch (error) {
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
throw error;
}
return c.json(
{
error: "file conflict",
path: res.filePath,
currentVersion: null,
currentContent: null,
},
409,
);
}
try {
const currentContent = readFileSync(fd, "utf-8");
const currentVersion = fileContentVersion(currentContent);
if (expectedVersion !== currentVersion) {
return c.json(
{
error: "file conflict",
path: res.filePath,
currentVersion,
currentContent,
},
409,
);
}
backup = snapshotBeforeWrite(res.project.dir, res.absPath);
if (backup.error)
console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
ftruncateSync(fd, 0);
writeSync(fd, body, 0, "utf-8");
} finally {
closeSync(fd);
}
}
const version = fileContentVersion(body);
const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
recordFileWriteReceipt(res.absPath, { path: res.filePath, version, writeToken });
c.header("ETag", version);
return c.json({
ok: true,
path: res.filePath,
version,
writeToken,
backupPath: backupPathForResponse(res.project.dir, backup.backupPath),
});
});
@@ -2056,17 +2161,28 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
fallbackTiming,
);
if (!result.matched) {
return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath });
const version = fileContentVersion(originalContent);
c.header("ETag", version);
return c.json({
ok: false,
changed: false,
content: originalContent,
path: ctx.filePath,
version,
});
}
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
writeFileSync(ctx.absPath, result.html, "utf-8");
const version = fileContentVersion(result.html);
c.header("ETag", version);
return c.json({
ok: true,
changed: true,
content: result.html,
newId: result.newId,
path: ctx.filePath,
version,
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
});
});
+1 -1
View File
@@ -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> };
+1 -1
View File
@@ -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",
+76 -12
View File
@@ -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/")) {
+50 -16
View File
@@ -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;
}
};
+21 -5
View File
@@ -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];
+17 -1
View File
@@ -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 },
});
}
});
},