mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(studio-server): history refuses once closed and reopens when a new project takes its folder (#4537)
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
@@ -16,7 +24,7 @@ import { runCommand } from "citty";
|
||||
import { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
||||
import { consumeCommandResult } from "../utils/commandResult.js";
|
||||
import { historyDeps } from "../utils/historyOwner.js";
|
||||
import { historyDeps, withOwner } from "../utils/historyOwner.js";
|
||||
import historyCommand from "./history.js";
|
||||
|
||||
const pause = (ms: number) => new Promise((settle) => setTimeout(settle, ms));
|
||||
@@ -247,6 +255,24 @@ describe.each(["direct", "preview"])("hyperframes history (%s)", (mode) => {
|
||||
});
|
||||
|
||||
describe("hyperframes history, one owner", () => {
|
||||
it("lets go of the history when a new project takes the folder's path mid-command", async () => {
|
||||
const { dir, hf } = project();
|
||||
await hf("begin", "--who", "claude", "--label", "Retitle");
|
||||
const moved = `${dir}-moved`;
|
||||
onTestFinished(() => rmSync(moved, { recursive: true, force: true }));
|
||||
const swap = async () => {
|
||||
renameSync(dir, moved);
|
||||
mkdirSync(dir);
|
||||
};
|
||||
await expect(withOwner(dir, swap)).rejects.toThrow("now another project");
|
||||
const reopened = await openProjectHistory({
|
||||
projectDir: moved,
|
||||
historyRoot: historyDeps.historyRoot,
|
||||
ownerWaitMs: 200,
|
||||
});
|
||||
await reopened.close();
|
||||
});
|
||||
|
||||
it("goes through a running preview, so the log keeps one baseline and every entry once", async () => {
|
||||
const { dir, json, turn } = project();
|
||||
const held = await preview(dir);
|
||||
@@ -400,6 +426,7 @@ describe("hyperframes history, one owner", () => {
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
HYPERFRAMES_SKIP_UPDATE_CHECK: "1",
|
||||
HYPERFRAMES_NO_TELEMETRY: "1",
|
||||
},
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import {
|
||||
createProjectSignature,
|
||||
fileContentVersion,
|
||||
HistoryBusyError,
|
||||
HistoryClosedError,
|
||||
} from "@hyperframes/studio-server";
|
||||
import { loadHyperframeRuntimeSource } from "@hyperframes/core";
|
||||
import { loadRuntimeSource } from "./runtimeSource.js";
|
||||
@@ -156,16 +165,38 @@ describe("createStudioServer project history (D-491)", () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
it("tries a history another process was holding again on the next request, instead of turning it off", async () => {
|
||||
historyState.open = async () => {
|
||||
historyState.open = null;
|
||||
throw new HistoryBusyError(1);
|
||||
};
|
||||
it.each([
|
||||
["another process was holding", new HistoryBusyError(1)],
|
||||
["whose folder changed while it opened", new HistoryClosedError("now another project")],
|
||||
])(
|
||||
"tries a history %s again on the next request, instead of turning it off",
|
||||
async (_, refusal) => {
|
||||
historyState.open = async () => {
|
||||
historyState.open = null;
|
||||
throw refusal;
|
||||
};
|
||||
const projectDir = tmpProject();
|
||||
server = createStudioServer({ projectDir, historyRoot: tmpProject() });
|
||||
const historyUrl = `/api/projects/${encodeURIComponent(basename(projectDir))}/history`;
|
||||
expect((await server.app.request(historyUrl)).status).toBe(404);
|
||||
expect((await server.app.request(historyUrl)).status).toBe(200);
|
||||
await server.shutdown();
|
||||
},
|
||||
);
|
||||
|
||||
it("opens a new project's own history once it takes the folder's path", async () => {
|
||||
const projectDir = tmpProject();
|
||||
writeFileSync(join(projectDir, "index.html"), "<html>before</html>");
|
||||
server = createStudioServer({ projectDir, historyRoot: tmpProject() });
|
||||
const historyUrl = `/api/projects/${encodeURIComponent(basename(projectDir))}/history`;
|
||||
expect((await server.app.request(historyUrl)).status).toBe(404);
|
||||
expect((await server.app.request(historyUrl)).status).toBe(200);
|
||||
renameSync(projectDir, `${projectDir}-moved`);
|
||||
dirs.push(`${projectDir}-moved`);
|
||||
mkdirSync(projectDir);
|
||||
writeFileSync(join(projectDir, "index.html"), "<html>new</html>");
|
||||
|
||||
expect((await server.app.request(historyUrl)).status).toBe(200);
|
||||
expect(existsSync(join(projectDir, ".hyperframes", "history-id"))).toBe(true);
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,8 @@ import {
|
||||
DEFAULT_HISTORY_ROOT,
|
||||
openProjectHistory,
|
||||
HistoryBusyError,
|
||||
type ProjectHistory,
|
||||
HistoryClosedError,
|
||||
historyCache,
|
||||
} from "@hyperframes/studio-server";
|
||||
import { resolveAutoProxy } from "../utils/projectConfig.js";
|
||||
import { getElementScreenshotClip } from "@hyperframes/studio-server/screenshot-clip";
|
||||
@@ -429,18 +430,20 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
|
||||
// Opened on first use, so a server that never serves Studio's history never writes one. A failed open stays off
|
||||
// for this run; one another process was holding is tried again on the next request.
|
||||
let history: Promise<ProjectHistory | null> | undefined;
|
||||
const projectHistory = () =>
|
||||
(history ??= openProjectHistory({
|
||||
const histories = historyCache(() =>
|
||||
openProjectHistory({
|
||||
projectDir,
|
||||
historyRoot: options.historyRoot ?? DEFAULT_HISTORY_ROOT,
|
||||
}).catch((error: unknown) => {
|
||||
console.warn(`[studio] Project history is off: ${String(error)}`);
|
||||
if (error instanceof HistoryBusyError) history = undefined;
|
||||
if (error instanceof HistoryBusyError || error instanceof HistoryClosedError)
|
||||
histories.forget(projectDir);
|
||||
return null;
|
||||
}));
|
||||
}),
|
||||
);
|
||||
const projectHistory = () => histories.get(projectDir);
|
||||
watcher.addListener((changedPath) => {
|
||||
void history?.then((opened) => opened?.noteChange(changedPath));
|
||||
void histories.peek(projectDir)?.then((opened) => opened?.noteChange(changedPath));
|
||||
});
|
||||
|
||||
const inFlightRenders = new Map<AbortController, Promise<void>>();
|
||||
@@ -1098,7 +1101,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
|
||||
const shutdown = async (): Promise<void> => {
|
||||
shuttingDown = true;
|
||||
// Commits any open edit window; bounded with the renders below, so a history still opening cannot hold exit.
|
||||
const closeHistory = history?.then((opened) => opened?.close()).catch(() => {});
|
||||
const closeHistory = histories.closeAll().catch(() => {});
|
||||
const renders = [...inFlightRenders];
|
||||
for (const [abortController] of renders) abortController.abort();
|
||||
const { killTrackedProcesses, closeBrowserPool } = await import("@hyperframes/engine");
|
||||
|
||||
@@ -210,8 +210,12 @@ export async function withOwner<T>(
|
||||
} finally {
|
||||
// This open filed the turn so far under its id; the turn goes on under a fresh one, from its last write.
|
||||
const direct = owner.via === "direct" && turn;
|
||||
const kept = direct && (await owner.list()).find((entry) => entry.id === turn.id);
|
||||
await owner.close();
|
||||
let kept: HistoryListItem | undefined;
|
||||
try {
|
||||
kept = direct ? (await owner.list()).find((entry) => entry.id === turn.id) : undefined;
|
||||
} finally {
|
||||
await owner.close();
|
||||
}
|
||||
if (direct && readTurn(projectDir)?.id === turn.id) {
|
||||
const lastWriteAt = kept ? kept.endedAt : turn.lastWriteAt;
|
||||
const parts = kept ? [...turn.parts, kept.id] : turn.parts;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { constants, createReadStream } from "node:fs";
|
||||
import { constants, createReadStream, renameSync } from "node:fs";
|
||||
import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface BlobStore {
|
||||
has(hash: string): boolean;
|
||||
read(hash: string): Promise<Buffer>;
|
||||
/** Writes the blob's bytes to `absPath` by clone-or-copy and rename, so a reader never sees half a file. */
|
||||
writeTo(hash: string, absPath: string): Promise<void>;
|
||||
writeTo(hash: string, absPath: string, beforeReplace?: () => void): Promise<void>;
|
||||
bytes(): number;
|
||||
size(hash: string): number;
|
||||
prune(keep: ReadonlySet<string>): Promise<void>;
|
||||
@@ -24,12 +24,13 @@ async function hashFile(path: string): Promise<string> {
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function cloneOrCopy(from: string, to: string): Promise<void> {
|
||||
async function cloneOrCopy(from: string, to: string, beforeReplace?: () => void): Promise<void> {
|
||||
await mkdir(dirname(to), { recursive: true });
|
||||
const temp = `${to}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await copyFile(from, temp, constants.COPYFILE_FICLONE);
|
||||
await rename(temp, to);
|
||||
beforeReplace?.();
|
||||
renameSync(temp, to);
|
||||
} catch (error) {
|
||||
await rm(temp, { force: true });
|
||||
throw error;
|
||||
@@ -70,7 +71,8 @@ export async function openBlobStore(dir: string): Promise<BlobStore> {
|
||||
},
|
||||
has: (hash) => sizes.has(hash),
|
||||
read: async (hash) => readFile(pathOf(hash)),
|
||||
writeTo: async (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
|
||||
writeTo: async (hash, absPath, beforeReplace) =>
|
||||
cloneOrCopy(pathOf(hash), absPath, beforeReplace),
|
||||
bytes: () => total,
|
||||
size: (hash) => sizes.get(hash) ?? 0,
|
||||
async prune(keep) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ProjectHistory } from "./projectHistory.js";
|
||||
|
||||
type Opened = Promise<ProjectHistory | null>;
|
||||
|
||||
/** A host's histories, one per project folder, opened on first use; a new project at the path gets its own. */
|
||||
export function historyCache(open: (projectDir: string) => Opened) {
|
||||
const histories = new Map<string, Opened>();
|
||||
const get = async (projectDir: string): Promise<ProjectHistory | null> => {
|
||||
let cached = histories.get(projectDir);
|
||||
if (!cached) histories.set(projectDir, (cached = open(projectDir)));
|
||||
const opened = await cached;
|
||||
if (!opened?.replacedAtPath()) return opened;
|
||||
const reopen = () => open(projectDir);
|
||||
if (histories.get(projectDir) === cached)
|
||||
histories.set(projectDir, opened.close().then(reopen, reopen));
|
||||
return get(projectDir);
|
||||
};
|
||||
return {
|
||||
get,
|
||||
/** The history if one was opened, without opening it. */
|
||||
peek: (projectDir: string) => histories.get(projectDir),
|
||||
forget: (projectDir: string) => histories.delete(projectDir),
|
||||
closeAll: () =>
|
||||
Promise.all(
|
||||
[...histories.values()].map((opened) => opened.then((history) => history?.close())),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { replaceFileAtomically } from "../helpers/atomicFile.js";
|
||||
|
||||
export const ID_PATH = join(".hyperframes", "history-id");
|
||||
/** The only shape minted here; the id is project content and becomes a path, so nothing else is trusted. */
|
||||
const ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
function readId(projectDir: string): string | null {
|
||||
export function readId(projectDir: string): string | null {
|
||||
try {
|
||||
const id = readFileSync(join(projectDir, ID_PATH), "utf-8").trim();
|
||||
return ID_SHAPE.test(id) ? id : null;
|
||||
@@ -15,28 +16,42 @@ function readId(projectDir: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function recordedDir(historyDir: string): string | null {
|
||||
export type FolderIdentity = { ino: number; birthtimeMs: number };
|
||||
|
||||
export const sameFolder = (a: FolderIdentity, b: FolderIdentity) =>
|
||||
a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
|
||||
|
||||
export function isRecordedFolder(historyDir: string, folder: FolderIdentity): boolean {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(historyDir, "project.json"), "utf-8")).dir ?? null;
|
||||
const was = JSON.parse(readFileSync(join(historyDir, "project.json"), "utf-8"));
|
||||
return sameFolder({ ino: was.ino, birthtimeMs: was.born }, folder);
|
||||
} catch {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The project's history id, kept in the project so a rename or move keeps its history. A folder whose id is
|
||||
* still carried by the folder that history was recorded for is a copy, and gets an id of its own.
|
||||
* The project's history id, kept in the project so a rename or move keeps its history. Where history exists under it,
|
||||
* only the recorded folder keeps it: same inode and creation time, since an inode alone is reused after a delete.
|
||||
*/
|
||||
export function projectHistoryId(projectDir: string, historyRoot: string): string {
|
||||
const dir = resolve(projectDir);
|
||||
const folder = statSync(dir);
|
||||
let id = readId(dir);
|
||||
const was = id && recordedDir(join(historyRoot, id));
|
||||
if (!id || (was && was !== dir && existsSync(was) && readId(was) === id)) {
|
||||
if (
|
||||
!id ||
|
||||
(existsSync(join(historyRoot, id)) && !isRecordedFolder(join(historyRoot, id), folder))
|
||||
) {
|
||||
id = randomUUID();
|
||||
mkdirSync(join(dir, ".hyperframes"), { recursive: true });
|
||||
writeFileSync(join(dir, ID_PATH), `${id}\n`);
|
||||
}
|
||||
mkdirSync(join(historyRoot, id), { recursive: true });
|
||||
writeFileSync(join(historyRoot, id, "project.json"), JSON.stringify({ dir }));
|
||||
recordProject(join(historyRoot, id), dir, folder);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function recordProject(historyDir: string, dir: string, folder: FolderIdentity): void {
|
||||
const record = { dir, ino: folder.ino, born: folder.birthtimeMs };
|
||||
mkdirSync(historyDir, { recursive: true });
|
||||
replaceFileAtomically(join(historyDir, "project.json"), JSON.stringify(record), 0o644);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface HistoryLog {
|
||||
pins: Set<string>;
|
||||
}
|
||||
|
||||
type LogRecord =
|
||||
export type LogRecord =
|
||||
| { type: "baseline"; files: Record<string, string> }
|
||||
| { type: "entry"; entry: HistoryEntry }
|
||||
| { type: "pin"; id: string; pinned: boolean };
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// @vitest-environment node
|
||||
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { START, type HistoryWho } from "./historyLog";
|
||||
import { HistoryClosedError, openProjectHistory } from "./projectHistory";
|
||||
|
||||
// Lets a test swap the folder at an exact point inside the history's own copies.
|
||||
const hooks = vi.hoisted(() => ({
|
||||
afterWrite: null as null | (() => void),
|
||||
beforeWrite: null as null | (() => void),
|
||||
beforePut: null as null | (() => void),
|
||||
onReceipt: null as null | ((path: string) => void),
|
||||
recreated: null as null | string,
|
||||
}));
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:fs")>();
|
||||
const statSync = ((path: string, options?: object) => {
|
||||
const stat = original.statSync(path, options);
|
||||
if (!stat || resolve(String(path)) !== hooks.recreated) return stat;
|
||||
return new Proxy(stat, {
|
||||
get: (target, key) =>
|
||||
key === "birthtimeMs" ? target.birthtimeMs + 1000 : Reflect.get(target, key),
|
||||
});
|
||||
}) as typeof original.statSync;
|
||||
return { ...original, statSync };
|
||||
});
|
||||
vi.mock("../helpers/fileVersion", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("../helpers/fileVersion")>();
|
||||
return {
|
||||
...original,
|
||||
recordFileWriteReceipt: (...args: Parameters<typeof original.recordFileWriteReceipt>) => {
|
||||
hooks.onReceipt?.(args[1].path);
|
||||
return original.recordFileWriteReceipt(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("./blobStore", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("./blobStore")>();
|
||||
return {
|
||||
...original,
|
||||
openBlobStore: async (dir: string) => {
|
||||
const store = await original.openBlobStore(dir);
|
||||
return {
|
||||
...store,
|
||||
put: async (path: string) => {
|
||||
hooks.beforePut?.();
|
||||
return store.put(path);
|
||||
},
|
||||
writeTo: async (hash: string, path: string, beforeReplace?: () => void) => {
|
||||
hooks.beforeWrite?.();
|
||||
await store.writeTo(hash, path, beforeReplace);
|
||||
hooks.afterWrite?.();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const you: HistoryWho = { kind: "person", name: "You" };
|
||||
const cleanup: Array<() => unknown> = [];
|
||||
const files = ["a.html", "b.html", "c.html"];
|
||||
|
||||
afterEach(async () => {
|
||||
hooks.afterWrite = null;
|
||||
hooks.beforeWrite = null;
|
||||
hooks.beforePut = null;
|
||||
hooks.onReceipt = null;
|
||||
hooks.recreated = null;
|
||||
for (const step of cleanup.splice(0).reverse()) await step();
|
||||
});
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix));
|
||||
cleanup.push(() => rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
function unopened() {
|
||||
const root = tempDir("hf-history-swap-");
|
||||
const projectDir = join(root, "project");
|
||||
const moved = join(root, "moved");
|
||||
mkdirSync(projectDir);
|
||||
for (const path of files) writeFileSync(join(projectDir, path), "v1");
|
||||
const historyRoot = tempDir("hf-history-swap-root-");
|
||||
const swap = () => {
|
||||
renameSync(projectDir, moved);
|
||||
mkdirSync(projectDir);
|
||||
for (const path of files) writeFileSync(join(projectDir, path), "new project");
|
||||
};
|
||||
const texts = (dir: string) => files.map((path) => readFileSync(join(dir, path), "utf-8"));
|
||||
return { projectDir, historyRoot, moved, swap, texts };
|
||||
}
|
||||
|
||||
async function swappable() {
|
||||
const folders = unopened();
|
||||
const history = await openProjectHistory(folders);
|
||||
cleanup.push(() => history.close());
|
||||
return { history, ...folders };
|
||||
}
|
||||
|
||||
describe("a project folder swapped while the history is at work", () => {
|
||||
it("stops a restore at the next file, so none of the old project's bytes land in the new one", async () => {
|
||||
const { history, projectDir, moved, swap, texts } = await swappable();
|
||||
const window = await history.beginWindow(you, "Edit");
|
||||
for (const path of files) writeFileSync(join(projectDir, path), "edited");
|
||||
await window.close();
|
||||
hooks.afterWrite = () => {
|
||||
hooks.afterWrite = null;
|
||||
swap();
|
||||
};
|
||||
|
||||
await expect(history.restore(START, you)).rejects.toThrow(HistoryClosedError);
|
||||
expect(texts(projectDir)).toEqual(["new project", "new project", "new project"]);
|
||||
expect(texts(moved).filter((text) => text === "edited")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("refuses a restore write whose folder was swapped after its check, before the file is replaced", async () => {
|
||||
const { history, projectDir, moved, swap, texts } = await swappable();
|
||||
const window = await history.beginWindow(you, "Edit");
|
||||
for (const path of files) writeFileSync(join(projectDir, path), "edited");
|
||||
await window.close();
|
||||
hooks.beforeWrite = () => {
|
||||
hooks.beforeWrite = null;
|
||||
swap();
|
||||
};
|
||||
|
||||
await expect(history.restore(START, you)).rejects.toThrow(HistoryClosedError);
|
||||
expect([texts(projectDir), texts(moved)]).toEqual([
|
||||
["new project", "new project", "new project"],
|
||||
["edited", "edited", "edited"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a restore delete whose folder was swapped after its check, so the new project keeps the file", async () => {
|
||||
const { history, projectDir, swap } = await swappable();
|
||||
const window = await history.beginWindow(you, "Add");
|
||||
writeFileSync(join(projectDir, "d.html"), "added");
|
||||
await window.close();
|
||||
hooks.onReceipt = (path) => {
|
||||
if (path !== "d.html") return;
|
||||
hooks.onReceipt = null;
|
||||
swap();
|
||||
writeFileSync(join(projectDir, "d.html"), "new project");
|
||||
};
|
||||
|
||||
await expect(history.restore(START, you, { writeToken: "t" })).rejects.toThrow(
|
||||
HistoryClosedError,
|
||||
);
|
||||
expect(readFileSync(join(projectDir, "d.html"), "utf-8")).toBe("new project");
|
||||
});
|
||||
|
||||
it("opens with a first baseline that skips a file deleted while it was read", async () => {
|
||||
const { projectDir, historyRoot } = unopened();
|
||||
let puts = 0;
|
||||
hooks.beforePut = () => {
|
||||
if (++puts === 2) rmSync(join(projectDir, "b.html"));
|
||||
};
|
||||
|
||||
const history = await openProjectHistory({ projectDir, historyRoot });
|
||||
cleanup.push(() => history.close());
|
||||
expect(history.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a first baseline read partly from a swapped folder, so the original never gets its bytes", async () => {
|
||||
const { projectDir, historyRoot, moved, swap, texts } = unopened();
|
||||
let puts = 0;
|
||||
hooks.beforePut = () => {
|
||||
if (++puts === 2) swap();
|
||||
};
|
||||
await expect(openProjectHistory({ projectDir, historyRoot })).rejects.toThrow(
|
||||
HistoryClosedError,
|
||||
);
|
||||
hooks.beforePut = null;
|
||||
|
||||
const original = await openProjectHistory({ projectDir: moved, historyRoot });
|
||||
cleanup.push(() => original.close());
|
||||
await original.restore(START, you);
|
||||
expect(texts(moved)).toEqual(["v1", "v1", "v1"]);
|
||||
});
|
||||
|
||||
it("refuses, while open, a copy carrying its marker that came back on the recycled inode", async () => {
|
||||
const { history, projectDir, texts } = await swappable();
|
||||
const window = await history.beginWindow(you, "Edit");
|
||||
for (const path of files) writeFileSync(join(projectDir, path), "edited");
|
||||
await window.close();
|
||||
hooks.recreated = resolve(projectDir);
|
||||
|
||||
expect(() => history.list()).toThrow(HistoryClosedError);
|
||||
await expect(history.restore(START, you)).rejects.toThrow(HistoryClosedError);
|
||||
expect(texts(projectDir)).toEqual(["edited", "edited", "edited"]);
|
||||
});
|
||||
|
||||
it("stops a sweep at the next file, so the old history never files the new project's bytes", async () => {
|
||||
const { history, projectDir, historyRoot, moved, swap } = await swappable();
|
||||
for (const path of files) writeFileSync(join(projectDir, path), "edited");
|
||||
let puts = 0;
|
||||
hooks.beforePut = () => {
|
||||
if (++puts === 2) swap();
|
||||
};
|
||||
await history.flush();
|
||||
await history.close();
|
||||
|
||||
const again = await openProjectHistory({ projectDir: moved, historyRoot });
|
||||
cleanup.push(() => again.close());
|
||||
const filed: string[] = [];
|
||||
for (const entry of again.list())
|
||||
for (const file of entry.files)
|
||||
if (file.after) filed.push(String(await again.readBlob(file.after)));
|
||||
expect(filed).toContain("edited");
|
||||
expect(filed).not.toContain("new project");
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ import { basename, dirname, join, relative } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fileContentVersion, recordFileWriteReceipt } from "../helpers/fileVersion";
|
||||
import { HistoryBusyError } from "./ownerLock";
|
||||
import { openProjectHistory, type ProjectHistory } from "./projectHistory";
|
||||
import { HistoryClosedError, openProjectHistory, type ProjectHistory } from "./projectHistory";
|
||||
import { START, type HistoryWho } from "./historyLog";
|
||||
|
||||
const you: HistoryWho = { kind: "person", name: "You" };
|
||||
@@ -542,6 +542,179 @@ describe("openProjectHistory", () => {
|
||||
expect(readdirSync(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses every call after close, even through a window begun before it", async () => {
|
||||
const { history, write } = await project({ "index.html": "v1" });
|
||||
const entry = await change(history, you, "Second", () => write("index.html", "v2"));
|
||||
const window = await history.beginWindow(agent, "Turn");
|
||||
await history.close();
|
||||
|
||||
await expect(window.close()).rejects.toThrow(HistoryClosedError);
|
||||
await expect(history.claim(you, "Edit", ["index.html"])).rejects.toThrow(HistoryClosedError);
|
||||
await expect(history.undo(entry.id, { who: you })).rejects.toThrow(HistoryClosedError);
|
||||
await expect(history.restore(START, you)).rejects.toThrow(HistoryClosedError);
|
||||
await expect(history.readBlob("a")).rejects.toThrow(HistoryClosedError);
|
||||
expect(() => history.list()).toThrow(HistoryClosedError);
|
||||
});
|
||||
|
||||
it("releases ownership only after every close has settled", async () => {
|
||||
const { history } = await project({ "index.html": "v1" });
|
||||
let firstDone = false;
|
||||
const first = history.close().then(() => (firstDone = true));
|
||||
await history.close();
|
||||
expect(firstDone).toBe(true);
|
||||
await first;
|
||||
});
|
||||
|
||||
it("refuses once a new project takes the folder's path, leaving both projects' files alone", async () => {
|
||||
const { history, write, read, projectDir, historyRoot } = await project({ "index.html": "v1" });
|
||||
await change(history, you, "Second", () => write("index.html", "v2"));
|
||||
const window = await history.beginWindow(agent, "Turn");
|
||||
write("index.html", "v3");
|
||||
history.noteChange("index.html");
|
||||
await pause(100);
|
||||
const moved = `${projectDir}-moved`;
|
||||
renameSync(projectDir, moved);
|
||||
cleanup.push(() => rmSync(moved, { recursive: true, force: true }));
|
||||
mkdirSync(projectDir);
|
||||
writeFileSync(join(projectDir, "index.html"), "new project");
|
||||
|
||||
expect(history.replacedAtPath()).toBe(true);
|
||||
await expect(history.restore(START, you)).rejects.toThrow(HistoryClosedError);
|
||||
expect(() => history.list()).toThrow(HistoryClosedError);
|
||||
// What the turn wrote before the swap is still its entry; nothing of the new project is.
|
||||
expect((await window.close())?.files.map((file) => file.path)).toEqual(["index.html"]);
|
||||
await history.close();
|
||||
expect([read("index.html"), readFileSync(join(moved, "index.html"), "utf-8")]).toEqual([
|
||||
"new project",
|
||||
"v3",
|
||||
]);
|
||||
const again = await open(moved, historyRoot);
|
||||
expect(again.list().map((entry) => entry.label)).toEqual(["Second", "Turn"]);
|
||||
});
|
||||
|
||||
it("refuses to write into a folder moved away, so the old path is not recreated", async () => {
|
||||
const { history, write, projectDir } = await project({ "index.html": "v1" });
|
||||
await change(history, you, "Second", () => write("index.html", "v2"));
|
||||
renameSync(projectDir, `${projectDir}-moved`);
|
||||
cleanup.push(() => rmSync(`${projectDir}-moved`, { recursive: true, force: true }));
|
||||
|
||||
await expect(history.restore(START, you)).rejects.toThrow("The project folder is gone");
|
||||
expect(existsSync(projectDir)).toBe(false);
|
||||
expect(history.replacedAtPath()).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a new project took the folder's path", "new"],
|
||||
["a copy carrying its id took the folder's path", "copy"],
|
||||
["the folder moved away", "none"],
|
||||
])(
|
||||
"refuses to open, so a server can retry, once %s while it waited for the history",
|
||||
async (_, replacement) => {
|
||||
const { history, projectDir, historyRoot } = await project({ "index.html": "v1" });
|
||||
const waiting = openProjectHistory({ projectDir, historyRoot });
|
||||
waiting.then((late) => cleanup.push(() => late.close())).catch(() => {});
|
||||
await pause(50);
|
||||
renameSync(projectDir, `${projectDir}-moved`);
|
||||
cleanup.push(() => rmSync(`${projectDir}-moved`, { recursive: true, force: true }));
|
||||
if (replacement === "new") mkdirSync(projectDir);
|
||||
if (replacement === "copy") cpSync(`${projectDir}-moved`, projectDir, { recursive: true });
|
||||
await history.close();
|
||||
|
||||
await expect(waiting).rejects.toThrow(HistoryClosedError);
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses, so a server can retry, to open a folder that is not there", async () => {
|
||||
const projectDir = join(tempDir("hf-history-gone-"), "project");
|
||||
const historyRoot = tempDir("hf-history-root-");
|
||||
await expect(openProjectHistory({ projectDir, historyRoot })).rejects.toThrow(
|
||||
HistoryClosedError,
|
||||
);
|
||||
});
|
||||
|
||||
it("gives a folder whose history record cannot prove it is that folder a history of its own", async () => {
|
||||
const { history, write, projectDir, historyRoot } = await project({ "index.html": "v1" });
|
||||
await change(history, you, "Old change", () => write("index.html", "v2"));
|
||||
await history.close();
|
||||
writeFileSync(
|
||||
join(historyRoot, history.projectId, "project.json"),
|
||||
JSON.stringify({ dir: projectDir }),
|
||||
);
|
||||
|
||||
const again = await open(projectDir, historyRoot);
|
||||
expect([again.projectId === history.projectId, again.list()]).toEqual([false, []]);
|
||||
});
|
||||
|
||||
it("leaves the id alone for a history root that has no history under it, so the open one keeps recording", async () => {
|
||||
const { history, write, projectDir } = await project({ "index.html": "v1" });
|
||||
const other = await open(projectDir, tempDir("hf-history-other-root-"));
|
||||
|
||||
await change(history, you, "Edit", () => write("index.html", "v2"));
|
||||
expect([other.projectId, history.list().length]).toEqual([history.projectId, 1]);
|
||||
});
|
||||
|
||||
it("gives a folder on a reused inode, created at another time, a history of its own", async () => {
|
||||
const { history, write, projectDir, historyRoot } = await project({ "index.html": "v1" });
|
||||
await change(history, you, "Old change", () => write("index.html", "v2"));
|
||||
await history.close();
|
||||
const record = join(historyRoot, history.projectId, "project.json");
|
||||
const was = JSON.parse(readFileSync(record, "utf-8"));
|
||||
writeFileSync(record, JSON.stringify({ ...was, born: was.born - 1000 }));
|
||||
|
||||
const again = await open(projectDir, historyRoot);
|
||||
expect([again.projectId === history.projectId, again.list()]).toEqual([false, []]);
|
||||
});
|
||||
|
||||
it("keeps the history when the disk's device number changed, as a remounted drive's does", async () => {
|
||||
const { history, write, projectDir, historyRoot } = await project({ "index.html": "v1" });
|
||||
await change(history, you, "Old change", () => write("index.html", "v2"));
|
||||
await history.close();
|
||||
const record = join(historyRoot, history.projectId, "project.json");
|
||||
writeFileSync(
|
||||
record,
|
||||
JSON.stringify({ ...JSON.parse(readFileSync(record, "utf-8")), dev: -1 }),
|
||||
);
|
||||
|
||||
const again = await open(projectDir, historyRoot);
|
||||
expect(again.list().map((entry) => entry.label)).toEqual(["Old change"]);
|
||||
});
|
||||
|
||||
it("counts a folder whose history id is gone as another project, as a recreated folder on a reused inode is", async () => {
|
||||
const { history, write, read, projectDir } = await project({ "index.html": "v1" });
|
||||
await change(history, you, "Second", () => write("index.html", "v2"));
|
||||
rmSync(join(projectDir, ".hyperframes"), { recursive: true });
|
||||
write("index.html", "new project");
|
||||
|
||||
expect(history.replacedAtPath()).toBe(true);
|
||||
await expect(history.restore(START, you)).rejects.toThrow(HistoryClosedError);
|
||||
expect(read("index.html")).toBe("new project");
|
||||
});
|
||||
|
||||
it.each([["the copy"], ["the moved original"]])(
|
||||
"gives a copy put where the original was its own history, and the moved original keeps its own, %s opened first",
|
||||
async (first) => {
|
||||
const { history, write, projectDir, historyRoot } = await project({ "index.html": "v1" });
|
||||
await change(history, you, "Old change", () => write("index.html", "v2"));
|
||||
await history.close();
|
||||
const moved = `${projectDir}-moved`;
|
||||
cpSync(projectDir, `${projectDir}-copy`, { recursive: true });
|
||||
renameSync(projectDir, moved);
|
||||
renameSync(`${projectDir}-copy`, projectDir);
|
||||
cleanup.push(() => rmSync(moved, { recursive: true, force: true }));
|
||||
|
||||
const labels = async (dir: string) => {
|
||||
const opened = await open(dir, historyRoot);
|
||||
const list = opened.list().map((entry) => entry.label);
|
||||
await opened.close();
|
||||
return list;
|
||||
};
|
||||
const order = first === "the copy" ? [projectDir, moved] : [moved, projectDir];
|
||||
const [a, b] = [await labels(order[0]!), await labels(order[1]!)];
|
||||
const [copy, original] = first === "the copy" ? [a, b] : [b, a];
|
||||
expect({ copy, original }).toEqual({ copy: [], original: ["Old change"] });
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the history across a move and a reopen, and a copy of the folder starts its own", async () => {
|
||||
const { history, write, projectDir, historyRoot } = await project({ "index.html": "v1" });
|
||||
write("index.html", "v2");
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
@@ -25,7 +26,15 @@ import {
|
||||
} from "../helpers/fileVersion.js";
|
||||
import { affectsProjectSignature, listProjectFiles } from "../helpers/projectSignature.js";
|
||||
import { openBlobStore, type BlobStore } from "./blobStore.js";
|
||||
import { ID_PATH, projectHistoryId } from "./historyId.js";
|
||||
import {
|
||||
ID_PATH,
|
||||
isRecordedFolder,
|
||||
sameFolder,
|
||||
projectHistoryId,
|
||||
readId,
|
||||
recordProject,
|
||||
type FolderIdentity,
|
||||
} from "./historyId.js";
|
||||
import { takeHistoryOwnership } from "./ownerLock.js";
|
||||
import {
|
||||
START,
|
||||
@@ -43,6 +52,7 @@ import {
|
||||
type HistoryFileChange,
|
||||
type HistoryLog,
|
||||
type HistoryWho,
|
||||
type LogRecord,
|
||||
type Manifest,
|
||||
} from "./historyLog.js";
|
||||
|
||||
@@ -149,6 +159,8 @@ export interface ProjectHistory {
|
||||
onEntry(listener: (entry: HistoryEntry) => void): () => void;
|
||||
/** Takes in every pending write and commits every open window and the outside group. */
|
||||
flush(): Promise<void>;
|
||||
/** True once another project stands at the folder's path: open that one's history instead. */
|
||||
replacedAtPath(): boolean;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -255,6 +267,7 @@ function addChange(group: Group, path: string, before: string | null, after: str
|
||||
class Engine {
|
||||
readonly dir: string;
|
||||
readonly foldsCase: boolean;
|
||||
readonly folder: FolderIdentity & { dev: number };
|
||||
readonly home: string;
|
||||
log: HistoryLog = { baseline: new Map(), entries: [], pins: new Set() };
|
||||
tracked = new Map<string, Tracked>();
|
||||
@@ -268,6 +281,8 @@ class Engine {
|
||||
notedTimer: NodeJS.Timeout | null = null;
|
||||
listeners = new Set<(entry: HistoryEntry) => void>();
|
||||
tail: Promise<unknown> = Promise.resolve();
|
||||
closed = false;
|
||||
closing: Promise<void> | undefined;
|
||||
|
||||
constructor(
|
||||
readonly options: ProjectHistoryOptions,
|
||||
@@ -277,6 +292,11 @@ class Engine {
|
||||
this.dir = resolve(options.projectDir);
|
||||
this.home = join(options.historyRoot, projectId);
|
||||
this.foldsCase = ignoresCase(this.dir);
|
||||
const folder = statSync(this.dir, { throwIfNoEntry: false });
|
||||
// Checked after the ownership wait, so a folder swapped for a copy meanwhile is refused.
|
||||
if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
|
||||
throw this.replaced();
|
||||
this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
|
||||
}
|
||||
|
||||
/** A path as the project's disk compares names: folded where `A` and `a` are one name. */
|
||||
@@ -294,11 +314,43 @@ class Engine {
|
||||
|
||||
/** One operation at a time, in order: sweeps, windows and undos never interleave. */
|
||||
queue<T>(task: () => Promise<T>): Promise<T> {
|
||||
if (this.closed)
|
||||
return Promise.reject(new HistoryClosedError("This project's history is closed."));
|
||||
const run = this.tail.then(task);
|
||||
this.tail = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
|
||||
whereFolder(): "here" | "gone" | "replaced" {
|
||||
const now = statSync(this.dir, { throwIfNoEntry: false });
|
||||
if (!now) return "gone";
|
||||
const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
|
||||
return same && readId(this.dir) === this.projectId ? "here" : "replaced";
|
||||
}
|
||||
|
||||
assertWritable(): void {
|
||||
const folder = this.whereFolder();
|
||||
if (folder === "gone") throw new Error(`The project folder is gone: ${this.dir}`);
|
||||
if (folder === "replaced") throw this.replaced();
|
||||
}
|
||||
|
||||
persistLog(record?: LogRecord): void {
|
||||
if (record) saveRecord(this.logFile, this.log, record);
|
||||
else writeLog(this.logFile, this.log);
|
||||
if (!existsSync(join(this.home, "project.json")))
|
||||
recordProject(this.home, this.dir, this.folder);
|
||||
}
|
||||
|
||||
replaced(): HistoryClosedError {
|
||||
return new HistoryClosedError(`${this.dir} is now another project.`);
|
||||
}
|
||||
|
||||
assertOpen(): void {
|
||||
if (this.closed) throw new HistoryClosedError("This project's history is closed.");
|
||||
if (this.whereFolder() === "replaced") throw this.replaced();
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const log = readLog(this.logFile, (line) =>
|
||||
this.options.onError?.(
|
||||
@@ -327,13 +379,13 @@ class Engine {
|
||||
|
||||
async firstOpen(): Promise<void> {
|
||||
const sweptAt = Date.now();
|
||||
for (const file of listProjectFiles(this.dir))
|
||||
this.tracked.set(file.path, {
|
||||
hash: await this.blobs.put(join(this.dir, file.path)),
|
||||
stat: statKey(file, sweptAt),
|
||||
});
|
||||
for (const file of listProjectFiles(this.dir)) {
|
||||
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) });
|
||||
}
|
||||
this.log.baseline = this.manifest();
|
||||
writeLog(this.logFile, this.log);
|
||||
this.persistLog();
|
||||
this.saveStatCache();
|
||||
}
|
||||
|
||||
@@ -361,8 +413,8 @@ class Engine {
|
||||
* project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
|
||||
*/
|
||||
async sweep(): Promise<void> {
|
||||
// A missing project folder was moved or removed, not emptied: that is no change to its files.
|
||||
if (!existsSync(this.dir)) return;
|
||||
// A folder moved or removed was not emptied, and another project at its path is none of this history's.
|
||||
if (this.whereFolder() !== "here") return;
|
||||
const sweptAt = Date.now();
|
||||
const changedAt = (file: { mtimeMs: number; ctimeMs: number }) =>
|
||||
Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
|
||||
@@ -414,7 +466,7 @@ class Engine {
|
||||
const known = this.tracked.get(path) ?? { hash: null, stat: null };
|
||||
if (stat && known.stat === stat) return false;
|
||||
const hash = await this.storeIfPresent(path);
|
||||
if (hash === null) return false;
|
||||
if (hash === null || this.whereFolder() !== "here") return false;
|
||||
this.tracked.set(path, { hash, stat });
|
||||
if (known.hash !== hash) await this.record(path, known.hash, hash, at);
|
||||
return known.hash !== hash || known.stat !== stat;
|
||||
@@ -630,7 +682,7 @@ class Engine {
|
||||
const entry: HistoryEntry = { ...pending, endedAt, ...extra };
|
||||
this.log.entries.push(entry);
|
||||
try {
|
||||
saveRecord(this.logFile, this.log, { type: "entry", entry });
|
||||
this.persistLog({ type: "entry", entry });
|
||||
} catch (error) {
|
||||
this.log.entries.pop();
|
||||
throw error;
|
||||
@@ -657,7 +709,7 @@ class Engine {
|
||||
new Set([...referencedHashes(this.log, this.manifest()), ...this.pendingHashes()]),
|
||||
);
|
||||
}
|
||||
if (folded) writeLog(this.logFile, this.log);
|
||||
if (folded) this.persistLog();
|
||||
}
|
||||
|
||||
/** Hashes only pending changes point at yet, such as a claim's stored cut. */
|
||||
@@ -688,7 +740,9 @@ class Engine {
|
||||
}
|
||||
|
||||
background(task: () => Promise<unknown>): void {
|
||||
this.queue(task).catch((error) => this.options.onError?.(error));
|
||||
this.queue(task).catch((error) => {
|
||||
if (!(error instanceof HistoryClosedError)) this.options.onError?.(error);
|
||||
});
|
||||
}
|
||||
|
||||
beginWindow(who: HistoryWho, label: string, idleMs: number): Promise<HistoryWindow> {
|
||||
@@ -770,6 +824,7 @@ class Engine {
|
||||
target: Map<string, string | null>,
|
||||
extra: Partial<HistoryEntry>,
|
||||
): Promise<HistoryEntry | null> {
|
||||
this.assertWritable();
|
||||
const changes = [...target]
|
||||
.filter(([path, hash]) => (this.tracked.get(path)?.hash ?? null) !== hash)
|
||||
.sort(([, a], [, b]) => Number(a !== null) - Number(b !== null));
|
||||
@@ -792,16 +847,19 @@ class Engine {
|
||||
|
||||
/** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
|
||||
async writeProjectFile(path: string, hash: string | null): Promise<void> {
|
||||
this.assertWritable();
|
||||
const absPath = join(this.dir, path);
|
||||
const { writeToken } = this;
|
||||
if (writeToken) {
|
||||
const version = hash === null ? DELETED_VERSION : hashVersion(hash);
|
||||
recordFileWriteReceipt(absPath, { path, version, writeToken });
|
||||
}
|
||||
if (hash === null) await rm(absPath, { force: true });
|
||||
else {
|
||||
if (hash === null) {
|
||||
this.assertWritable();
|
||||
rmSync(absPath, { force: true });
|
||||
} else {
|
||||
await removeEmptyFolders(absPath);
|
||||
await this.blobs.writeTo(hash, absPath);
|
||||
await this.blobs.writeTo(hash, absPath, () => this.assertWritable());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,8 +963,11 @@ class Engine {
|
||||
this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 30_000),
|
||||
claim: (who, label, paths, options = {}) =>
|
||||
this.queue(() => this.claimNow(who, label, paths, options)),
|
||||
noteChange: (path) => this.noteChange(path),
|
||||
noteChange: (path) => {
|
||||
if (!this.closed) this.noteChange(path);
|
||||
},
|
||||
list: () => {
|
||||
this.assertOpen();
|
||||
const undone = undoneIds(this.log.entries);
|
||||
return this.log.entries.map((entry) => ({
|
||||
...entry,
|
||||
@@ -926,6 +987,7 @@ class Engine {
|
||||
this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`),
|
||||
),
|
||||
peek: (point) => {
|
||||
this.assertOpen();
|
||||
const files = manifestAt(this.log, point);
|
||||
return files && Object.fromEntries(files);
|
||||
},
|
||||
@@ -947,23 +1009,34 @@ class Engine {
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
next: (direction, who) => this.next(direction, who),
|
||||
readBlob: (hash) => this.blobs.read(hash),
|
||||
next: (direction, who) => {
|
||||
this.assertOpen();
|
||||
return this.next(direction, who);
|
||||
},
|
||||
readBlob: async (hash) => {
|
||||
this.assertOpen();
|
||||
return this.blobs.read(hash);
|
||||
},
|
||||
pin: (id, pinned) => {
|
||||
this.assertOpen();
|
||||
this.entry(id);
|
||||
if (pinned) this.log.pins.add(id);
|
||||
else this.log.pins.delete(id);
|
||||
saveRecord(this.logFile, this.log, { type: "pin", id, pinned });
|
||||
this.persistLog({ type: "pin", id, pinned });
|
||||
},
|
||||
onEntry: (listener) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
},
|
||||
flush: () => this.queue(() => this.settleAll()),
|
||||
close: async () => {
|
||||
if (this.notedTimer) clearTimeout(this.notedTimer);
|
||||
await this.queue(() => this.settleAll());
|
||||
},
|
||||
replacedAtPath: () => this.whereFolder() === "replaced",
|
||||
close: () =>
|
||||
(this.closing ??= (async () => {
|
||||
if (this.notedTimer) clearTimeout(this.notedTimer);
|
||||
const settled = this.queue(() => this.settleAll());
|
||||
this.closed = true;
|
||||
await settled;
|
||||
})()),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -981,8 +1054,18 @@ function missingIsEmpty(error: NodeJS.ErrnoException): string[] {
|
||||
throw error;
|
||||
}
|
||||
|
||||
/** A call on a history that was closed, or whose folder is now another project. */
|
||||
export class HistoryClosedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "HistoryClosedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a project's history: every write to its files becomes an entry that can be undone or restored. */
|
||||
export async function openProjectHistory(options: ProjectHistoryOptions): Promise<ProjectHistory> {
|
||||
if (!existsSync(options.projectDir))
|
||||
throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
|
||||
const projectId = projectHistoryId(options.projectDir, options.historyRoot);
|
||||
const release = await takeHistoryOwnership(
|
||||
join(options.historyRoot, projectId),
|
||||
|
||||
@@ -13,8 +13,10 @@ export {
|
||||
type HistoryListItem,
|
||||
type HistoryResult,
|
||||
type HistoryWindow,
|
||||
HistoryClosedError,
|
||||
} from "./history/projectHistory.js";
|
||||
export { HistoryBusyError } from "./history/ownerLock.js";
|
||||
export { historyCache } from "./history/historyCache.js";
|
||||
export {
|
||||
START as HISTORY_START,
|
||||
type HistoryEntry,
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, win32 } from "node:path";
|
||||
import { isValidProjectId } from "./src/utils/projectRouting";
|
||||
@@ -52,6 +61,46 @@ describe("Studio's dev server keeps each project's history", () => {
|
||||
await history?.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens a new project's own history once it takes the folder's path", async () => {
|
||||
const { data, adapter, app } = fixture();
|
||||
mkdirSync(join(data, "demo"));
|
||||
writeFileSync(join(data, "demo", "index.html"), "A");
|
||||
const old = await adapter.history!(adapter.resolveProject("demo")!);
|
||||
renameSync(join(data, "demo"), join(data, "demo-moved"));
|
||||
mkdirSync(join(data, "demo"));
|
||||
writeFileSync(join(data, "demo", "index.html"), "new");
|
||||
|
||||
const list = await app.request("http://localhost/projects/demo/history");
|
||||
expect(list.status).toBe(200);
|
||||
expect(existsSync(join(data, "demo", ".hyperframes", "history-id"))).toBe(true);
|
||||
const fresh = await adapter.history!(adapter.resolveProject("demo")!);
|
||||
expect(fresh?.projectId).not.toBe(old?.projectId);
|
||||
await fresh?.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Studio's dev server retries a history whose folder changed while it opened", () => {
|
||||
it("opens it again on the next request", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "hf-project-history-retry-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "demo"));
|
||||
const history = { replacedAtPath: () => false } as unknown as ProjectHistory;
|
||||
const openHistory = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("now another project"), { name: "HistoryClosedError" }),
|
||||
)
|
||||
.mockResolvedValue(history);
|
||||
const adapter = createViteAdapter(
|
||||
root,
|
||||
{} as ViteDevServer,
|
||||
createProjectSignatureCache({ compute: () => "test" }),
|
||||
{ openHistory },
|
||||
);
|
||||
expect(await adapter.history!(adapter.resolveProject("demo")!)).toBeNull();
|
||||
expect(await adapter.history!(adapter.resolveProject("demo")!)).toBe(history);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Studio's dev server closes the histories it opened when it stops", () => {
|
||||
@@ -61,7 +110,9 @@ describe("Studio's dev server closes the histories it opened when it stops", ()
|
||||
mkdirSync(join(root, "demo"));
|
||||
const httpServer = new EventEmitter();
|
||||
const close = vi.fn(async () => {});
|
||||
const openHistory = vi.fn(async () => ({ close }) as unknown as ProjectHistory);
|
||||
const openHistory = vi.fn(
|
||||
async () => ({ close, replacedAtPath: () => false }) as unknown as ProjectHistory,
|
||||
);
|
||||
const adapter = createViteAdapter(
|
||||
root,
|
||||
{ httpServer } as unknown as ViteDevServer,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
copyFileSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { join, relative, resolve, isAbsolute, dirname, sep } from "node:path";
|
||||
import { basename, join, relative, resolve, isAbsolute, dirname, sep } from "node:path";
|
||||
import type { ViteDevServer } from "vite";
|
||||
import {
|
||||
type ResolvedProject,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
PREVIEW_BUNDLE_OPTIONS,
|
||||
DEFAULT_HISTORY_ROOT,
|
||||
openProjectHistory,
|
||||
type ProjectHistory,
|
||||
historyCache,
|
||||
} from "@hyperframes/studio-server";
|
||||
import type { RegistryItem } from "@hyperframes/core/registry";
|
||||
import type { BundleOptions } from "@hyperframes/core/compiler";
|
||||
@@ -109,11 +109,17 @@ export function createViteAdapter(
|
||||
openHistory = openProjectHistory,
|
||||
}: { historyRoot?: string; openHistory?: typeof openProjectHistory } = {},
|
||||
): StudioApiAdapter {
|
||||
const histories = new Map<string, Promise<ProjectHistory | null>>();
|
||||
const histories = historyCache((projectDir) =>
|
||||
openHistory({ projectDir, historyRoot }).catch((error: unknown) => {
|
||||
console.warn(`[studio] Project history is off for ${basename(projectDir)}: ${String(error)}`);
|
||||
// By name: the dev server's engine is its own module copy, so its error class is not this import's.
|
||||
if (error instanceof Error && error.name === "HistoryClosedError")
|
||||
histories.forget(projectDir);
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
// Commits any open edit when the dev server stops, so it keeps its label.
|
||||
server.httpServer?.on("close", () => {
|
||||
for (const opened of histories.values()) void opened.then((history) => history?.close());
|
||||
});
|
||||
server.httpServer?.on("close", () => void histories.closeAll());
|
||||
let _bundler: ((dir: string, options?: BundleOptions) => Promise<string>) | null = null;
|
||||
let _producerModuleLoader:
|
||||
| (() => Promise<{
|
||||
@@ -212,17 +218,10 @@ export function createViteAdapter(
|
||||
.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
|
||||
},
|
||||
|
||||
// Studio's undo runs on the project's history: opened once per project, and a failed open stays off.
|
||||
// Studio's undo runs on the project's history: opened once per project; a failed open stays off unless the folder
|
||||
// changed while it opened.
|
||||
history(project: ResolvedProject) {
|
||||
let opened = histories.get(project.dir);
|
||||
if (!opened) {
|
||||
opened = openHistory({ projectDir: project.dir, historyRoot }).catch((error: unknown) => {
|
||||
console.warn(`[studio] Project history is off for ${project.id}: ${String(error)}`);
|
||||
return null;
|
||||
});
|
||||
histories.set(project.dir, opened);
|
||||
}
|
||||
return opened;
|
||||
return histories.get(project.dir);
|
||||
},
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
|
||||
Reference in New Issue
Block a user