,,
This commit is contained in:
parent
0c75d3b4c2
commit
3dab48f58b
3 changed files with 1141 additions and 1376 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue