.
This commit is contained in:
parent
6a8c7635f1
commit
d715d94b6d
26 changed files with 1007 additions and 453 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it } from "node:test";
|
||||
import type { DocumentId, DocumentRecord, RelativePath } from "../sync-operations/types";
|
||||
import assert from "node:assert/strict";
|
||||
import type { RelativePath } from "../sync-operations/types";
|
||||
import type { SyncEventQueue } from "../sync-operations/sync-event-queue";
|
||||
import { FileOperations } from "./file-operations";
|
||||
import { Logger } from "../tracing/logger";
|
||||
|
|
@ -7,6 +8,7 @@ import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
|
|||
import type { FileSystemOperations } from "./filesystem-operations";
|
||||
import type { TextWithCursors } from "reconcile-text";
|
||||
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
||||
import { isConflictPath } from "../utils/conflict-path";
|
||||
|
||||
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
||||
public async getConfig(): Promise<ServerConfigData> {
|
||||
|
|
@ -18,18 +20,19 @@ class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
|||
}
|
||||
}
|
||||
|
||||
class MockQueue implements Pick<SyncEventQueue, "getDocument" | "moveDocument"> {
|
||||
public getDocumentByPath(
|
||||
_path: RelativePath
|
||||
): DocumentRecord | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// The queue only receives `moveDocument`/`removeDocument` from file-ops; for
|
||||
// these tests we just need no-op implementations that let the type-check
|
||||
// pass when cast to `SyncEventQueue`.
|
||||
class MockQueue implements Pick<SyncEventQueue, "moveDocument" | "removeDocument"> {
|
||||
public moveDocument(
|
||||
_oldPath: RelativePath,
|
||||
_newPath: RelativePath
|
||||
): DocumentId | undefined {
|
||||
return undefined;
|
||||
): void {
|
||||
// no-op
|
||||
}
|
||||
|
||||
public removeDocument(_path: RelativePath): void {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +42,7 @@ class FakeFileSystemOperations implements FileSystemOperations {
|
|||
public async listFilesRecursively(
|
||||
_root: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
return ["file.md"];
|
||||
return Array.from(this.names);
|
||||
}
|
||||
public async read(_path: RelativePath): Promise<Uint8Array> {
|
||||
throw new Error("Method not implemented.");
|
||||
|
|
@ -65,9 +68,6 @@ class FakeFileSystemOperations implements FileSystemOperations {
|
|||
public async exists(path: RelativePath): Promise<boolean> {
|
||||
return this.names.has(path);
|
||||
}
|
||||
public async createDirectory(_path: RelativePath): Promise<void> {
|
||||
// this is called but irrelevant for this mock
|
||||
}
|
||||
public async delete(_path: RelativePath): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
|
@ -80,152 +80,140 @@ class FakeFileSystemOperations implements FileSystemOperations {
|
|||
}
|
||||
}
|
||||
|
||||
function makeOps(): {
|
||||
fs: FakeFileSystemOperations;
|
||||
ops: FileOperations;
|
||||
} {
|
||||
const fs = new FakeFileSystemOperations();
|
||||
const ops = new FileOperations(
|
||||
new Logger(),
|
||||
new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fs,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
return { fs, ops };
|
||||
}
|
||||
|
||||
function singleConflictPath(
|
||||
names: Set<string>,
|
||||
expectedNonConflictNames: string[]
|
||||
): string {
|
||||
const expected = new Set(expectedNonConflictNames);
|
||||
const conflicts = Array.from(names).filter(
|
||||
(name) => !expected.has(name)
|
||||
);
|
||||
assert.equal(
|
||||
conflicts.length,
|
||||
1,
|
||||
`expected exactly one conflict-path entry, got ${JSON.stringify(conflicts)}`
|
||||
);
|
||||
assert.ok(
|
||||
isConflictPath(conflicts[0]),
|
||||
`expected ${conflicts[0]} to match the conflict-path pattern`
|
||||
);
|
||||
return conflicts[0];
|
||||
}
|
||||
|
||||
describe("File operations", () => {
|
||||
it("should deconflict renames", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
it("move to empty target just renames the file", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await fileOperations.create("a", new Uint8Array());
|
||||
assertSetContainsExactly(fileSystemOperations.names, "a");
|
||||
await fileOperations.move("a", "b");
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b");
|
||||
await ops.create("a", new Uint8Array());
|
||||
assertSetContainsExactly(fs.names, "a");
|
||||
|
||||
await fileOperations.create("c", new Uint8Array());
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b", "c");
|
||||
await ops.move("a", "b");
|
||||
assertSetContainsExactly(fs.names, "b");
|
||||
});
|
||||
|
||||
await fileOperations.move("c", "b");
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)");
|
||||
it("create at an occupied path displaces the existing file to a conflict-uuid path", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await fileOperations.create("c", new Uint8Array());
|
||||
await fileOperations.move("c", "b");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b",
|
||||
"b (1)",
|
||||
"b (2)"
|
||||
await ops.create("note.md", new Uint8Array());
|
||||
await ops.create("note.md", new Uint8Array());
|
||||
|
||||
const conflict = singleConflictPath(fs.names, ["note.md"]);
|
||||
assert.ok(
|
||||
conflict.endsWith("-note.md"),
|
||||
`conflict name should preserve the original filename, got ${conflict}`
|
||||
);
|
||||
});
|
||||
|
||||
it("should deconflict renames with file extension", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
it("move to an occupied target displaces the target to a conflict-uuid path", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await fileOperations.create("b.md", new Uint8Array());
|
||||
await fileOperations.create("c.md", new Uint8Array());
|
||||
await fileOperations.move("c.md", "b.md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md"
|
||||
);
|
||||
await ops.create("source.md", new Uint8Array());
|
||||
await ops.create("dest.md", new Uint8Array());
|
||||
|
||||
await fileOperations.create("d.md", new Uint8Array());
|
||||
await fileOperations.move("d.md", "b.md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md",
|
||||
"b (2).md"
|
||||
);
|
||||
await ops.move("source.md", "dest.md");
|
||||
|
||||
await fileOperations.create("file-23.md", new Uint8Array());
|
||||
await fileOperations.create("file-23 (1).md", new Uint8Array());
|
||||
await fileOperations.move("file-23.md", "file-23 (1).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md",
|
||||
"b (2).md",
|
||||
"file-23 (1).md",
|
||||
"file-23 (2).md"
|
||||
// `dest.md` now holds what used to be at `source.md`; the original
|
||||
// `dest.md` moved to a conflict path in the same directory.
|
||||
const conflict = singleConflictPath(fs.names, ["dest.md"]);
|
||||
assert.ok(
|
||||
conflict.endsWith("-dest.md"),
|
||||
`conflict should preserve the original filename, got ${conflict}`
|
||||
);
|
||||
});
|
||||
|
||||
it("should deconflict renames with paths", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
it("preserves the parent directory when generating a conflict path", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await fileOperations.create("a/b.c/d", new Uint8Array());
|
||||
await fileOperations.create("a/b.c/e", new Uint8Array());
|
||||
await fileOperations.move("a/b.c/d", "a/b.c/e");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"a/b.c/e",
|
||||
"a/b.c/e (1)"
|
||||
await ops.create("a/b.c/d", new Uint8Array());
|
||||
await ops.create("a/b.c/e", new Uint8Array());
|
||||
await ops.move("a/b.c/d", "a/b.c/e");
|
||||
|
||||
const conflict = singleConflictPath(fs.names, ["a/b.c/e"]);
|
||||
assert.ok(
|
||||
conflict.startsWith("a/b.c/"),
|
||||
`conflict should live in the same directory, got ${conflict}`
|
||||
);
|
||||
assert.ok(
|
||||
conflict.endsWith("-e"),
|
||||
`conflict should preserve the filename, got ${conflict}`
|
||||
);
|
||||
});
|
||||
|
||||
it("should continue deconfliction from existing number in filename", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
it("handles dotfiles without mangling the extension", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await ops.create(".gitignore", new Uint8Array());
|
||||
await ops.create("temp", new Uint8Array());
|
||||
await ops.move("temp", ".gitignore");
|
||||
|
||||
const conflict = singleConflictPath(fs.names, [".gitignore"]);
|
||||
assert.ok(
|
||||
conflict.endsWith("-.gitignore"),
|
||||
`conflict should preserve the dotfile name verbatim, got ${conflict}`
|
||||
);
|
||||
|
||||
await fileOperations.create("document (5).md", new Uint8Array());
|
||||
await fileOperations.create("other.md", new Uint8Array());
|
||||
await ops.create(".config.json", new Uint8Array());
|
||||
await ops.create("temp2", new Uint8Array());
|
||||
await ops.move("temp2", ".config.json");
|
||||
|
||||
await fileOperations.move("other.md", "document (5).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"document (5).md",
|
||||
"document (6).md"
|
||||
);
|
||||
|
||||
await fileOperations.create("another.md", new Uint8Array());
|
||||
await fileOperations.move("another.md", "document (5).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"document (5).md",
|
||||
"document (6).md",
|
||||
"document (7).md"
|
||||
// Now one conflict for .gitignore, one for .config.json.
|
||||
const conflicts = Array.from(fs.names).filter(
|
||||
(name) => name !== ".gitignore" && name !== ".config.json"
|
||||
);
|
||||
assert.equal(conflicts.length, 2);
|
||||
assert.ok(conflicts.every(isConflictPath));
|
||||
assert.ok(conflicts.some((c) => c.endsWith("-.gitignore")));
|
||||
assert.ok(conflicts.some((c) => c.endsWith("-.config.json")));
|
||||
});
|
||||
|
||||
it("should handle dotfiles correctly", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
it("generates a fresh conflict path on every displacement", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await fileOperations.create(".gitignore", new Uint8Array());
|
||||
await fileOperations.create("temp", new Uint8Array());
|
||||
await fileOperations.move("temp", ".gitignore");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
".gitignore",
|
||||
".gitignore (1)"
|
||||
);
|
||||
await ops.create("x", new Uint8Array());
|
||||
await ops.create("x", new Uint8Array());
|
||||
await ops.create("x", new Uint8Array());
|
||||
|
||||
await fileOperations.create(".config.json", new Uint8Array());
|
||||
await fileOperations.create("temp2", new Uint8Array());
|
||||
await fileOperations.move("temp2", ".config.json");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
".gitignore",
|
||||
".gitignore (1)",
|
||||
".config.json",
|
||||
".config (1).json"
|
||||
const conflicts = Array.from(fs.names).filter((n) => n !== "x");
|
||||
assert.equal(conflicts.length, 2);
|
||||
assert.ok(conflicts.every(isConflictPath));
|
||||
assert.notEqual(
|
||||
conflicts[0],
|
||||
conflicts[1],
|
||||
"each displacement should produce a unique conflict path"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import type { TextWithCursors } from "reconcile-text";
|
|||
import { reconcile } from "reconcile-text";
|
||||
import { isFileTypeMergable } from "../utils/is-file-type-mergable";
|
||||
import { isBinary } from "../utils/is-binary";
|
||||
import { buildConflictFileName } from "../utils/conflict-path";
|
||||
import type { ServerConfig } from "../services/server-config";
|
||||
|
||||
export class FileOperations {
|
||||
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
|
||||
private readonly fs: SafeFileSystemOperations;
|
||||
|
||||
public constructor(
|
||||
|
|
@ -59,26 +59,34 @@ export class FileOperations {
|
|||
return this.fs.write(path, this.toNativeLineEndings(newContent));
|
||||
}
|
||||
|
||||
// Returns the deconflicted path if a file was moved, undefined otherwise
|
||||
/**
|
||||
* Ensure nothing sits at `path` so the caller can write to it.
|
||||
*
|
||||
* If a file is already there, it is moved aside to a `conflict-<uuid>-<name>`
|
||||
* path in the same directory. The sync layer treats conflict-named files
|
||||
* as invisible (see `isConflictPath`), so no events are enqueued and no
|
||||
* document records are touched — any pre-existing record or pending
|
||||
* events for the displaced path are left behind for the caller to
|
||||
* overwrite as part of whatever operation prompted the displacement.
|
||||
*
|
||||
* Returns the conflict path the existing file was moved to, or `undefined`
|
||||
* if the path was already clear.
|
||||
*/
|
||||
public async ensureClearPath(
|
||||
path: RelativePath
|
||||
): Promise<RelativePath | undefined> {
|
||||
if (await this.fs.exists(path)) {
|
||||
const deconflictedPath = await this.deconflictPath(path);
|
||||
try {
|
||||
this.logger.debug(
|
||||
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
|
||||
);
|
||||
const conflictPath = FileOperations.buildConflictPath(path);
|
||||
this.logger.debug(
|
||||
`Displacing existing file at ${path} to '${conflictPath}' to make room`
|
||||
);
|
||||
|
||||
this.queue.moveDocument(path, deconflictedPath);
|
||||
await this.fs.rename(path, deconflictedPath, true);
|
||||
return deconflictedPath;
|
||||
} finally {
|
||||
this.fs.unlock(deconflictedPath);
|
||||
}
|
||||
} else {
|
||||
await this.createParentDirectories(path);
|
||||
this.queue.moveDocument(path, conflictPath);
|
||||
await this.fs.rename(path, conflictPath, true);
|
||||
return conflictPath;
|
||||
}
|
||||
|
||||
await this.createParentDirectories(path);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -119,8 +127,22 @@ export class FileOperations {
|
|||
return;
|
||||
}
|
||||
|
||||
const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings
|
||||
const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings
|
||||
let expectedText: string;
|
||||
let newText: string;
|
||||
try {
|
||||
expectedText = new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
expectedContent
|
||||
); // this comes from a previous read which must only have \n line endings
|
||||
newText = new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
newContent
|
||||
); // this comes from the server which stores text with \n line endings
|
||||
} catch (decodeError) {
|
||||
this.logger.warn(
|
||||
`3-way merge aborted for ${path}: one of expected/new is not valid UTF-8 (${decodeError}); falling back to overwrite`
|
||||
);
|
||||
await this.fs.write(path, this.toNativeLineEndings(newContent));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fs.atomicUpdateText(
|
||||
path,
|
||||
|
|
@ -166,7 +188,7 @@ export class FileOperations {
|
|||
return this.fs.exists(path);
|
||||
}
|
||||
|
||||
// Returns the deconflicted path if a file at the target was displaced
|
||||
// Returns the conflict path a displaced file was moved to, or undefined.
|
||||
public async move(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
|
|
@ -175,12 +197,16 @@ export class FileOperations {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const deconflictedPath = await this.ensureClearPath(newPath);
|
||||
this.queue.moveDocument(oldPath, newPath);
|
||||
const conflictPath = await this.ensureClearPath(newPath);
|
||||
// Do the disk rename *before* updating the queue. If the rename
|
||||
// throws (permissions, concurrent deletion, …), the queue still
|
||||
// reflects the actual on-disk state instead of claiming the doc
|
||||
// has already moved.
|
||||
await this.fs.rename(oldPath, newPath);
|
||||
this.queue.moveDocument(oldPath, newPath);
|
||||
|
||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
||||
return deconflictedPath;
|
||||
return conflictPath;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -248,51 +274,14 @@ export class FileOperations {
|
|||
}
|
||||
|
||||
/**
|
||||
* Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found.
|
||||
* The returned path has a lock acquired on it; it must be released by the caller when no longer needed.
|
||||
*
|
||||
* @param path The starting path to deconflict
|
||||
* @returns a non-existent path with a lock acquired on it
|
||||
* Build a local-only conflict path for a file the client has to set aside.
|
||||
* Format: `<dir>/conflict-<uuid>-<originalName>` — UUID makes collisions
|
||||
* statistically impossible, so no disk probe / lock dance is needed.
|
||||
*/
|
||||
private async deconflictPath(path: RelativePath): Promise<RelativePath> {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [directory, fileName] = FileOperations.getParentDirAndFile(path);
|
||||
|
||||
if (directory) {
|
||||
directory += "/";
|
||||
}
|
||||
|
||||
const nameParts = fileName.split(".");
|
||||
// Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json"
|
||||
const isDotfile = fileName.startsWith(".") && nameParts[0] === "";
|
||||
const extension =
|
||||
nameParts.length > 1 && !(isDotfile && nameParts.length === 2)
|
||||
? "." + nameParts[nameParts.length - 1]
|
||||
: "";
|
||||
let stem = extension ? nameParts.slice(0, -1).join(".") : fileName;
|
||||
let currentCount = Number.parseInt(
|
||||
FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0"
|
||||
);
|
||||
stem = stem.replace(FileOperations.PARENTHESES_REGEX, "");
|
||||
|
||||
let newName = path;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
currentCount++;
|
||||
newName = `${directory}${stem} (${currentCount})${extension}`;
|
||||
|
||||
// Avoid multiple deconflictPath calls returning the same path
|
||||
await this.fs.waitForLock(newName);
|
||||
const existingRecord = this.queue.getSettledDocumentByPath(newName);
|
||||
if (
|
||||
existingRecord !== undefined || // the document might have been confirmed by the server at a new path but haven't yet moved there locally
|
||||
(await this.fs.exists(newName, true))
|
||||
) {
|
||||
this.fs.unlock(newName);
|
||||
} else {
|
||||
return newName;
|
||||
}
|
||||
}
|
||||
private static buildConflictPath(path: RelativePath): RelativePath {
|
||||
const [directory, fileName] =
|
||||
FileOperations.getParentDirAndFile(path);
|
||||
const conflictName = buildConflictFileName(fileName);
|
||||
return directory ? `${directory}/${conflictName}` : conflictName;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue