mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +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:
@@ -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();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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