This commit is contained in:
Andras Schmelczer 2026-09-27 09:04:05 +01:00
commit c869f0a880
2 changed files with 461 additions and 578 deletions

View file

@ -17,16 +17,14 @@ import { mergeContent } from "../sync-operations/content";
import { hash } from "../utils/hash";
import { isBinary } from "../utils/is-binary";
import { isFileTypeMergeable } from "../utils/is-file-type-mergeable";
import { decodeText } from "../utils/decode-text";
import type { ServerConfig } from "../services/server-config";
import {
applyLocalChange,
unappliedChanges,
type LocalChange
} from "../sync-operations/local-changes";
import {
FileNotFoundError,
LocalChangesDuringReconciliation
} from "../errors/errors";
import { LocalChangesDuringReconciliation } from "../errors/errors";
export interface FileWrite {
expected?: HashedSnapshot;
@ -62,15 +60,6 @@ export class FileOperations {
this.paths = new FilePaths(fs, isProtectedFile);
}
public async read(path: RelativePath): Promise<Uint8Array> {
const snapshot = await this.fs.read(path);
if (!snapshot)
throw new FileNotFoundError(`File not found: ${path}`, path);
return snapshot.content;
}
public async snapshot(
path: RelativePath
): Promise<HashedSnapshot | undefined> {
@ -84,10 +73,10 @@ export class FileOperations {
);
}
public async captureChange(
public captureChange(
change: LocalChange,
previous: readonly LocalChange[]
): Promise<void> {
): void {
const state = structuredClone(this.applying ?? this.database.state);
if (state.lastSeenUpdateId === undefined) return;
@ -249,27 +238,23 @@ export class FileOperations {
isBinary(value.content)
)
) {
const decoder = new TextDecoder("utf-8", {
fatal: true,
ignoreBOM: true
});
const parent = decoder.decode(base.content);
const remote = decoder.decode(replacement.content);
const parent = decodeText(base.content);
const remote = decodeText(replacement.content);
const text = await this.fs.atomicUpdateText(path, (latest) =>
reconcile(parent, latest, remote)
);
return hash(new TextEncoder().encode(text));
}
const result =
current && write.expected
current && base
? await mergeContent(
path,
write.expected,
base,
await hashSnapshot(current),
write.replacement,
replacement,
extensions
)
: write.replacement;
: replacement;
await this.fs.write(path, {
content: result.content,
cursors: result.cursors

View file

@ -1,14 +1,21 @@
import { v4 as uuid } from "uuid";
import type { FileSystemOperations } from "./filesystem-operations";
import type { DocumentId, EngineState, RelativePath } from "../persistence/database";
import type {
DocumentId,
EngineState,
RelativePath
} from "../persistence/database";
import { allocatePortablePath, isInternalPath } from "../utils/portable-path";
/** Resolve occupied rename destinations without discarding another file. */
export class FilePaths {
public constructor(
private readonly fs: FileSystemOperations,
private readonly isProtectedFile: (path: RelativePath, size: number) => boolean
) { }
private readonly isProtectedFile: (
path: RelativePath,
size: number
) => boolean
) {}
public async prepareDestination(
next: EngineState,
@ -18,7 +25,7 @@ export class FilePaths {
mutate: (action: () => Promise<void>) => Promise<void>
): Promise<RelativePath> {
try {
for (; ;) {
for (;;) {
const obstruction = await this.obstruction(path);
if (!obstruction) break;
const children =
@ -29,8 +36,8 @@ export class FilePaths {
for (const child of children) {
const occupant = Object.keys(current.local).find(
(key) =>
current.documents[key]?.observedHash !== undefined &&
current.local[key] === child
current.documents[key]?.observedHash !==
undefined && current.local[key] === child
);
const info = await this.fs.stat(child);
if (
@ -65,7 +72,9 @@ export class FilePaths {
next.local[occupant.id] = displaced;
}
if (obstruction.kind === "directory")
await mutate(() => this.fs.deleteDirectory(obstruction.path));
await mutate(() =>
this.fs.deleteDirectory(obstruction.path)
);
}
await this.ensureParents(path);
} catch (error) {
@ -113,14 +122,13 @@ export class FilePaths {
id: DocumentId,
path: RelativePath
): Promise<RelativePath> {
const occupied = { ...next.local };
delete occupied[id];
const files = (await this.fs.listFilesRecursively()).filter(
(path) => !isInternalPath(path)
const occupied = Object.entries(next.local).flatMap(
([other, occupiedPath]) => (other === id ? [] : [occupiedPath])
);
for (const [i, diskPath] of files.entries())
occupied[`disk-${i}`] = diskPath;
return allocatePortablePath(path, id, occupied);
const files = (await this.fs.listFilesRecursively()).filter(
(diskPath) => !isInternalPath(diskPath)
);
return allocatePortablePath(path, id, [...occupied, ...files]);
}
private async ensureParents(path: RelativePath): Promise<void> {