Skip to content

Commit 26be951

Browse files
committed
fix(studio): journal source writebacks
1 parent ad5229a commit 26be951

7 files changed

Lines changed: 284 additions & 14 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
existsSync,
3+
mkdirSync,
4+
mkdtempSync,
5+
readdirSync,
6+
readFileSync,
7+
rmSync,
8+
writeFileSync,
9+
} from "node:fs";
10+
import { tmpdir } from "node:os";
11+
import { join } from "node:path";
12+
import { afterEach, describe, expect, it } from "vitest";
13+
import { backupPathForResponse, snapshotBeforeWrite } from "./backupJournal";
14+
15+
const tempDirs: string[] = [];
16+
17+
afterEach(() => {
18+
for (const dir of tempDirs.splice(0)) {
19+
rmSync(dir, { recursive: true, force: true });
20+
}
21+
});
22+
23+
function createProjectDir(): string {
24+
const projectDir = mkdtempSync(join(tmpdir(), "hf-backup-journal-"));
25+
tempDirs.push(projectDir);
26+
return projectDir;
27+
}
28+
29+
describe("snapshotBeforeWrite", () => {
30+
it("copies the current file bytes before overwrite", () => {
31+
const projectDir = createProjectDir();
32+
mkdirSync(join(projectDir, "compositions"), { recursive: true });
33+
const file = join(projectDir, "compositions", "scene.html");
34+
writeFileSync(file, "before");
35+
36+
const result = snapshotBeforeWrite(projectDir, file);
37+
writeFileSync(file, "after");
38+
39+
expect(result.backupPath && existsSync(result.backupPath)).toBe(true);
40+
expect(readFileSync(result.backupPath!, "utf-8")).toBe("before");
41+
expect(backupPathForResponse(projectDir, result.backupPath)).toMatch(
42+
/^\.hyperframes\/backup\//,
43+
);
44+
});
45+
46+
it("creates backups for zero-byte files", () => {
47+
const projectDir = createProjectDir();
48+
const file = join(projectDir, "empty.html");
49+
writeFileSync(file, "");
50+
51+
const result = snapshotBeforeWrite(projectDir, file);
52+
53+
expect(result.backupPath && existsSync(result.backupPath)).toBe(true);
54+
expect(readFileSync(result.backupPath!, "utf-8")).toBe("");
55+
});
56+
57+
it("prunes older backups for the same file", () => {
58+
const projectDir = createProjectDir();
59+
const file = join(projectDir, "index.html");
60+
writeFileSync(file, "0");
61+
62+
for (let i = 1; i <= 5; i += 1) {
63+
writeFileSync(file, String(i));
64+
snapshotBeforeWrite(projectDir, file, { keepPerFile: 3 });
65+
}
66+
67+
expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(3);
68+
});
69+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {
2+
existsSync,
3+
mkdirSync,
4+
readdirSync,
5+
readFileSync,
6+
statSync,
7+
unlinkSync,
8+
writeFileSync,
9+
} from "node:fs";
10+
import { join, relative } from "node:path";
11+
import { isSafePath } from "./safePath.js";
12+
13+
const DEFAULT_KEEP_PER_FILE = 10;
14+
15+
export interface BackupJournalResult {
16+
backupPath: string | null;
17+
error?: string;
18+
}
19+
20+
function sanitizePath(path: string): string {
21+
return path
22+
.split(/[\\/]+/)
23+
.filter(Boolean)
24+
.join("__")
25+
.replace(/[^a-zA-Z0-9._-]/g, "_");
26+
}
27+
28+
function timestampPrefix(): string {
29+
return new Date().toISOString().replace(/[:.]/g, "-");
30+
}
31+
32+
export function backupPathForResponse(
33+
projectDir: string,
34+
backupPath: string | null,
35+
): string | null {
36+
if (!backupPath) return null;
37+
const rel = relative(projectDir, backupPath);
38+
if (!rel || rel.startsWith("..")) return null;
39+
return rel.split("\\").join("/");
40+
}
41+
42+
export function snapshotBeforeWrite(
43+
projectDir: string,
44+
absPath: string,
45+
options: { keepPerFile?: number } = {},
46+
): BackupJournalResult {
47+
if (!isSafePath(projectDir, absPath) || !existsSync(absPath)) return { backupPath: null };
48+
49+
try {
50+
const stat = statSync(absPath);
51+
if (!stat.isFile()) return { backupPath: null };
52+
53+
const relativePath = relative(projectDir, absPath);
54+
const backupDir = join(projectDir, ".hyperframes", "backup");
55+
mkdirSync(backupDir, { recursive: true });
56+
57+
const sanitized = sanitizePath(relativePath);
58+
const backupPath = nextBackupPath(backupDir, sanitized);
59+
writeFileSync(backupPath, readFileSync(absPath));
60+
pruneBackups(backupDir, sanitized, options.keepPerFile ?? DEFAULT_KEEP_PER_FILE);
61+
return { backupPath };
62+
} catch (error) {
63+
return { backupPath: null, error: error instanceof Error ? error.message : String(error) };
64+
}
65+
}
66+
67+
function nextBackupPath(backupDir: string, sanitizedPath: string): string {
68+
const base = `${timestampPrefix()}-${sanitizedPath}`;
69+
let candidate = join(backupDir, base);
70+
let counter = 2;
71+
while (existsSync(candidate)) {
72+
candidate = join(backupDir, `${base}-${counter}`);
73+
counter += 1;
74+
}
75+
return candidate;
76+
}
77+
78+
function pruneBackups(backupDir: string, sanitizedPath: string, keepPerFile: number): void {
79+
const keep = Math.max(1, Math.floor(keepPerFile));
80+
const suffix = `-${sanitizedPath}`;
81+
const matches = readdirSync(backupDir)
82+
.filter((name) => name.includes(suffix))
83+
.map((name) => join(backupDir, name))
84+
.sort((a, b) => {
85+
const delta = statSync(b).mtimeMs - statSync(a).mtimeMs;
86+
return delta === 0 ? b.localeCompare(a) : delta;
87+
});
88+
89+
for (const file of matches.slice(keep)) {
90+
try {
91+
unlinkSync(file);
92+
} catch {
93+
// Backup pruning is best-effort and must not block the user's write.
94+
}
95+
}
96+
}

packages/core/src/studio-api/routes/files.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, describe, expect, it } from "vitest";
22
import { Hono } from "hono";
3-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { registerFileRoutes } from "./files";
@@ -64,6 +64,58 @@ describe("registerFileRoutes", () => {
6464
expect(response.status).toBe(404);
6565
});
6666

67+
it("backs up the previous file content before PUT overwrite", async () => {
68+
const projectDir = createProjectDir();
69+
writeFileSync(join(projectDir, "index.html"), "before");
70+
const app = new Hono();
71+
registerFileRoutes(app, createAdapter(projectDir));
72+
73+
const response = await app.request("http://localhost/projects/demo/files/index.html", {
74+
method: "PUT",
75+
body: "after",
76+
});
77+
const payload = (await response.json()) as { path?: string; backupPath?: string };
78+
79+
expect(response.status).toBe(200);
80+
expect(payload.path).toBe("index.html");
81+
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
82+
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before");
83+
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toBe("after");
84+
});
85+
86+
it("backs up the previous file content before structured DOM mutations", async () => {
87+
const projectDir = createProjectDir();
88+
writeFileSync(projectDir + "/index.html", '<div id="title">Before</div>');
89+
const app = new Hono();
90+
registerFileRoutes(app, createAdapter(projectDir));
91+
92+
const response = await app.request(
93+
"http://localhost/projects/demo/file-mutations/patch-element/index.html",
94+
{
95+
method: "POST",
96+
headers: { "Content-Type": "application/json" },
97+
body: JSON.stringify({
98+
target: { id: "title" },
99+
operations: [{ type: "text-content", property: "textContent", value: "After" }],
100+
}),
101+
},
102+
);
103+
const payload = (await response.json()) as {
104+
changed?: boolean;
105+
path?: string;
106+
backupPath?: string;
107+
};
108+
109+
expect(response.status).toBe(200);
110+
expect(payload.changed).toBe(true);
111+
expect(payload.path).toBe("index.html");
112+
expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//);
113+
expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe(
114+
'<div id="title">Before</div>',
115+
);
116+
expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After");
117+
});
118+
67119
// A realistic sub-composition: markup + GSAP wrapped in a <template>, tweens
68120
// targeting element variables resolved from querySelector, with interleaved
69121
// gsap.set() calls. This is the shape every scaffolded composition uses.

packages/core/src/studio-api/routes/files.ts

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { isAudioFile } from "../helpers/mime.js";
1717
import { generateWaveformCache } from "../helpers/waveform.js";
1818
import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js";
1919
import { isSafePath } from "../helpers/safePath.js";
20+
import { backupPathForResponse, snapshotBeforeWrite } from "../helpers/backupJournal.js";
2021
import type { GsapAnimation } from "../../parsers/gsapSerialize.js";
2122
import {
2223
removeElementFromHtml,
@@ -94,15 +95,25 @@ type MutationTarget = {
9495
/** Write `next` to `absPath` only if it differs from `original`, returning a standardized change response. */
9596
function writeIfChanged(
9697
c: RouteContext,
98+
projectDir: string,
99+
filePath: string,
97100
absPath: string,
98101
original: string,
99102
next: string,
100103
): Response {
101104
if (next === original) {
102-
return c.json({ ok: true, changed: false, content: original });
105+
return c.json({ ok: true, changed: false, content: original, path: filePath });
103106
}
107+
const backup = snapshotBeforeWrite(projectDir, absPath);
108+
if (backup.error) console.warn(`Failed to create backup for ${filePath}: ${backup.error}`);
104109
writeFileSync(absPath, next, "utf-8");
105-
return c.json({ ok: true, changed: true, content: next });
110+
return c.json({
111+
ok: true,
112+
changed: true,
113+
content: next,
114+
path: filePath,
115+
backupPath: backupPathForResponse(projectDir, backup.backupPath),
116+
});
106117
}
107118

108119
/**
@@ -815,9 +826,15 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
815826

816827
ensureDir(res.absPath);
817828
const body = await c.req.text();
829+
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
830+
if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
818831
writeFileSync(res.absPath, body, "utf-8");
819832

820-
return c.json({ ok: true });
833+
return c.json({
834+
ok: true,
835+
path: res.filePath,
836+
backupPath: backupPathForResponse(res.project.dir, backup.backupPath),
837+
});
821838
});
822839

823840
// ── Create (fail if exists) ──
@@ -867,6 +884,8 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
867884
const originalContent = readFileSync(ctx.absPath, "utf-8");
868885
return writeIfChanged(
869886
c,
887+
ctx.project.dir,
888+
ctx.filePath,
870889
ctx.absPath,
871890
originalContent,
872891
removeElementFromHtml(originalContent, parsed.target),
@@ -900,10 +919,19 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
900919
parsed.body.newId,
901920
);
902921
if (!result.matched) {
903-
return c.json({ ok: false, changed: false, content: originalContent });
922+
return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath });
904923
}
924+
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
925+
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
905926
writeFileSync(ctx.absPath, result.html, "utf-8");
906-
return c.json({ ok: true, changed: true, content: result.html, newId: result.newId });
927+
return c.json({
928+
ok: true,
929+
changed: true,
930+
content: result.html,
931+
newId: result.newId,
932+
path: ctx.filePath,
933+
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
934+
});
907935
});
908936

909937
api.post("/projects/:id/file-mutations/patch-element/*", async (c) => {
@@ -931,10 +959,25 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
931959
parsed.body.operations,
932960
);
933961
if (patched === originalContent) {
934-
return c.json({ ok: true, changed: false, matched, content: originalContent });
962+
return c.json({
963+
ok: true,
964+
changed: false,
965+
matched,
966+
content: originalContent,
967+
path: ctx.filePath,
968+
});
935969
}
970+
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
971+
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
936972
writeFileSync(ctx.absPath, patched, "utf-8");
937-
return c.json({ ok: true, changed: true, matched, content: patched });
973+
return c.json({
974+
ok: true,
975+
changed: true,
976+
matched,
977+
content: patched,
978+
path: ctx.filePath,
979+
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath),
980+
});
938981
});
939982

940983
api.post("/projects/:id/file-mutations/probe-element/*", async (c) => {
@@ -1113,7 +1156,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
11131156
const newScript = typeof result === "string" ? result : result.script;
11141157
const changed = newScript !== block.scriptText;
11151158
const newHtml = changed ? block.replaceScript(newScript) : html;
1159+
let backupPath: string | null = null;
11161160
if (changed) {
1161+
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
1162+
if (backup.error)
1163+
console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
1164+
backupPath = backupPathForResponse(res.project.dir, backup.backupPath);
11171165
writeFileSync(res.absPath, newHtml, "utf-8");
11181166
}
11191167

@@ -1126,6 +1174,8 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
11261174
before: html,
11271175
after: newHtml,
11281176
scriptText: newScript,
1177+
path: res.filePath,
1178+
backupPath,
11291179
};
11301180
if (typeof result !== "string" && result.skippedSelectors.length > 0) {
11311181
responsePayload.skippedSelectors = result.skippedSelectors;

0 commit comments

Comments
 (0)