fix(studio): undo takes back the first edit, and the first save never waits on media

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-02 03:39:02 -07:00
parent a26f2d730e
commit 4e1e75d7f4
3 changed files with 214 additions and 4 deletions
@@ -53,6 +53,15 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono {
.catch(() => null);
if (project) adapter.invalidateProjectSignature?.(project.dir);
});
api.use("/projects/:id/*", async function openHistorySoNoWriteBecomesItsBaseline(c, next) {
if (c.req.method !== "GET" && c.req.method !== "HEAD" && adapter.history) {
await Promise.resolve()
.then(() => adapter.resolveProject(c.req.param("id")))
.then((project) => project && adapter.history?.(project))
.catch(() => null);
}
await next();
});
registerProjectRoutes(api, adapter);
registerFileRoutes(api, adapter);
@@ -199,6 +199,9 @@ const statKey = (file: { size: number; mtimeMs: number; ctimeMs: number }, swept
? ""
: `${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
type ListedFile = ReturnType<typeof listProjectFiles>[number];
const COPY_LATER_ABOVE_BYTES = 1024 ** 2;
const sameWho = (a: HistoryWho, b: HistoryWho) => a.kind === b.kind && a.name === b.name;
/** Cuts a change at the overwritten `at`: [kept before it, claimed after]; `at` unknown or not kept claims all. */
@@ -294,6 +297,7 @@ class Engine {
outside: Group | null = null;
/** A coalescing claim, open until another key, its idle timer, an operation, a window, or another write. */
claimed: { group: Group; key: string; timer?: NodeJS.Timeout } | null = null;
adopting = new Map<string, Promise<void>>();
/** Per path and hash an API write left, the hash of the bytes it replaced, until walked or written past. */
overwritten = new Map<string, Map<string, string>>();
stopHearing: (() => void) | undefined;
@@ -387,6 +391,10 @@ class Engine {
const cached = cache.get(path);
this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
}
const left = this.readAdopting();
const unadopted = (file: { path: string }) =>
left.has(file.path) && !this.tracked.has(file.path);
this.adoptMediaInBackground(listProjectFiles(this.dir).filter(unadopted), Date.now());
// What changed while the project was closed is one outside entry, or the closed window's.
this.reopenClosedWindow();
await this.settleAll();
@@ -441,7 +449,8 @@ class Engine {
async firstOpen(): Promise<void> {
const sweptAt = Date.now();
for (const file of listProjectFiles(this.dir)) {
const files = listProjectFiles(this.dir);
for (const file of files.filter((file) => file.size <= COPY_LATER_ABOVE_BYTES)) {
const hash = await this.storeIfPresent(file.path);
if (this.whereFolder() !== "here") throw this.replaced();
if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) });
@@ -449,6 +458,47 @@ class Engine {
this.log.baseline = this.manifest();
this.persistLog();
this.saveStatCache();
this.adoptMediaInBackground(
files.filter((file) => file.size > COPY_LATER_ABOVE_BYTES),
sweptAt,
);
}
adoptMediaInBackground(files: ListedFile[], sweptAt: number): void {
let copied = Promise.resolve();
for (const file of files) {
copied = copied.then(() => this.adopt(file, sweptAt));
this.adopting.set(file.path, copied);
}
this.saveAdopting();
}
saveAdopting(): void {
const file = join(this.home, "adopting.json");
if (!this.adopting.size) return rmSync(file, { force: true });
mkdirSync(this.home, { recursive: true });
replaceFileAtomically(file, JSON.stringify([...this.adopting.keys()]), 0o644);
}
async adopt(file: ListedFile, sweptAt: number) {
try {
const hash = await this.storeIfPresent(file.path);
await this.queue(async () => {
this.adopting.delete(file.path);
this.saveAdopting();
if (hash === null || this.whereFolder() !== "here") return;
const change = { path: file.path, before: null, after: hash };
const found = this.overwrittenBy(change, undefined, new Set());
const start = found && this.blobs.has(found) ? found : hash;
this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) });
this.log.baseline.set(file.path, start);
if (start !== hash) addChange(this.outsideGroup(), file.path, start, hash);
this.persistLog({ type: "baseline", files: Object.fromEntries(this.log.baseline) });
this.saveStatCache();
});
} catch (error) {
if (!(error instanceof HistoryClosedError)) this.options.onError?.(error);
}
}
manifest(): Manifest {
@@ -464,6 +514,16 @@ class Engine {
}
}
readAdopting(): Set<string> {
try {
return new Set(
JSON.parse(readFileSync(join(this.home, "adopting.json"), "utf-8")) as string[],
);
} catch {
return new Set();
}
}
saveStatCache(): void {
const files = JSON.stringify(Object.fromEntries(this.tracked));
mkdirSync(this.home, { recursive: true });
@@ -480,7 +540,7 @@ class Engine {
const sweptAt = Date.now();
const changedAt = (file: { mtimeMs: number; ctimeMs: number }) =>
Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
const seen = listProjectFiles(this.dir);
const seen = listProjectFiles(this.dir).filter((file) => !this.adopting.has(file.path));
const heard = new Map(this.overwritten);
const present = new Set(seen.map((file) => file.path));
const removed = [...this.tracked.keys()]
@@ -1037,8 +1097,10 @@ class Engine {
projectId: this.projectId,
beginWindow: (who, label, options = {}) =>
this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 30_000),
claim: (who, label, paths, options = {}) =>
this.queue(() => this.claimNow(who, label, paths, options)),
claim: async (who, label, paths, options = {}) => {
await Promise.all(paths.map((path) => this.adopting.get(this.logPath(path))));
return this.queue(() => this.claimNow(who, label, paths, options));
},
noteChange: (path) => {
if (!this.closed) this.noteChange(path);
},
@@ -7,12 +7,34 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createStudioApi } from "../createStudioApi";
import { DELETED_VERSION, fileContentVersion, identifyFileWrite } from "../helpers/fileVersion";
import { openProjectHistory, type ProjectHistory } from "../history/projectHistory";
import { START } from "../history/historyLog";
import { historyCache } from "../history/historyCache";
import { createProjectSignature, resolveProjectSignature } from "../helpers/projectSignature";
import type { StudioApiAdapter } from "../types";
// Media copies wait on `held`, so a test can keep the first open's media copy running.
const mediaCopy = vi.hoisted(() => ({ held: null as Promise<void> | null }));
vi.mock("../history/blobStore", async (importOriginal) => {
const real = await importOriginal<typeof import("../history/blobStore")>();
return {
...real,
openBlobStore: async (dir: string) => {
const store = await real.openBlobStore(dir);
return {
...store,
put: async (path: string) => {
if (path.endsWith(".mp4")) await mediaCopy.held;
return store.put(path);
},
};
},
};
});
const cleanup: Array<() => unknown> = [];
afterEach(async () => {
mediaCopy.held = null;
for (const step of cleanup.splice(0).reverse()) await step();
});
@@ -36,6 +58,51 @@ function apiFor(projectDir: string, history?: ProjectHistory) {
);
}
/** A project whose index.html reads "A", its history opened on first use as both hosts do. */
function lazyHistoryProject() {
const projectDir = tempDir("hf-history-first-write-");
const file = join(projectDir, "index.html");
writeFileSync(file, "A");
const historyRoot = tempDir("hf-history-first-write-root-");
const histories = historyCache((dir) => openProjectHistory({ projectDir: dir, historyRoot }));
cleanup.push(() => histories.closeAll());
const api = createStudioApi({
listProjects: () => [],
resolveProject: (id: string) => (id === "demo" ? { id, dir: projectDir } : null),
history: (project: { dir: string }) => histories.get(project.dir),
} as unknown as StudioApiAdapter);
const post = (path: string, body: object) =>
api.request(`/projects/demo/history${path}`, { method: "POST", body: JSON.stringify(body) });
const put = (content: string) =>
api.request("/projects/demo/files/index.html", {
method: "PUT",
body: content,
headers: { "If-Match": fileContentVersion("A") },
});
return { projectDir, file, histories, api, post, put };
}
async function claimFirstWrite(post: ReturnType<typeof lazyHistoryProject>["post"]) {
const reply = await post("/claim", {
label: "Color",
paths: ["index.html"],
overwrote: { "index.html": fileContentVersion("A") },
});
return ((await reply.json()) as { claimed: unknown }).claimed;
}
async function withinMs<T>(ms: number, task: () => Promise<T>): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const late = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`took over ${ms} ms`)), ms);
});
try {
return await Promise.race([task(), late]);
} finally {
clearTimeout(timer);
}
}
/** A project whose index.html reads "A", with its history and the routes over it. */
async function demoProject({ quietMs }: { quietMs?: number } = {}) {
const projectDir = tempDir("hf-history-routes-");
@@ -340,6 +407,78 @@ describe("history routes", () => {
expect(back).toMatchObject({ id: claimed.id, label: "Moved Title" });
});
it("undo Studio's first write when that write is what opens the project's history", async () => {
const { file, put, post } = lazyHistoryProject();
expect((await put("B")).status).toBe(200);
expect(await claimFirstWrite(post)).not.toBeNull();
expect(await (await post("/step", { direction: "back" })).json()).toMatchObject({ ok: true });
expect(readFileSync(file, "utf-8")).toBe("A");
});
it("save and undo the first edit while the project's media is still being copied in", async () => {
let finishCopy = () => {};
mediaCopy.held = new Promise<void>((resolve) => (finishCopy = resolve));
const { projectDir, file, put, post, histories } = lazyHistoryProject();
writeFileSync(join(projectDir, "clip.mp4"), Buffer.alloc(4 * 1024 ** 2, 7));
const saved = await withinMs(2000, async () => {
expect((await put("B")).status).toBe(200);
return claimFirstWrite(post);
});
expect(saved).not.toBeNull();
await post("/step", { direction: "back" });
expect(readFileSync(file, "utf-8")).toBe("A");
finishCopy();
const history = (await histories.get(projectDir))!;
await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"]));
await history.flush();
expect(history.list().map((entry) => entry.label)).toEqual(["Color", "Undid: Color"]);
});
it("undo Studio's overwrite of media whose first copy was still running", async () => {
let finishCopy = () => {};
mediaCopy.held = new Promise<void>((resolve) => (finishCopy = resolve));
const { projectDir, api, post } = lazyHistoryProject();
const clip = join(projectDir, "clip.mp4");
const original = Buffer.alloc(4 * 1024 ** 2, 7);
writeFileSync(clip, original);
await post("/claim", { label: "Nothing", paths: [] });
const written = await api.request("/projects/demo/files/clip.mp4", {
method: "PUT",
body: Buffer.alloc(4 * 1024 ** 2, 9),
headers: { "If-Match": fileContentVersion(original) },
});
expect(written.status).toBe(200);
finishCopy();
const claimed = await post("/claim", {
label: "Replace clip",
paths: ["clip.mp4"],
overwrote: { "clip.mp4": fileContentVersion(original) },
});
expect(((await claimed.json()) as { claimed: unknown }).claimed).not.toBeNull();
await post("/step", { direction: "back" });
expect(readFileSync(clip).equals(original)).toBe(true);
});
it("adopt media whose copy a restart cut short, rather than log it as added", async () => {
mediaCopy.held = new Promise<void>(() => {});
const { projectDir, post, histories } = lazyHistoryProject();
writeFileSync(join(projectDir, "clip.mp4"), Buffer.alloc(4 * 1024 ** 2, 7));
await post("/claim", { label: "Nothing", paths: [] });
await histories.closeAll();
histories.forget(projectDir);
mediaCopy.held = null;
const history = (await histories.get(projectDir))!;
await vi.waitFor(() => expect(history.peek(START)).toHaveProperty(["clip.mp4"]));
await history.flush();
expect(history.list()).toEqual([]);
});
it("end a window given idleMs by itself once its writes stop", async () => {
const { projectDir, call } = await demoProject();