Extract reconcile

This commit is contained in:
Andras Schmelczer 2026-09-19 20:37:25 +01:00
commit f5fc71f388
2 changed files with 162 additions and 0 deletions

View file

@ -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"
);
});
});

View file

@ -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<StoredSnapshot> {
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<StoredSnapshot> {
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
});
}