mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio): undo takes back the first edit, and the first save never waits on media
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user