vault-link/frontend/sync-client/src/sync-operations/scan.ts
2026-09-27 09:04:05 +01:00

153 lines
5.3 KiB
TypeScript

import { v4 as uuid } from "uuid";
import type { FileOperations } from "../file-operations/file-operations";
import type {
Database,
DocumentId,
RelativePath
} from "../persistence/database";
import type { VaultSnapshot } from "../services/types/VaultSnapshot";
import { EMPTY_HASH, hash } from "../utils/hash";
import {
arePathAliases,
findPathWithSameSpelling
} from "../utils/portable-path";
import {
applyLocalChange,
unappliedChanges,
type LocalChange
} from "./local-changes";
export type { LocalChange } from "./local-changes";
interface ScanOptions {
database: Database;
guard: () => void;
changes: LocalChange[];
files: FileOperations;
ignored: (path: RelativePath) => boolean;
oversized: (size: number) => boolean;
}
/** Scans are complete or fail. Missing unmaterialized/ignored entries are not deletions. */
export async function scanLocalFiles(
{
database,
guard,
changes: queuedChanges,
files,
ignored,
oversized
}: ScanOptions,
initial?: VaultSnapshot
): Promise<Set<RelativePath>> {
const next = structuredClone(database.state);
// Leave the prefix queued until its resulting state is saved.
// A persisted change ID makes replay safe after an uncertain save.
const changes = [...queuedChanges];
const unsyncablePaths = new Set<RelativePath>();
for (const change of unappliedChanges(next, changes)) {
applyLocalChange(next, change, ignored, initial);
next.lastAppliedLocalChangeId = change.changeId;
}
const paths: RelativePath[] = [];
const reportedPaths = await files.listFilesRecursively();
for (const path of reportedPaths) {
// Some filesystems may report a different Unicode spelling after a
// rename. Keep an already-accessible spelling when possible.
const alias = findPathWithSameSpelling(Object.values(next.local), path);
paths.push(
alias &&
!reportedPaths.includes(alias) &&
(await files.fs.exists(alias))
? alias
: path
);
}
const present = new Set(paths);
const hashes = new Map<RelativePath, string>();
for (const path of paths) {
if (ignored(path)) {
unsyncablePaths.add(path);
continue;
}
const info = await files.fs.stat(path);
if (!info || info.kind !== "file")
throw new Error(`File changed during scan: ${path}`);
if (oversized(info.size)) {
unsyncablePaths.add(path);
continue;
}
const snapshot = await files.fs.read(path);
if (!snapshot) throw new Error(`File changed during scan: ${path}`);
if (!oversized(snapshot.content.length)) {
hashes.set(path, await hash(snapshot.content));
} else {
unsyncablePaths.add(path);
}
}
const missing = new Set(
Object.keys(next.local).filter(
(id) =>
next.documents[id]?.observedHash !== undefined &&
!ignored(next.local[id]) &&
!present.has(next.local[id])
)
);
const knownPaths = new Set(Object.values(next.local));
const unknown = [...hashes.keys()].filter((path) => !knownPaths.has(path));
for (const path of unknown) {
const digest = hashes.get(path)!;
const matches = [...missing].filter(
(id) => next.documents[id]?.observedHash === digest
);
const sameHash = unknown.filter(
(other) => hashes.get(other) === digest
);
let id: DocumentId | undefined;
if (
digest !== EMPTY_HASH &&
matches.length === 1 &&
sameHash.length === 1
) {
id = matches[0];
missing.delete(id);
} else if (initial) {
id = Object.keys(initial.fileManifest.entries).find(
(candidate) =>
!next.local[candidate] &&
arePathAliases(
initial.fileManifest.entries[candidate],
path
)
);
}
id ??= uuid();
next.local[id] = path;
next.documents[id] ??= { observedHash: "" };
}
for (const id of missing) delete next.local[id];
for (const [id, path] of Object.entries(next.local)) {
if (unsyncablePaths.has(path) || ignored(path)) {
next.excluded ??= {};
if (next.excluded[id] === undefined)
next.excluded[id] = next.fileManifest.entries[id] ?? null;
}
const digest = hashes.get(path);
if (digest !== undefined) {
const doc = (next.documents[id] ??= {});
// The old history no longer contains this base. Preserve any edit
// made during recovery as a new file instead of overwriting it.
if (doc.base?.vaultUpdateId === null && doc.base.hash !== digest) {
const recoveredId = uuid();
next.local[recoveredId] = path;
next.documents[recoveredId] = { observedHash: digest };
delete next.local[id];
} else doc.observedHash = digest;
}
}
guard();
await database.commit(next);
queuedChanges.splice(0, changes.length);
guard();
return unsyncablePaths;
}