This commit is contained in:
Andras Schmelczer 2026-09-27 08:29:17 +01:00
commit 3dab48f58b
3 changed files with 1141 additions and 1376 deletions

View file

@ -1,11 +1,15 @@
import { reconcile } from "reconcile-text";
import type { FileSystemOperations } from "./filesystem-operations";
import type { Database, EngineState } from "../persistence/database";
import type {
Database,
DocumentId,
EngineState,
RelativePath
} from "../persistence/database";
import {
fromStoredSnapshot,
toStoredSnapshot,
hashSnapshot,
type FileSnapshot,
type StoredSnapshot
type HashedSnapshot
} from "../snapshot";
import { isInternalPath } from "../utils/portable-path";
import { FilePaths } from "./file-paths";
@ -25,8 +29,8 @@ import {
} from "../errors/errors";
export interface FileWrite {
expected?: StoredSnapshot;
replacement: StoredSnapshot;
expected?: HashedSnapshot;
replacement: HashedSnapshot;
}
type Mutation = (action: () => Promise<void>) => Promise<void>;
@ -42,7 +46,7 @@ export class FileOperations {
private readonly serverConfig: ServerConfig,
private readonly filesApplied: (
paths: readonly string[]
paths: readonly RelativePath[]
) => void = () => undefined,
private readonly localChanges: {
@ -51,14 +55,14 @@ export class FileOperations {
} = { entries: () => [], flush: async () => undefined },
private readonly isProtectedFile: (
path: string,
path: RelativePath,
size: number
) => boolean = () => false
) {
this.paths = new FilePaths(fs, isProtectedFile);
}
public async read(path: string): Promise<Uint8Array> {
public async read(path: RelativePath): Promise<Uint8Array> {
const snapshot = await this.fs.read(path);
if (!snapshot)
@ -67,18 +71,19 @@ export class FileOperations {
return snapshot.content;
}
public async snapshot(path: string): Promise<StoredSnapshot | undefined> {
public async snapshot(
path: RelativePath
): Promise<HashedSnapshot | undefined> {
const snapshot = await this.fs.read(path);
return snapshot && toStoredSnapshot(snapshot);
return snapshot && hashSnapshot(snapshot);
}
public async listFilesRecursively(): Promise<string[]> {
public async listFilesRecursively(): Promise<RelativePath[]> {
return (await this.fs.listFilesRecursively()).filter(
(path) => !isInternalPath(path)
);
}
public async captureChange(
change: LocalChange,
previous: readonly LocalChange[]
@ -95,7 +100,7 @@ export class FileOperations {
public async apply(
next: EngineState,
writes: Partial<Record<string, FileWrite>> = {},
writes: Partial<Record<DocumentId, FileWrite>> = {},
validatePlan: () => void = () => undefined
): Promise<void> {
await this.localChanges.flush();
@ -116,7 +121,7 @@ export class FileOperations {
const current = structuredClone(this.database.state);
current.lastSeenUpdateId = next.lastSeenUpdateId;
this.applying = current;
const paths = new Set<string>();
const paths = new Set<RelativePath>();
const observeChanges = async (): Promise<void> => {
await this.localChanges.flush();
guard();
@ -129,7 +134,8 @@ export class FileOperations {
state,
change,
(path) =>
isInternalPath(path) || this.isProtectedFile(path, 0)
isInternalPath(path) ||
this.isProtectedFile(path, 0)
);
state.lastAppliedLocalChangeId = change.changeId;
}
@ -141,9 +147,10 @@ export class FileOperations {
...Object.keys(next.local)
])) {
await observeChanges();
let from = current.documents[id]?.observedHash !== undefined
? current.local[id]
: undefined;
let from =
current.documents[id]?.observedHash !== undefined
? current.local[id]
: undefined;
let to = next.local[id];
const write = writes[id];
if (!write && (from === undefined || from === to)) continue;
@ -182,9 +189,10 @@ export class FileOperations {
mutate
);
next.local[id] = to;
from = current.documents[id]?.observedHash !== undefined
? current.local[id]
: undefined;
from =
current.documents[id]?.observedHash !== undefined
? current.local[id]
: undefined;
if (from !== undefined) {
await mutate(async () => {
current.local[id] = to;
@ -195,9 +203,7 @@ export class FileOperations {
}
if (write) {
const snapshot =
from === undefined
? undefined
: await this.fs.read(to);
from === undefined ? undefined : await this.fs.read(to);
if (from !== undefined && !snapshot) {
delete next.local[id];
continue;
@ -229,13 +235,12 @@ export class FileOperations {
}
private async updateContent(
path: string,
path: RelativePath,
current: FileSnapshot | undefined,
write: FileWrite,
extensions: string[]
): Promise<string> {
const replacement = fromStoredSnapshot(write.replacement);
const base = write.expected && fromStoredSnapshot(write.expected);
const { replacement, expected: base } = write;
if (
current &&
base &&
@ -258,14 +263,17 @@ export class FileOperations {
const result =
current && write.expected
? await mergeContent(
path,
write.expected,
await toStoredSnapshot(current),
write.replacement,
extensions
)
path,
write.expected,
await hashSnapshot(current),
write.replacement,
extensions
)
: write.replacement;
await this.fs.write(path, fromStoredSnapshot(result));
await this.fs.write(path, {
content: result.content,
cursors: result.cursors
});
return result.hash;
}
}

View file

@ -1,22 +1,22 @@
import { v4 as uuid } from "uuid";
import type { FileSystemOperations } from "./filesystem-operations";
import type { EngineState } 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: string, size: number) => boolean
private readonly isProtectedFile: (path: RelativePath, size: number) => boolean
) { }
public async prepareDestination(
next: EngineState,
current: EngineState,
id: string,
path: string,
id: DocumentId,
path: RelativePath,
mutate: (action: () => Promise<void>) => Promise<void>
): Promise<string> {
): Promise<RelativePath> {
try {
for (; ;) {
const obstruction = await this.obstruction(path);
@ -97,8 +97,8 @@ export class FilePaths {
}
private async obstruction(
path: string
): Promise<{ path: string; kind: "file" | "directory" } | undefined> {
path: RelativePath
): Promise<{ path: RelativePath; kind: "file" | "directory" } | undefined> {
const parts = path.split("/");
for (let i = 0; i < parts.length; i++) {
const prefix = parts.slice(0, i + 1).join("/");
@ -110,9 +110,9 @@ export class FilePaths {
private async conflictPath(
next: EngineState,
id: string,
path: string
): Promise<string> {
id: DocumentId,
path: RelativePath
): Promise<RelativePath> {
const occupied = { ...next.local };
delete occupied[id];
const files = (await this.fs.listFilesRecursively()).filter(
@ -123,12 +123,12 @@ export class FilePaths {
return allocatePortablePath(path, id, occupied);
}
private async ensureParents(path: string): Promise<void> {
private async ensureParents(path: RelativePath): Promise<void> {
const parent = path.split("/").slice(0, -1).join("/");
if (parent) await this.fs.createDirectory(parent);
}
public async removeEmptyParents(path: string): Promise<void> {
public async removeEmptyParents(path: RelativePath): Promise<void> {
let parent = path.split("/").slice(0, -1).join("/");
while (parent && !isInternalPath(parent)) {
if ((await this.fs.listFilesRecursively(parent)).length) break;

View file

@ -8,24 +8,24 @@ import type { FileSnapshot } from "../snapshot";
* A successful scan is complete: unreadable directories must reject, not vanish.
*/
export interface FileSystemOperations {
listFilesRecursively: (root?: string) => Promise<string[]>;
listFilesRecursively: (root?: RelativePath) => Promise<RelativePath[]>;
/** Return undefined on missing file */
read: (path: string) => Promise<FileSnapshot | undefined>;
read: (path: RelativePath) => Promise<FileSnapshot | undefined>;
/** Reject symlinks and special files rather than following them and return undefined. */
stat: (
path: string
path: RelativePath
) => Promise<{ kind: "file" | "directory"; size: number } | undefined>;
/** Includes directories. */
exists: (path: string) => Promise<boolean>;
exists: (path: RelativePath) => Promise<boolean>;
/** Recursive, idempotent directory creation. */
createDirectory: (path: string) => Promise<void>;
createDirectory: (path: RelativePath) => Promise<void>;
/** Create or overwrite a file, applying optional editor cursor metadata. */
write: (path: string, snapshot: FileSnapshot) => Promise<void>;
write: (path: RelativePath, snapshot: FileSnapshot) => Promise<void>;
// Atomically update the content of a text file.
atomicUpdateText: (
@ -34,11 +34,11 @@ export interface FileSystemOperations {
) => Promise<string>;
/** Move a file without replacing an existing destination. */
rename: (from: string, to: string) => Promise<void>;
rename: (from: RelativePath, to: RelativePath) => Promise<void>;
/** Idempotently unlink one regular file. */
deleteFile: (path: string) => Promise<void>;
deleteFile: (path: RelativePath) => Promise<void>;
/** Prune directories using rmdir only. Reject files and nonempty directories. */
deleteDirectory: (path: string) => Promise<void>;
deleteDirectory: (path: RelativePath) => Promise<void>;
}