From 45220f2b86b950a83284903c8854f25834ffec32 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 09:14:25 -0700 Subject: [PATCH 1/3] feat(studio-server): a write receipt names the version the write replaced --- .../src/helpers/applyFileMutations.test.ts | 21 +++++++++++++++++ .../src/helpers/fileVersion.test.ts | 23 +++++++++++++++++++ .../studio-server/src/helpers/fileVersion.ts | 13 ++++++++--- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/studio-server/src/helpers/applyFileMutations.test.ts b/packages/studio-server/src/helpers/applyFileMutations.test.ts index 21ccb4b4d71..aa0940097d8 100644 --- a/packages/studio-server/src/helpers/applyFileMutations.test.ts +++ b/packages/studio-server/src/helpers/applyFileMutations.test.ts @@ -105,6 +105,27 @@ describe("applyFileMutations", () => { expectStaleMutation("after", false); }); + it("leaves a receipt saying which bytes the write started from", () => { + resetFileWriteReceipts(); + const projectDir = mkdtempSync(join(tmpdir(), "hf-mutation-from-")); + const path = join(projectDir, "index.html"); + try { + // Someone else's change lands before the patch reads the file: the receipt names it as the start. + writeFileSync(path, "external", "utf8"); + applyFileMutations( + projectDir, + [{ sourceFile: "index.html", absPath: path, after: "patched" }], + "hand", + ); + expect(identifyFileWrite(path, fileContentVersion("patched"))).toMatchObject({ + writeToken: "hand", + from: fileContentVersion("external"), + }); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + it("clears receipts for writes rolled back after a partial batch", () => { resetFileWriteReceipts(); const projectDir = mkdtempSync(join(tmpdir(), "hf-mutation-rollback-")); diff --git a/packages/studio-server/src/helpers/fileVersion.test.ts b/packages/studio-server/src/helpers/fileVersion.test.ts index e203ec804fa..bd3236f33f1 100644 --- a/packages/studio-server/src/helpers/fileVersion.test.ts +++ b/packages/studio-server/src/helpers/fileVersion.test.ts @@ -109,6 +109,29 @@ describe("file versions and write receipts", () => { expect(identifyFileWrite("/project/index.html", first.version)).toEqual(first); }); + it("says which version a write replaced when the writer read it, and nothing when it did not", () => { + const version = fileContentVersion("

Title!

"); + recordFileWriteReceipt("/project/index.html", { + path: "index.html", + version, + writeToken: "hand", + overwrote: "

Title

", + }); + expect(identifyFileWrite("/project/index.html", version)).toEqual({ + path: "index.html", + version, + writeToken: "hand", + from: fileContentVersion("

Title

"), + }); + const restored = fileContentVersion("

Old

"); + recordFileWriteReceipt("/project/index.html", { + path: "index.html", + version: restored, + writeToken: "restore", + }); + expect(identifyFileWrite("/project/index.html", restored)).not.toHaveProperty("from"); + }); + it("labels a repeat of earlier bytes with the newest token, not the spent one", () => { const version = fileContentVersion("same bytes"); const older = { path: "index.html", version, writeToken: "write-older" }; diff --git a/packages/studio-server/src/helpers/fileVersion.ts b/packages/studio-server/src/helpers/fileVersion.ts index d399f9afa34..69c41be7141 100644 --- a/packages/studio-server/src/helpers/fileVersion.ts +++ b/packages/studio-server/src/helpers/fileVersion.ts @@ -5,6 +5,9 @@ export interface FileWriteReceipt { path: string; version: string; writeToken: string; + /** The version the write replaced, when the writer read it: a host can tell a write applied to the bytes it last + * showed from one that merged someone else's change landing just before it. */ + from?: string; } interface StoredReceipt extends FileWriteReceipt { @@ -70,7 +73,11 @@ export function recordFileWriteReceipt( if (live.length > 0) receipts.set(path, live); else receipts.delete(path); } - receipts.set(absPath, [...(receipts.get(absPath) ?? []), { ...receipt, recordedAt: now }]); + const from = overwrote === undefined ? undefined : fileContentVersion(overwrote); + receipts.set(absPath, [ + ...(receipts.get(absPath) ?? []), + { ...receipt, ...(from && { from }), recordedAt: now }, + ]); } export function clearFileWriteReceipt(filePath: string, version: string, writeToken: string): void { @@ -96,8 +103,8 @@ export function identifyFileWrite( ): FileWriteReceipt | null { const receipt = newestReceipt(realFilePath(filePath), expectedVersion); if (!receipt) return null; - const { path, version, writeToken } = receipt; - return { path, version, writeToken }; + const { path, version, writeToken, from } = receipt; + return { path, version, writeToken, ...(from && { from }) }; } function newestReceipt(absPath: string, expectedVersion: string): StoredReceipt | undefined { From 497d827c448a9ea1e662f6701276f914133fa9f7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 10:31:01 -0700 Subject: [PATCH 2/3] test(studio-server): route receipts carry the version each write replaced --- packages/studio-server/src/routes/files.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index bb018acb129..ae52f716290 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -158,6 +158,7 @@ describe("registerFileRoutes", () => { path: "index.html", version: result.version, writeToken: "studio-insert-1", + from: fileContentVersion(before), }); const committed = result.after; @@ -428,6 +429,7 @@ describe("registerFileRoutes", () => { path: "image.png", version: payload.version, writeToken: "binary-write", + from: fileContentVersion(before), }); }); @@ -541,6 +543,7 @@ describe("registerFileRoutes", () => { path: "index.html", version: payload.version, writeToken: "studio-write-1", + from: fileContentVersion("before"), }); expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//); expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe("before"); @@ -734,6 +737,7 @@ describe("registerFileRoutes", () => { path: "index.html", version, writeToken: "studio-patch-1", + from: fileContentVersion('
Before
'), }); }); @@ -780,6 +784,7 @@ describe("registerFileRoutes", () => { path: "index.html", version, writeToken: "studio-layer-order-1", + from: fileContentVersion(original), }); expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(1); }); @@ -884,6 +889,7 @@ describe("registerFileRoutes", () => { path: file.sourceFile, version, writeToken: "studio-group-drag-1", + from: fileContentVersion(`
Before
`), }); } }); @@ -1105,6 +1111,7 @@ describe("registerFileRoutes", () => { path: "index.html", version: payload.files[0].version, writeToken: "cut-test", + from: fileContentVersion(before), }); }); From 2e38986892e89a12d1c80a9295600de841e7c12d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 1 Oct 2026 10:41:13 -0700 Subject: [PATCH 3/3] refactor(studio-server): the receipt's from field needs no comment --- packages/studio-server/src/helpers/fileVersion.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/studio-server/src/helpers/fileVersion.ts b/packages/studio-server/src/helpers/fileVersion.ts index 69c41be7141..e06e3b02ebd 100644 --- a/packages/studio-server/src/helpers/fileVersion.ts +++ b/packages/studio-server/src/helpers/fileVersion.ts @@ -5,8 +5,6 @@ export interface FileWriteReceipt { path: string; version: string; writeToken: string; - /** The version the write replaced, when the writer read it: a host can tell a write applied to the bytes it last - * showed from one that merged someone else's change landing just before it. */ from?: string; }