Extract reconcile
This commit is contained in:
parent
c9512ab8b1
commit
f5fc71f388
2 changed files with 162 additions and 0 deletions
93
frontend/sync-client/src/sync-operations/content.test.ts
Normal file
93
frontend/sync-client/src/sync-operations/content.test.ts
Normal 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
69
frontend/sync-client/src/sync-operations/content.ts
Normal file
69
frontend/sync-client/src/sync-operations/content.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue