From 1f3236922501c38fe8fc059bfbff2205a524f81c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 08:12:47 -0400 Subject: [PATCH 1/8] fix(studio-server): undo and restore keep the media they delete or replace --- .../studio-server/src/history/blobStore.ts | 64 +++++++++----- .../src/history/projectHistory.ts | 3 + .../src/history/undoUncopied.test.ts | 87 +++++++++++++++++++ scripts/package-size-budgets.json | 4 +- 4 files changed, 136 insertions(+), 22 deletions(-) create mode 100644 packages/studio-server/src/history/undoUncopied.test.ts diff --git a/packages/studio-server/src/history/blobStore.ts b/packages/studio-server/src/history/blobStore.ts index 52dc05ab4ea..1f8ae458248 100644 --- a/packages/studio-server/src/history/blobStore.ts +++ b/packages/studio-server/src/history/blobStore.ts @@ -16,6 +16,7 @@ export interface BlobStore { writeTo(hash: string, absPath: string, beforeReplace?: () => void): Promise; bytes(): number; size(hash: string): number; + keep(absPath: string, remove: boolean): Promise; prune(keep: ReadonlySet): Promise; drop(hashes: Iterable): Promise; } @@ -60,11 +61,27 @@ async function cloneOrCopy(from: string, to: string, beforeReplace?: () => void) export async function openBlobStore(dir: string): Promise { const sizes = new Map(); + // Bytes history moved out of the project live apart from the blobs, so reclaiming old media copies never takes them. + const asideDir = join(dirname(dir), "aside"); + const aside = new Set(); await mkdir(dir, { recursive: true }); - for (const shard of await readdir(dir)) - for (const hash of await readdir(join(dir, shard)).catch(() => [])) - sizes.set(hash, (await stat(join(dir, shard, hash))).size); - const pathOf = (hash: string) => blobPath(dir, hash); + for (const [root, isAside] of [ + [dir, false], + [asideDir, true], + ] as const) + for (const shard of await readdir(root).catch(() => [])) + for (const hash of await readdir(join(root, shard)).catch(() => [])) { + if (sizes.has(hash)) continue; + sizes.set(hash, (await stat(join(root, shard, hash))).size); + if (isAside) aside.add(hash); + } + const pathOf = (hash: string) => blobPath(aside.has(hash) ? asideDir : dir, hash); + const forget = async (hash: string) => { + await rm(pathOf(hash), { force: true }); + total -= sizes.get(hash) ?? 0; + sizes.delete(hash); + aside.delete(hash); + }; let total = [...sizes.values()].reduce((sum, size) => sum + size, 0); return { @@ -91,27 +108,34 @@ export async function openBlobStore(dir: string): Promise { }, reference: (absPath) => hashFile(absPath), has: (hash) => sizes.has(hash), - read: async (hash) => readBlob(dir, hash), - writeTo: async (hash, absPath, beforeReplace) => - cloneOrCopy(pathOf(hash), absPath, beforeReplace), + read: (hash) => readFile(pathOf(hash)), + async writeTo(hash, absPath, beforeReplace) { + await cloneOrCopy(pathOf(hash), absPath, beforeReplace); + if (aside.has(hash)) await forget(hash); + }, + async keep(absPath, remove) { + const hash = await hashFile(absPath); + if (sizes.has(hash)) return; + const to = blobPath(asideDir, hash); + await mkdir(dirname(to), { recursive: true }); + const moved = + remove && + (await rename(absPath, to).then( + () => true, + () => false, + )); + if (!moved) await cloneOrStream(absPath, to); + sizes.set(hash, (await stat(to)).size); + aside.add(hash); + total += sizes.get(hash)!; + }, bytes: () => total, size: (hash) => sizes.get(hash) ?? 0, async drop(hashes) { - for (const hash of hashes) { - const size = sizes.get(hash); - if (size === undefined) continue; - await rm(pathOf(hash), { force: true }); - sizes.delete(hash); - total -= size; - } + for (const hash of hashes) if (sizes.has(hash) && !aside.has(hash)) await forget(hash); }, async prune(keep) { - for (const [hash, size] of sizes) { - if (keep.has(hash)) continue; - await rm(pathOf(hash), { force: true }); - sizes.delete(hash); - total -= size; - } + for (const hash of [...sizes.keys()]) if (!keep.has(hash)) await forget(hash); }, }; } diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index ec389369ef2..5f7fbb105e3 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -1138,6 +1138,9 @@ class Engine { const version = hash === null ? DELETED_VERSION : hashVersion(hash); recordFileWriteReceipt(absPath, { path, version, writeToken }); } + const standing = lstatSync(absPath, { throwIfNoEntry: false }); + if (standing?.isFile() && isUncopied(path, standing.size)) + await this.blobs.keep(absPath, hash === null); if (hash === null) { this.assertWritable(); rmSync(absPath, { force: true }); diff --git a/packages/studio-server/src/history/undoUncopied.test.ts b/packages/studio-server/src/history/undoUncopied.test.ts new file mode 100644 index 00000000000..220513ddec1 --- /dev/null +++ b/packages/studio-server/src/history/undoUncopied.test.ts @@ -0,0 +1,87 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { openProjectHistory, type ProjectHistory } from "./projectHistory"; +import type { HistoryWho } from "./historyLog"; + +const you: HistoryWho = { kind: "person", name: "You" }; +const MiB = 1024 ** 2; +const cleanup: Array<() => unknown> = []; +afterEach(async () => { + 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; +} + +async function open(projectDir: string, historyRoot: string) { + const history = await openProjectHistory({ projectDir, historyRoot, quietMs: 30 }); + cleanup.push(() => history.close()); + return history; +} + +async function change(history: ProjectHistory, label: string, writes: () => void) { + const window = await history.beginWindow(you, label); + writes(); + const entry = await window.close(); + if (!entry) throw new Error("the window recorded nothing"); + return entry; +} + +async function project() { + const projectDir = tempDir("hf-undo-media-project-"); + const historyRoot = tempDir("hf-undo-media-root-"); + writeFileSync(join(projectDir, "index.html"), "A"); + const history = await open(projectDir, historyRoot); + const media = join(projectDir, "join.mp4"); + return { projectDir, historyRoot, history, media }; +} + +describe("undoing a change that made or replaced media", () => { + it("keeps the made file's bytes, so redo brings the same file back", async () => { + const { history, media } = await project(); + const bytes = Buffer.alloc(2 * MiB, 7); + const made = await change(history, "Place join", () => writeFileSync(media, bytes)); + + const undone = await history.undo(made.id, { who: you }); + expect(existsSync(media)).toBe(false); + + const redone = await history.undo(undone.ok ? undone.entry!.id : "", { who: you }); + expect(redone).toMatchObject({ ok: true }); + expect(readFileSync(media).equals(bytes)).toBe(true); + }); + + it("still brings the file back after the history is closed and opened again", async () => { + const { history, media, projectDir, historyRoot } = await project(); + const bytes = Buffer.alloc(2 * MiB, 9); + const made = await change(history, "Place join", () => writeFileSync(media, bytes)); + const undone = await history.undo(made.id, { who: you }); + await history.close(); + + const reopened = await open(projectDir, historyRoot); + const redone = await reopened.undo(undone.ok ? undone.entry!.id : "", { who: you }); + expect(redone).toMatchObject({ ok: true }); + expect(readFileSync(media).equals(bytes)).toBe(true); + }); + + it("keeps the bytes a restore overwrites, so undoing the restore returns them", async () => { + const { history, media, projectDir } = await project(); + const first = Buffer.alloc(2 * MiB, 1); + const second = Buffer.alloc(2 * MiB, 2); + const made = await change(history, "Place join", () => writeFileSync(media, first)); + await history.undo(made.id, { who: you }); + await change(history, "New join", () => writeFileSync(media, second)); + mkdirSync(join(projectDir, "x"), { recursive: true }); + + const restored = await history.restore(made.id, you); + expect(readFileSync(media).equals(first)).toBe(true); + + const back = await history.undo(restored!.id, { who: you }); + expect(back).toMatchObject({ ok: true }); + expect(readFileSync(media).equals(second)).toBe(true); + }); +}); diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 1e5ae92bcd9..4dd3c33ee66 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -52,7 +52,7 @@ "unpacked": 24360208 }, "@hyperframes/studio-server": { - "packed": 143700, - "unpacked": 559199 + "packed": 144000, + "unpacked": 568000 } } From 88f443168dd75d6908a69fd528cfbb090234c946 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 08:19:04 -0400 Subject: [PATCH 2/8] fix(studio-server): set-aside media is freed only after a whole undo or restore lands --- .../studio-server/src/history/blobStore.ts | 8 ++++-- .../src/history/projectHistory.ts | 1 + .../src/history/undoUncopied.test.ts | 28 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/studio-server/src/history/blobStore.ts b/packages/studio-server/src/history/blobStore.ts index 1f8ae458248..d023cbffcb6 100644 --- a/packages/studio-server/src/history/blobStore.ts +++ b/packages/studio-server/src/history/blobStore.ts @@ -17,6 +17,7 @@ export interface BlobStore { bytes(): number; size(hash: string): number; keep(absPath: string, remove: boolean): Promise; + release(hashes: Iterable): Promise; prune(keep: ReadonlySet): Promise; drop(hashes: Iterable): Promise; } @@ -109,9 +110,10 @@ export async function openBlobStore(dir: string): Promise { reference: (absPath) => hashFile(absPath), has: (hash) => sizes.has(hash), read: (hash) => readFile(pathOf(hash)), - async writeTo(hash, absPath, beforeReplace) { - await cloneOrCopy(pathOf(hash), absPath, beforeReplace); - if (aside.has(hash)) await forget(hash); + writeTo: async (hash, absPath, beforeReplace) => + cloneOrCopy(pathOf(hash), absPath, beforeReplace), + async release(hashes) { + for (const hash of hashes) if (aside.has(hash)) await forget(hash); }, async keep(absPath, remove) { const hash = await hashFile(absPath); diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index 5f7fbb105e3..8d6d66615b9 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -1122,6 +1122,7 @@ class Engine { this.windows.push(group); try { for (const [path, hash] of changes) await this.writeProjectFile(path, hash); + await this.blobs.release(changes.flatMap(([, hash]) => (hash ? [hash] : []))); await this.sweep(); } finally { this.windows = this.windows.filter((open) => open !== group); diff --git a/packages/studio-server/src/history/undoUncopied.test.ts b/packages/studio-server/src/history/undoUncopied.test.ts index 220513ddec1..02e32099da3 100644 --- a/packages/studio-server/src/history/undoUncopied.test.ts +++ b/packages/studio-server/src/history/undoUncopied.test.ts @@ -84,4 +84,32 @@ describe("undoing a change that made or replaced media", () => { expect(back).toMatchObject({ ok: true }); expect(readFileSync(media).equals(second)).toBe(true); }); + + it("does not spend the set-aside bytes when an old version is only checked out", async () => { + const { history, media } = await project(); + const bytes = Buffer.alloc(2 * MiB, 3); + const made = await change(history, "Place join", () => writeFileSync(media, bytes)); + const undone = await history.undo(made.id, { who: you }); + + await history.checkout(made.id, "after", tempDir("hf-undo-media-out-")); + + const redone = await history.undo(undone.ok ? undone.entry!.id : "", { who: you }); + expect(redone).toMatchObject({ ok: true }); + expect(readFileSync(media).equals(bytes)).toBe(true); + }); + + it("brings back two files with the same bytes made in one change", async () => { + const { history, projectDir } = await project(); + const bytes = Buffer.alloc(2 * MiB, 4); + const made = await change(history, "Place joins", () => { + writeFileSync(join(projectDir, "a.mp4"), bytes); + writeFileSync(join(projectDir, "b.mp4"), bytes); + }); + const undone = await history.undo(made.id, { who: you }); + + const redone = await history.undo(undone.ok ? undone.entry!.id : "", { who: you }); + expect(redone).toMatchObject({ ok: true }); + for (const name of ["a.mp4", "b.mp4"]) + expect(readFileSync(join(projectDir, name)).equals(bytes)).toBe(true); + }); }); From b2d4b27af20fddc671593630e093730fab30e405 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 08:49:39 -0400 Subject: [PATCH 3/8] fix(studio-server): set-aside media does not count toward the history budget --- .../studio-server/src/history/blobStore.ts | 5 ++--- .../src/history/undoUncopied.test.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/studio-server/src/history/blobStore.ts b/packages/studio-server/src/history/blobStore.ts index d023cbffcb6..84846d65a8e 100644 --- a/packages/studio-server/src/history/blobStore.ts +++ b/packages/studio-server/src/history/blobStore.ts @@ -79,11 +79,11 @@ export async function openBlobStore(dir: string): Promise { const pathOf = (hash: string) => blobPath(aside.has(hash) ? asideDir : dir, hash); const forget = async (hash: string) => { await rm(pathOf(hash), { force: true }); - total -= sizes.get(hash) ?? 0; + if (!aside.has(hash)) total -= sizes.get(hash) ?? 0; sizes.delete(hash); aside.delete(hash); }; - let total = [...sizes.values()].reduce((sum, size) => sum + size, 0); + let total = [...sizes].reduce((sum, [hash, size]) => sum + (aside.has(hash) ? 0 : size), 0); return { async put(absPath) { @@ -129,7 +129,6 @@ export async function openBlobStore(dir: string): Promise { if (!moved) await cloneOrStream(absPath, to); sizes.set(hash, (await stat(to)).size); aside.add(hash); - total += sizes.get(hash)!; }, bytes: () => total, size: (hash) => sizes.get(hash) ?? 0, diff --git a/packages/studio-server/src/history/undoUncopied.test.ts b/packages/studio-server/src/history/undoUncopied.test.ts index 02e32099da3..308110871dd 100644 --- a/packages/studio-server/src/history/undoUncopied.test.ts +++ b/packages/studio-server/src/history/undoUncopied.test.ts @@ -112,4 +112,22 @@ describe("undoing a change that made or replaced media", () => { for (const name of ["a.mp4", "b.mp4"]) expect(readFileSync(join(projectDir, name)).equals(bytes)).toBe(true); }); + + it("does not fold the history away when the media it keeps is larger than the budget", async () => { + const projectDir = tempDir("hf-undo-media-project-"); + const historyRoot = tempDir("hf-undo-media-root-"); + writeFileSync(join(projectDir, "index.html"), "A"); + const history = await openProjectHistory({ projectDir, historyRoot, budgetBytes: MiB }); + cleanup.push(() => history.close()); + const media = join(projectDir, "join.mp4"); + const bytes = Buffer.alloc(2 * MiB, 5); + const made = await change(history, "Place join", () => writeFileSync(media, bytes)); + + const undone = await history.undo(made.id, { who: you }); + const redone = await history.undo(undone.ok ? undone.entry!.id : "", { who: you }); + + expect(redone).toMatchObject({ ok: true }); + expect(readFileSync(media).equals(bytes)).toBe(true); + expect(history.list().length).toBeGreaterThanOrEqual(3); + }); }); From b5f1c17c0c73d2b3d8388a6dbe422862327d0f2c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 10:46:55 -0400 Subject: [PATCH 4/8] fix(studio-server): set-aside media keeps its own ledger apart from copied blobs --- .../studio-server/src/history/blobStore.ts | 53 +++++++++++-------- .../src/history/undoUncopied.test.ts | 52 +++++++++++++++++- 2 files changed, 81 insertions(+), 24 deletions(-) diff --git a/packages/studio-server/src/history/blobStore.ts b/packages/studio-server/src/history/blobStore.ts index 84846d65a8e..b85632dca31 100644 --- a/packages/studio-server/src/history/blobStore.ts +++ b/packages/studio-server/src/history/blobStore.ts @@ -64,26 +64,26 @@ export async function openBlobStore(dir: string): Promise { const sizes = new Map(); // Bytes history moved out of the project live apart from the blobs, so reclaiming old media copies never takes them. const asideDir = join(dirname(dir), "aside"); - const aside = new Set(); + const asideSizes = new Map(); await mkdir(dir, { recursive: true }); - for (const [root, isAside] of [ - [dir, false], - [asideDir, true], + for (const [root, found] of [ + [dir, sizes], + [asideDir, asideSizes], ] as const) for (const shard of await readdir(root).catch(() => [])) - for (const hash of await readdir(join(root, shard)).catch(() => [])) { - if (sizes.has(hash)) continue; - sizes.set(hash, (await stat(join(root, shard, hash))).size); - if (isAside) aside.add(hash); - } - const pathOf = (hash: string) => blobPath(aside.has(hash) ? asideDir : dir, hash); + for (const hash of await readdir(join(root, shard)).catch(() => [])) + found.set(hash, (await stat(join(root, shard, hash))).size); + const pathOf = (hash: string) => blobPath(sizes.has(hash) ? dir : asideDir, hash); + let total = [...sizes.values()].reduce((sum, size) => sum + size, 0); const forget = async (hash: string) => { - await rm(pathOf(hash), { force: true }); - if (!aside.has(hash)) total -= sizes.get(hash) ?? 0; + await rm(blobPath(dir, hash), { force: true }); + total -= sizes.get(hash) ?? 0; sizes.delete(hash); - aside.delete(hash); }; - let total = [...sizes].reduce((sum, [hash, size]) => sum + (aside.has(hash) ? 0 : size), 0); + const forgetAside = async (hash: string) => { + await rm(blobPath(asideDir, hash), { force: true }); + asideSizes.delete(hash); + }; return { async put(absPath) { @@ -100,24 +100,24 @@ export async function openBlobStore(dir: string): Promise { await rm(temp, { force: true }); return hash; } - await mkdir(dirname(pathOf(hash)), { recursive: true }); + await mkdir(dirname(blobPath(dir, hash)), { recursive: true }); const size = (await stat(temp)).size; - await rename(temp, pathOf(hash)); + await rename(temp, blobPath(dir, hash)); sizes.set(hash, size); total += size; return hash; }, reference: (absPath) => hashFile(absPath), - has: (hash) => sizes.has(hash), + has: (hash) => sizes.has(hash) || asideSizes.has(hash), read: (hash) => readFile(pathOf(hash)), writeTo: async (hash, absPath, beforeReplace) => cloneOrCopy(pathOf(hash), absPath, beforeReplace), async release(hashes) { - for (const hash of hashes) if (aside.has(hash)) await forget(hash); + for (const hash of hashes) if (asideSizes.has(hash)) await forgetAside(hash); }, async keep(absPath, remove) { const hash = await hashFile(absPath); - if (sizes.has(hash)) return; + if (sizes.has(hash) || asideSizes.has(hash)) return; const to = blobPath(asideDir, hash); await mkdir(dirname(to), { recursive: true }); const moved = @@ -126,17 +126,24 @@ export async function openBlobStore(dir: string): Promise { () => true, () => false, )); - if (!moved) await cloneOrStream(absPath, to); - sizes.set(hash, (await stat(to)).size); - aside.add(hash); + if (!moved) { + const temp = join(asideDir, `incoming-${randomUUID()}`); + await cloneOrStream(absPath, temp).catch(async (error) => { + await rm(temp, { force: true }); + throw error; + }); + await rename(temp, to); + } + asideSizes.set(hash, (await stat(to)).size); }, bytes: () => total, size: (hash) => sizes.get(hash) ?? 0, async drop(hashes) { - for (const hash of hashes) if (sizes.has(hash) && !aside.has(hash)) await forget(hash); + for (const hash of hashes) if (sizes.has(hash)) await forget(hash); }, async prune(keep) { for (const hash of [...sizes.keys()]) if (!keep.has(hash)) await forget(hash); + for (const hash of [...asideSizes.keys()]) if (!keep.has(hash)) await forgetAside(hash); }, }; } diff --git a/packages/studio-server/src/history/undoUncopied.test.ts b/packages/studio-server/src/history/undoUncopied.test.ts index 308110871dd..ab50460652b 100644 --- a/packages/studio-server/src/history/undoUncopied.test.ts +++ b/packages/studio-server/src/history/undoUncopied.test.ts @@ -1,7 +1,16 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { openBlobStore } from "./blobStore"; import { openProjectHistory, type ProjectHistory } from "./projectHistory"; import type { HistoryWho } from "./historyLog"; @@ -131,3 +140,44 @@ describe("undoing a change that made or replaced media", () => { expect(history.list().length).toBeGreaterThanOrEqual(3); }); }); + +describe("the set-aside store beside the blobs", () => { + async function stores() { + const home = tempDir("hf-aside-home-"); + const source = join(home, "join.mp4"); + writeFileSync(source, Buffer.alloc(2 * MiB, 6)); + const blobs = await openBlobStore(join(home, "blobs")); + const hash = await blobs.reference(source); + return { home, source, blobs, hash }; + } + + it("keeps a copied blob of the same bytes when the set-aside copy is released", async () => { + const { home, source, blobs, hash } = await stores(); + await blobs.keep(source, false); + const copy = join(home, "backup.txt"); + writeFileSync(copy, readFileSync(source)); + + expect(await blobs.put(copy)).toBe(hash); + await blobs.release([hash]); + + expect(blobs.has(hash)).toBe(true); + expect((await blobs.read(hash)).length).toBe(2 * MiB); + }); + + it("counts for no bytes and no size until it is released", async () => { + const { source, blobs, hash } = await stores(); + await blobs.keep(source, true); + + expect([blobs.has(hash), blobs.bytes(), blobs.size(hash)]).toEqual([true, 0, 0]); + expect(existsSync(source)).toBe(false); + }); + + it("leaves only the finished copy in the set-aside folder", async () => { + const { home, source, blobs, hash } = await stores(); + await blobs.keep(source, false); + + const aside = join(home, "aside"); + expect(readdirSync(aside)).toEqual([hash.slice(0, 2)]); + expect(readdirSync(join(aside, hash.slice(0, 2)))).toEqual([hash]); + }); +}); From 34c5c26679342f3f4b8707b084bf6a2436c5d573 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 11:27:57 -0400 Subject: [PATCH 5/8] fix(studio-server): a set-aside copy waits for every path that counts on it --- .../studio-server/src/history/blobStore.ts | 24 +++++++++---- .../src/history/projectHistory.ts | 6 ++-- .../src/history/undoUncopied.test.ts | 34 ++++++++++++++++--- scripts/package-size-budgets.json | 4 +-- 4 files changed, 54 insertions(+), 14 deletions(-) diff --git a/packages/studio-server/src/history/blobStore.ts b/packages/studio-server/src/history/blobStore.ts index b85632dca31..4a4e0bb193f 100644 --- a/packages/studio-server/src/history/blobStore.ts +++ b/packages/studio-server/src/history/blobStore.ts @@ -16,8 +16,8 @@ export interface BlobStore { writeTo(hash: string, absPath: string, beforeReplace?: () => void): Promise; bytes(): number; size(hash: string): number; - keep(absPath: string, remove: boolean): Promise; - release(hashes: Iterable): Promise; + keep(path: string, absPath: string, remove: boolean): Promise; + release(held: Iterable): Promise; prune(keep: ReadonlySet): Promise; drop(hashes: Iterable): Promise; } @@ -65,6 +65,8 @@ export async function openBlobStore(dir: string): Promise { // Bytes history moved out of the project live apart from the blobs, so reclaiming old media copies never takes them. const asideDir = join(dirname(dir), "aside"); const asideSizes = new Map(); + // The project paths still counting on each set-aside copy; it goes when the last one has its file back. + const holds = new Map>(); await mkdir(dir, { recursive: true }); for (const [root, found] of [ [dir, sizes], @@ -73,6 +75,7 @@ export async function openBlobStore(dir: string): Promise { for (const shard of await readdir(root).catch(() => [])) for (const hash of await readdir(join(root, shard)).catch(() => [])) found.set(hash, (await stat(join(root, shard, hash))).size); + for (const hash of asideSizes.keys()) holds.set(hash, new Set(["*"])); const pathOf = (hash: string) => blobPath(sizes.has(hash) ? dir : asideDir, hash); let total = [...sizes.values()].reduce((sum, size) => sum + size, 0); const forget = async (hash: string) => { @@ -83,6 +86,7 @@ export async function openBlobStore(dir: string): Promise { const forgetAside = async (hash: string) => { await rm(blobPath(asideDir, hash), { force: true }); asideSizes.delete(hash); + holds.delete(hash); }; return { @@ -112,12 +116,19 @@ export async function openBlobStore(dir: string): Promise { read: (hash) => readFile(pathOf(hash)), writeTo: async (hash, absPath, beforeReplace) => cloneOrCopy(pathOf(hash), absPath, beforeReplace), - async release(hashes) { - for (const hash of hashes) if (asideSizes.has(hash)) await forgetAside(hash); + async release(held) { + for (const [hash, path] of held) { + holds.get(hash)?.delete(path); + if (holds.get(hash)?.size === 0) await forgetAside(hash); + } }, - async keep(absPath, remove) { + async keep(path, absPath, remove) { const hash = await hashFile(absPath); - if (sizes.has(hash) || asideSizes.has(hash)) return; + if (sizes.has(hash)) return; + if (asideSizes.has(hash)) { + holds.get(hash)!.add(path); + return; + } const to = blobPath(asideDir, hash); await mkdir(dirname(to), { recursive: true }); const moved = @@ -135,6 +146,7 @@ export async function openBlobStore(dir: string): Promise { await rename(temp, to); } asideSizes.set(hash, (await stat(to)).size); + holds.set(hash, new Set([path])); }, bytes: () => total, size: (hash) => sizes.get(hash) ?? 0, diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index 8d6d66615b9..8acaa9e75c6 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -1122,7 +1122,9 @@ class Engine { this.windows.push(group); try { for (const [path, hash] of changes) await this.writeProjectFile(path, hash); - await this.blobs.release(changes.flatMap(([, hash]) => (hash ? [hash] : []))); + await this.blobs.release( + changes.flatMap(([path, hash]) => (hash ? [[hash, path] as const] : [])), + ); await this.sweep(); } finally { this.windows = this.windows.filter((open) => open !== group); @@ -1141,7 +1143,7 @@ class Engine { } const standing = lstatSync(absPath, { throwIfNoEntry: false }); if (standing?.isFile() && isUncopied(path, standing.size)) - await this.blobs.keep(absPath, hash === null); + await this.blobs.keep(path, absPath, hash === null); if (hash === null) { this.assertWritable(); rmSync(absPath, { force: true }); diff --git a/packages/studio-server/src/history/undoUncopied.test.ts b/packages/studio-server/src/history/undoUncopied.test.ts index ab50460652b..0e6cbfb63be 100644 --- a/packages/studio-server/src/history/undoUncopied.test.ts +++ b/packages/studio-server/src/history/undoUncopied.test.ts @@ -141,6 +141,32 @@ describe("undoing a change that made or replaced media", () => { }); }); +describe("the same bytes at two paths in separate entries", () => { + it("brings both files back through undo and redo in any order", async () => { + const { history, projectDir } = await project(); + const bytes = Buffer.alloc(2 * MiB, 8); + const first = await change(history, "Place a", () => + writeFileSync(join(projectDir, "a.mp4"), bytes), + ); + const second = await change(history, "Place b", () => + writeFileSync(join(projectDir, "b.mp4"), bytes), + ); + const undoneSecond = await history.undo(second.id, { who: you }); + const undoneFirst = await history.undo(first.id, { who: you }); + + const redoneFirst = await history.undo(undoneFirst.ok ? undoneFirst.entry!.id : "", { + who: you, + }); + const redoneSecond = await history.undo(undoneSecond.ok ? undoneSecond.entry!.id : "", { + who: you, + }); + + expect([redoneFirst, redoneSecond]).toMatchObject([{ ok: true }, { ok: true }]); + for (const name of ["a.mp4", "b.mp4"]) + expect(readFileSync(join(projectDir, name)).equals(bytes)).toBe(true); + }); +}); + describe("the set-aside store beside the blobs", () => { async function stores() { const home = tempDir("hf-aside-home-"); @@ -153,12 +179,12 @@ describe("the set-aside store beside the blobs", () => { it("keeps a copied blob of the same bytes when the set-aside copy is released", async () => { const { home, source, blobs, hash } = await stores(); - await blobs.keep(source, false); + await blobs.keep("join.mp4", source, false); const copy = join(home, "backup.txt"); writeFileSync(copy, readFileSync(source)); expect(await blobs.put(copy)).toBe(hash); - await blobs.release([hash]); + await blobs.release([[hash, "join.mp4"]]); expect(blobs.has(hash)).toBe(true); expect((await blobs.read(hash)).length).toBe(2 * MiB); @@ -166,7 +192,7 @@ describe("the set-aside store beside the blobs", () => { it("counts for no bytes and no size until it is released", async () => { const { source, blobs, hash } = await stores(); - await blobs.keep(source, true); + await blobs.keep("join.mp4", source, true); expect([blobs.has(hash), blobs.bytes(), blobs.size(hash)]).toEqual([true, 0, 0]); expect(existsSync(source)).toBe(false); @@ -174,7 +200,7 @@ describe("the set-aside store beside the blobs", () => { it("leaves only the finished copy in the set-aside folder", async () => { const { home, source, blobs, hash } = await stores(); - await blobs.keep(source, false); + await blobs.keep("join.mp4", source, false); const aside = join(home, "aside"); expect(readdirSync(aside)).toEqual([hash.slice(0, 2)]); diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 4dd3c33ee66..49fe0f79aae 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -52,7 +52,7 @@ "unpacked": 24360208 }, "@hyperframes/studio-server": { - "packed": 144000, - "unpacked": 568000 + "packed": 144500, + "unpacked": 568500 } } From 57bdfad6f0afc4f22fd52cf66ce71f1f8c729da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 24 Oct 2026 13:20:29 -0400 Subject: [PATCH 6/8] fix(studio-server): an unreadable history folder fails the read instead of looking empty --- packages/studio-server/src/history/blobStore.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/studio-server/src/history/blobStore.ts b/packages/studio-server/src/history/blobStore.ts index 4a4e0bb193f..2291fff6065 100644 --- a/packages/studio-server/src/history/blobStore.ts +++ b/packages/studio-server/src/history/blobStore.ts @@ -60,6 +60,12 @@ async function cloneOrCopy(from: string, to: string, beforeReplace?: () => void) } } +const listDir = (path: string) => + readdir(path).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return []; + throw error; + }); + export async function openBlobStore(dir: string): Promise { const sizes = new Map(); // Bytes history moved out of the project live apart from the blobs, so reclaiming old media copies never takes them. @@ -72,8 +78,8 @@ export async function openBlobStore(dir: string): Promise { [dir, sizes], [asideDir, asideSizes], ] as const) - for (const shard of await readdir(root).catch(() => [])) - for (const hash of await readdir(join(root, shard)).catch(() => [])) + for (const shard of await listDir(root)) + for (const hash of await listDir(join(root, shard))) found.set(hash, (await stat(join(root, shard, hash))).size); for (const hash of asideSizes.keys()) holds.set(hash, new Set(["*"])); const pathOf = (hash: string) => blobPath(sizes.has(hash) ? dir : asideDir, hash); From b9158de6dd244b3dd4a3a52d5d16d3f8a4f55793 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 24 Oct 2026 14:49:31 -0400 Subject: [PATCH 7/8] fix(studio-server): history opens past a temp file a crash left behind --- .../studio-server/src/history/blobStore.ts | 14 ++++++++---- .../src/history/undoUncopied.test.ts | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/studio-server/src/history/blobStore.ts b/packages/studio-server/src/history/blobStore.ts index 2291fff6065..47f086fe442 100644 --- a/packages/studio-server/src/history/blobStore.ts +++ b/packages/studio-server/src/history/blobStore.ts @@ -61,7 +61,7 @@ async function cloneOrCopy(from: string, to: string, beforeReplace?: () => void) } const listDir = (path: string) => - readdir(path).catch((error: NodeJS.ErrnoException) => { + readdir(path, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return []; throw error; }); @@ -78,9 +78,15 @@ export async function openBlobStore(dir: string): Promise { [dir, sizes], [asideDir, asideSizes], ] as const) - for (const shard of await listDir(root)) - for (const hash of await listDir(join(root, shard))) - found.set(hash, (await stat(join(root, shard, hash))).size); + for (const shard of await listDir(root)) { + if (!shard.isDirectory()) { + // A crash mid-copy leaves its unpublished temp file beside the shards. + if (shard.name.startsWith("incoming-")) await rm(join(root, shard.name), { force: true }); + continue; + } + for (const hash of await listDir(join(root, shard.name))) + found.set(hash.name, (await stat(join(root, shard.name, hash.name))).size); + } for (const hash of asideSizes.keys()) holds.set(hash, new Set(["*"])); const pathOf = (hash: string) => blobPath(sizes.has(hash) ? dir : asideDir, hash); let total = [...sizes.values()].reduce((sum, size) => sum + size, 0); diff --git a/packages/studio-server/src/history/undoUncopied.test.ts b/packages/studio-server/src/history/undoUncopied.test.ts index 0e6cbfb63be..bab3e765236 100644 --- a/packages/studio-server/src/history/undoUncopied.test.ts +++ b/packages/studio-server/src/history/undoUncopied.test.ts @@ -1,4 +1,5 @@ import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -206,4 +207,25 @@ describe("the set-aside store beside the blobs", () => { expect(readdirSync(aside)).toEqual([hash.slice(0, 2)]); expect(readdirSync(join(aside, hash.slice(0, 2)))).toEqual([hash]); }); + + it("opens past a temp file a crash left mid-copy, and clears it", async () => { + const { home, source, blobs, hash } = await stores(); + await blobs.keep("join.mp4", source, false); + const leftovers = [join(home, "blobs", "incoming-1234"), join(home, "aside", "incoming-5678")]; + for (const leftover of leftovers) writeFileSync(leftover, "half a copy"); + + const reopened = await openBlobStore(join(home, "blobs")); + + expect(reopened.has(hash)).toBe(true); + expect(leftovers.some((leftover) => existsSync(leftover))).toBe(false); + }); + + it("still fails the open when the folder cannot be read", async () => { + const { home } = await stores(); + const blobsDir = join(home, "blobs"); + chmodSync(blobsDir, 0o111); + cleanup.push(() => chmodSync(blobsDir, 0o755)); + + await expect(openBlobStore(blobsDir)).rejects.toMatchObject({ code: "EACCES" }); + }); }); From d6878cd0e3eabf70a9199052e76a4b15ec4b673a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 24 Oct 2026 14:53:10 -0400 Subject: [PATCH 8/8] fix(studio-server): trim a comment so the comment share holds --- packages/studio-server/src/history/blobStore.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/studio-server/src/history/blobStore.ts b/packages/studio-server/src/history/blobStore.ts index 47f086fe442..d7b0a784fb0 100644 --- a/packages/studio-server/src/history/blobStore.ts +++ b/packages/studio-server/src/history/blobStore.ts @@ -68,7 +68,6 @@ const listDir = (path: string) => export async function openBlobStore(dir: string): Promise { const sizes = new Map(); - // Bytes history moved out of the project live apart from the blobs, so reclaiming old media copies never takes them. const asideDir = join(dirname(dir), "aside"); const asideSizes = new Map(); // The project paths still counting on each set-aside copy; it goes when the last one has its file back.