Extract reconcile (#85)

This commit is contained in:
Andras Schmelczer 2025-07-13 11:06:42 +01:00 committed by GitHub
parent 75b020146a
commit bb0e44f06f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
141 changed files with 294 additions and 36720 deletions

View file

@ -1,3 +1,3 @@
module.exports = {
preset: "ts-jest/presets/js-with-babel-esm"
preset: "ts-jest"
};

View file

@ -10,19 +10,19 @@
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest"
"test": "jest"
},
"dependencies": {
"byte-base64": "^1.1.0",
"minimatch": "^10.0.1",
"p-queue": "^8.1.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0",
"reconcile-text": "^0.5.0"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.15.30",
"jest": "^29.7.0",
"sync_lib": "file:../../backend/sync_lib/pkg",
"ts-jest": "^29.3.4",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
@ -32,4 +32,4 @@
"webpack-merge": "^6.0.1",
"ws": "^8.18.2"
}
}
}

View file

@ -6,12 +6,8 @@ import type {
import { FileOperations } from "./file-operations";
import { Logger } from "../tracing/logger";
import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
import type {
FileSystemOperations,
TextWithCursors
} from "./filesystem-operations";
import init, { base64ToBytes } from "sync_lib";
import fs from "fs";
import type { FileSystemOperations } from "./filesystem-operations";
import type { TextWithCursors } from "reconcile-text";
class MockDatabase implements Partial<Database> {
public getLatestDocumentByRelativePath(
@ -75,13 +71,6 @@ class FakeFileSystemOperations implements FileSystemOperations {
}
describe("File operations", () => {
beforeEach(async () => {
const wasmBin = fs.readFileSync(
"../../backend/sync_lib/pkg/sync_lib_bg.wasm"
);
await init({ module_or_path: wasmBin });
});
it("should deconflict renames", async () => {
const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations(

View file

@ -1,18 +1,10 @@
import type { Logger } from "../tracing/logger";
import type {
FileSystemOperations,
TextWithCursors
} from "./filesystem-operations";
import type { FileSystemOperations } from "./filesystem-operations";
import type { Database, RelativePath } from "../persistence/database";
import {
CursorPosition,
isBinary,
isFileTypeMergable,
mergeTextWithCursors,
TextWithCursors as RustTextWithCursors
} from "sync_lib";
import { SafeFileSystemOperations } from "./safe-filesystem-operations";
import type { TextWithCursors } from "reconcile-text";
import { isBinary, reconcile } from "reconcile-text";
import { isFileTypeMergable } from "../utils/is-file-type-mergable";
export class FileOperations {
private static readonly PARENTHESES_REGEX = / \((\d+)\)$/;
private readonly fs: SafeFileSystemOperations;
@ -102,39 +94,25 @@ export class FileOperations {
await this.fs.atomicUpdateText(
path,
({ text, cursors }: TextWithCursors): TextWithCursors => {
text = text.replace(this.nativeLineEndings, "\n");
this.logger.debug(
`Performing a 3-way merge for ${path} with the expected content`
);
const left = new RustTextWithCursors(
text,
cursors.map(
(cursor) =>
new CursorPosition(
cursor.id,
cursor.characterPosition
)
)
text = text.replace(this.nativeLineEndings, "\n");
const merged = reconcile(
expectedText,
{ text, cursors },
newText
);
const right = new RustTextWithCursors(newText, []);
const merged = mergeTextWithCursors(expectedText, left, right);
const resultText = merged
.text()
.replace("\n", this.nativeLineEndings);
const resultCursors = merged.cursors().map((cursor) => ({
id: cursor.id(),
characterPosition: cursor.characterPosition()
}));
merged.free();
const resultText = merged.text.replace(
"\n",
this.nativeLineEndings
);
return {
text: resultText,
cursors: resultCursors
cursors: merged.cursors
};
}
);

View file

@ -1,16 +1,6 @@
import type { RelativePath } from "../persistence/database";
export interface Cursor {
id: number;
/// The character position is the index of the character in the text where the text lines are separated by '\n' new line character even if the actual text uses different line endings.
characterPosition: number;
}
export interface TextWithCursors {
text: string;
cursors: Cursor[];
}
import type { TextWithCursors } from "reconcile-text";
export interface FileSystemOperations {
// List all files that should be synced.

View file

@ -1,11 +1,9 @@
import type { RelativePath } from "../persistence/database";
import type {
FileSystemOperations,
TextWithCursors
} from "./filesystem-operations";
import type { FileSystemOperations } from "./filesystem-operations";
import type { Logger } from "../tracing/logger";
import { Locks } from "../utils/locks";
import { FileNotFoundError } from "./file-not-found-error";
import type { TextWithCursors } from "reconcile-text";
/**
* Decorates `FileSystemOperations` to replace errors with `FileNotFoundError`

View file

@ -13,11 +13,7 @@ export { Logger, LogLevel, LogLine } from "./tracing/logger";
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
export { rateLimit } from "./utils/rate-limit";
export type { RelativePath, StoredDatabase } from "./persistence/database";
export type {
FileSystemOperations,
TextWithCursors,
Cursor
} from "./file-operations/filesystem-operations";
export type { FileSystemOperations } from "./file-operations/filesystem-operations";
export type { PersistenceProvider } from "./persistence/persistence";
export type { CursorSpan } from "./services/types/CursorSpan";
export type { ClientCursors } from "./services/types/ClientCursors";

View file

@ -1,5 +1,3 @@
import initWasm from "sync_lib";
import wasmBin from "../../../backend/sync_lib/pkg/sync_lib_bg.wasm";
import type { PersistenceProvider } from "./persistence/persistence";
import type { HistoryEntry, HistoryStats } from "./tracing/sync-history";
import { SyncHistory } from "./tracing/sync-history";
@ -79,11 +77,6 @@ export class SyncClient {
const history = new SyncHistory(logger);
await initWasm(
// eslint-disable-next-line
(wasmBin as any).default // it is loaded as a base64 string by webpack
);
let state = (await persistence.load()) ?? {
settings: undefined,
database: undefined

View file

@ -1,4 +1,4 @@
import assert from "assert";
import * as assert from "assert";
export function assertSetContainsExactly<T>(set: Set<T>, ...values: T[]): void {
assert(

View file

@ -1,18 +0,0 @@
import init, { base64ToBytes } from "sync_lib";
import fs from "fs";
describe("deserialize", () => {
it("should serialize a Uint8Array to a base64 string", async () => {
const wasmBin = fs.readFileSync(
"../../backend/sync_lib/pkg/sync_lib_bg.wasm"
);
await init({ module_or_path: wasmBin });
const base64 = "SGVsbG8=";
const jsResult = base64ToBytes(base64);
const expected = new Uint8Array([72, 101, 108, 108, 111]);
expect(jsResult).toEqual(expected);
const rustResult = base64ToBytes(base64);
expect(jsResult).toEqual(rustResult);
});
});

View file

@ -0,0 +1,28 @@
import { isFileTypeMergable } from "./is-file-type-mergable";
describe("isFileTypeMergable", () => {
it("should return true for .md files", () => {
expect(isFileTypeMergable(".md")).toBe(true);
expect(isFileTypeMergable("hi.md")).toBe(true);
expect(isFileTypeMergable("my/path/to/my/document.md")).toBe(true);
});
it("should return true for .txt files", () => {
expect(isFileTypeMergable(".txt")).toBe(true);
expect(isFileTypeMergable("hi.txt")).toBe(true);
expect(isFileTypeMergable("my/path/to/my/document.txt")).toBe(true);
});
it("should be case insensitive", () => {
expect(isFileTypeMergable("hi.MD")).toBe(true);
expect(isFileTypeMergable("my/path/to/my/DOCUMENT.MD")).toBe(true);
expect(isFileTypeMergable("hi.TXT")).toBe(true);
expect(isFileTypeMergable("my/path/to/my/DOCUMENT.TXT")).toBe(true);
});
it("should return false for non-mergable file types", () => {
expect(isFileTypeMergable(".json")).toBe(false);
expect(isFileTypeMergable("HELLO.JSON")).toBe(false);
expect(isFileTypeMergable("my/config.yml")).toBe(false);
});
});

View file

@ -0,0 +1,6 @@
export function isFileTypeMergable(pathOrFileName: string): boolean {
const parts = pathOrFileName.split(".");
const fileExtension = parts.at(-1) ?? "";
return ["md", "txt"].includes(fileExtension.toLowerCase());
}

View file

@ -1,18 +0,0 @@
import { serialize } from "./serialize";
import init, { bytesToBase64 } from "sync_lib";
import fs from "fs";
describe("serialize", () => {
it("should serialize a Uint8Array to a base64 string", async () => {
const wasmBin = fs.readFileSync(
"../../backend/sync_lib/pkg/sync_lib_bg.wasm"
);
await init({ module_or_path: wasmBin });
const data = new Uint8Array([72, 101, 108, 108, 111]);
const jsResult = serialize(data);
const rustResult = bytesToBase64(data);
expect(rustResult).toBe("SGVsbG8=");
expect(jsResult).toBe(rustResult);
});
});

View file

@ -1,5 +0,0 @@
import { bytesToBase64 } from "byte-base64";
export function serialize(data: Uint8Array): string {
return bytesToBase64(data);
}