Clean up file rename
This commit is contained in:
parent
5b9736f88e
commit
d5bdbbe304
3 changed files with 108 additions and 40 deletions
|
|
@ -0,0 +1,58 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { MemoryDisk } from "../../../test-support/storage";
|
||||||
|
import { Database, createEmptyDatabase } from "../persistence/database";
|
||||||
|
import type { ServerConfig } from "../services/server-config";
|
||||||
|
import { LocalChangeCheckpoint } from "../sync-operations/local-change-checkpoint";
|
||||||
|
import { hash } from "../utils/hash";
|
||||||
|
import { FileOperations } from "./file-operations";
|
||||||
|
|
||||||
|
const bytes = (text: string): Uint8Array => new TextEncoder().encode(text);
|
||||||
|
|
||||||
|
test("a path swap uses a conflict path only as a temporary destination", async () => {
|
||||||
|
const disk = new MemoryDisk();
|
||||||
|
await disk.userWrite("a.md", bytes("A"));
|
||||||
|
await disk.userWrite("b.md", bytes("B"));
|
||||||
|
const initial = createEmptyDatabase("test");
|
||||||
|
initial.actualFileManifest = { a: "a.md", b: "b.md" };
|
||||||
|
initial.documents = {
|
||||||
|
a: { observedHash: await hash(bytes("A")) },
|
||||||
|
b: { observedHash: await hash(bytes("B")) }
|
||||||
|
};
|
||||||
|
const database = new Database(
|
||||||
|
initial,
|
||||||
|
"test",
|
||||||
|
async () => undefined,
|
||||||
|
async () => undefined
|
||||||
|
);
|
||||||
|
const files = new FileOperations(disk, database, {} as ServerConfig);
|
||||||
|
const checkpoint = new LocalChangeCheckpoint({ generation: 0 });
|
||||||
|
const renames: string[] = [];
|
||||||
|
disk.boundary = (label): void => {
|
||||||
|
if (label.startsWith("visible:rename:")) {
|
||||||
|
renames.push(label.slice("visible:rename:".length));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = structuredClone(database.state);
|
||||||
|
next.actualFileManifest = { a: "b.md", b: "a.md" };
|
||||||
|
await files.applyChanges(next, checkpoint);
|
||||||
|
|
||||||
|
assert.deepEqual(renames, [
|
||||||
|
"b.md->b (conflict b).md", // Free B without losing its contents.
|
||||||
|
"a.md->b.md", // Move A into B.
|
||||||
|
"b (conflict b).md->a.md" // Finish moving B into A.
|
||||||
|
]);
|
||||||
|
assert.deepEqual(database.state.actualFileManifest, {
|
||||||
|
a: "b.md",
|
||||||
|
b: "a.md"
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
disk.userFiles(),
|
||||||
|
new Map([
|
||||||
|
["a.md", bytes("B")],
|
||||||
|
["b.md", bytes("A")]
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
193
frontend/sync-client/src/file-operations/file-renamer.ts
Normal file
193
frontend/sync-client/src/file-operations/file-renamer.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { FileKind } from "./filesystem-operations";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
import type { FileSystemOperations } from "./filesystem-operations";
|
||||||
|
import type { FileChangeSession } from "./file-change-session";
|
||||||
|
import { posix } from "path";
|
||||||
|
import type {
|
||||||
|
DocumentId,
|
||||||
|
StoredDatabase,
|
||||||
|
RelativePath
|
||||||
|
} from "../persistence/database";
|
||||||
|
import { allocatePortablePath, isInternalPath } from "../utils/portable-path";
|
||||||
|
|
||||||
|
interface PathCollision {
|
||||||
|
path: RelativePath;
|
||||||
|
kind: FileKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Occupant {
|
||||||
|
id: DocumentId;
|
||||||
|
path: RelativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collision-free target manifest can still collide with files on disk: rename swaps
|
||||||
|
* and cycles leave old paths occupied, and files or directories can block a new
|
||||||
|
* destination. FileRenamer renames local files while dealing with temporary conflicts.
|
||||||
|
*/
|
||||||
|
export class FileRenamer {
|
||||||
|
public constructor(
|
||||||
|
private readonly fs: FileSystemOperations,
|
||||||
|
private readonly isProtectedFile: (
|
||||||
|
path: RelativePath,
|
||||||
|
size: number
|
||||||
|
) => boolean
|
||||||
|
) { }
|
||||||
|
|
||||||
|
public async prepareDestination(
|
||||||
|
session: FileChangeSession,
|
||||||
|
id: DocumentId,
|
||||||
|
path: RelativePath
|
||||||
|
): Promise<RelativePath> {
|
||||||
|
const { current, planned } = session;
|
||||||
|
for (; ;) {
|
||||||
|
const collision = await this.findCollision(path);
|
||||||
|
if (!collision) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const occupants = await this.findMovableOccupants(
|
||||||
|
collision,
|
||||||
|
current,
|
||||||
|
planned
|
||||||
|
);
|
||||||
|
|
||||||
|
if (occupants === undefined) {
|
||||||
|
// path must be occupied by an ignored file which we can't move
|
||||||
|
path = await this.allocateConflictPath(planned, id, path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.relocateOccupants(occupants, session);
|
||||||
|
|
||||||
|
if (collision.kind === FileKind.Directory) {
|
||||||
|
session.beforeFileMutation();
|
||||||
|
await this.fs.deleteDirectory(collision.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.abortIfStale();
|
||||||
|
await this.createParentDirectories(path);
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeEmptyParents(path: RelativePath): Promise<void> {
|
||||||
|
let parent = posix.dirname(path);
|
||||||
|
|
||||||
|
while (parent !== "." && !isInternalPath(parent)) {
|
||||||
|
if ((await this.fs.listFilesRecursively(parent)).length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.fs.deleteDirectory(parent);
|
||||||
|
} catch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
parent = posix.dirname(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findCollision(
|
||||||
|
path: RelativePath
|
||||||
|
): Promise<PathCollision | undefined> {
|
||||||
|
const parts = path.split("/");
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const prefix = parts.slice(0, i + 1).join("/");
|
||||||
|
const info = await this.fs.stat(prefix);
|
||||||
|
|
||||||
|
if (
|
||||||
|
info &&
|
||||||
|
(info.kind === FileKind.File || i === parts.length - 1)
|
||||||
|
) {
|
||||||
|
return { path: prefix, kind: info.kind };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findMovableOccupants(
|
||||||
|
collision: PathCollision,
|
||||||
|
current: StoredDatabase,
|
||||||
|
planned: StoredDatabase
|
||||||
|
): Promise<Occupant[] | undefined> {
|
||||||
|
const children =
|
||||||
|
collision.kind === FileKind.File
|
||||||
|
? [collision.path]
|
||||||
|
: await this.fs.listFilesRecursively(collision.path);
|
||||||
|
|
||||||
|
const occupants = [];
|
||||||
|
for (const child of children) {
|
||||||
|
const occupant = Object.keys(current.actualFileManifest).find(
|
||||||
|
(key) =>
|
||||||
|
current.documents[key]?.observedHash !== undefined &&
|
||||||
|
current.actualFileManifest[key] === child
|
||||||
|
);
|
||||||
|
const info = await this.fs.stat(child);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!info || // doesn't exist anymore
|
||||||
|
this.isProtectedFile(child, info.size) ||
|
||||||
|
(collision.kind === FileKind.Directory &&
|
||||||
|
(occupant === undefined || planned.actualFileManifest[occupant] === child))
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
occupants.push({ id: occupant ?? uuid(), path: child });
|
||||||
|
}
|
||||||
|
|
||||||
|
return occupants;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async allocateConflictPath(
|
||||||
|
planned: StoredDatabase,
|
||||||
|
id: DocumentId,
|
||||||
|
path: RelativePath
|
||||||
|
): Promise<RelativePath> {
|
||||||
|
const occupied = Object.entries(planned.actualFileManifest).flatMap(
|
||||||
|
([other, occupiedPath]) => (other === id ? [] : [occupiedPath])
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = (await this.fs.listFilesRecursively()).filter(
|
||||||
|
(diskPath) => !isInternalPath(diskPath)
|
||||||
|
);
|
||||||
|
|
||||||
|
return allocatePortablePath(path, id, [...occupied, ...files]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async relocateOccupants(
|
||||||
|
occupants: Occupant[],
|
||||||
|
session: FileChangeSession
|
||||||
|
): Promise<void> {
|
||||||
|
const { current, planned } = session;
|
||||||
|
for (const occupant of occupants) {
|
||||||
|
const displaced = await this.allocateConflictPath(
|
||||||
|
planned,
|
||||||
|
occupant.id,
|
||||||
|
posix.basename(occupant.path)
|
||||||
|
);
|
||||||
|
|
||||||
|
session.beforeFileMutation();
|
||||||
|
current.actualFileManifest[occupant.id] = displaced;
|
||||||
|
await this.fs.rename(occupant.path, displaced);
|
||||||
|
|
||||||
|
if (!current.documents[occupant.id]) {
|
||||||
|
current.documents[occupant.id] = { observedHash: "" };
|
||||||
|
planned.documents[occupant.id] = { observedHash: "" };
|
||||||
|
planned.actualFileManifest[occupant.id] = displaced;
|
||||||
|
} else if (planned.actualFileManifest[occupant.id] === occupant.path) {
|
||||||
|
planned.actualFileManifest[occupant.id] = displaced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createParentDirectories(path: RelativePath): Promise<void> {
|
||||||
|
const parent = posix.dirname(path);
|
||||||
|
|
||||||
|
if (parent !== ".") {
|
||||||
|
await this.fs.createDirectory(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/sync-client/src/types/client-phase.ts
Normal file
5
frontend/sync-client/src/types/client-phase.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export enum ClientPhase {
|
||||||
|
Created = "created",
|
||||||
|
Started = "started",
|
||||||
|
Destroyed = "destroyed"
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue