diff --git a/frontend/sync-client/src/sync-operations/content.test.ts b/frontend/sync-client/src/sync-operations/content.test.ts new file mode 100644 index 00000000..db446097 --- /dev/null +++ b/frontend/sync-client/src/sync-operations/content.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { mergeContent, fromStoredSnapshot, toStoredSnapshot } from "./content"; + +const extensions = ["md"]; +const snapshot = async (text: string) => + toStoredSnapshot({ content: new TextEncoder().encode(text) }); + +describe("content reconciliation without file timestamps", () => { + it("round-trips content and editor selections without timestamps", async () => { + const original = { + content: new Uint8Array([0, 255, 13, 10]), + cursors: [] + }; + assert.deepEqual( + fromStoredSnapshot(await toStoredSnapshot(original)), + original + ); + }); + + it("keeps local binary edits when the server content is unchanged", async () => { + const base = await snapshot("\0base"); + const local = await snapshot("\0local"); + const merged = await mergeContent( + "file.bin", + base, + local, + base, + extensions + ); + assert.equal(merged.contentBase64, local.contentBase64); + }); + + it("incorporates server binary edits when local content is unchanged", async () => { + const base = await snapshot("\0base"); + const remote = await snapshot("\0remote"); + const merged = await mergeContent( + "file.bin", + base, + base, + remote, + extensions + ); + assert.equal(merged.contentBase64, remote.contentBase64); + }); + + it("keeps the server on concurrent binary or unmergeable text edits", async () => { + for (const [path, prefix] of [ + ["file.bin", "\0"], + ["file.md", "\0"], + ["file.json", ""] + ]) { + const base = await snapshot(`${prefix}base`); + const local = await snapshot(`${prefix}local`); + const remote = await snapshot(`${prefix}remote`); + const merged = await mergeContent( + path, + base, + local, + remote, + extensions + ); + assert.equal(merged.contentBase64, remote.contentBase64, path); + } + }); + + it("keeps the server for differing binary content without a common base", async () => { + const local = await snapshot("\0local"); + const remote = await snapshot("\0remote"); + const merged = await mergeContent( + "file.bin", + undefined, + local, + remote, + extensions + ); + assert.equal(merged.contentBase64, remote.contentBase64); + }); + + it("still merges independent text edits from both sides", async () => { + const merged = await mergeContent( + "note.md", + await snapshot("First paragraph.\n\nLast paragraph.\n"), + await snapshot("Updated first paragraph.\n\nLast paragraph.\n"), + await snapshot("First paragraph.\n\nUpdated last paragraph.\n"), + extensions + ); + assert.equal( + new TextDecoder().decode(fromStoredSnapshot(merged).content), + "Updated first paragraph.\n\nUpdated last paragraph.\n" + ); + }); +}); diff --git a/frontend/sync-client/src/sync-operations/content.ts b/frontend/sync-client/src/sync-operations/content.ts new file mode 100644 index 00000000..a70896bb --- /dev/null +++ b/frontend/sync-client/src/sync-operations/content.ts @@ -0,0 +1,69 @@ +import { base64ToBytes, bytesToBase64 } from "byte-base64"; +import { reconcile } from "reconcile-text"; +import type { FileSnapshot } from "../file-operations/filesystem-operations"; +import type { StoredSnapshot } from "../persistence/database"; +import { hash } from "../utils/hash"; +import { isBinary } from "../utils/is-binary"; +import { isFileTypeMergable } from "../utils/is-file-type-mergable"; + +export async function toStoredSnapshot( + snapshot: FileSnapshot +): Promise { + return { + contentBase64: bytesToBase64(snapshot.content), + cursors: snapshot.cursors, + hash: await hash(snapshot.content) + }; +} + +export function fromStoredSnapshot(snapshot: StoredSnapshot): FileSnapshot { + return { + content: base64ToBytes(snapshot.contentBase64), + cursors: snapshot.cursors + }; +} + +export async function mergeContent( + path: string, + base: StoredSnapshot | undefined, + local: StoredSnapshot, + remote: StoredSnapshot, + extensions: string[] +): Promise { + if (local.contentBase64 === remote.contentBase64) + return { + ...remote, + cursors: local.cursors + }; + + if (!local.cursors && local.contentBase64 === base?.contentBase64) + return remote; + + if (remote.contentBase64 === base?.contentBase64) return local; + + const inputs = [ + base?.contentBase64 ?? "", + local.contentBase64, + remote.contentBase64 + ].map(base64ToBytes); + + // Concurrent unmergeable edits keep the server's content. The filesystem + // journal retains displaced local bytes for recovery. + if (!isFileTypeMergable(path, extensions) || inputs.some(isBinary)) + return remote; + + const [parentText, localText, remoteText] = inputs.map((bytes) => + new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes) + ); + + const merged = reconcile( + parentText, + { text: localText, cursors: local.cursors }, + remoteText + ); + + return toStoredSnapshot({ + content: new TextEncoder().encode(merged.text), + cursors: local.cursors ? merged.cursors : undefined + }); +}