This commit is contained in:
Andras Schmelczer 2026-09-27 09:04:05 +01:00
commit c869f0a880

View file

@ -1,6 +1,10 @@
import { v4 as uuid } from "uuid";
import type { FileOperations } from "../file-operations/file-operations";
import type { DocumentId, EngineState, RelativePath } from "../persistence/database";
import type {
Database,
DocumentId,
RelativePath
} from "../persistence/database";
import type { VaultSnapshot } from "../services/types/VaultSnapshot";
import { EMPTY_HASH, hash } from "../utils/hash";
import {
@ -16,28 +20,27 @@ import {
export type { LocalChange } from "./local-changes";
interface ScanOptions {
next: EngineState;
database: Database;
guard: () => void;
changes: LocalChange[];
files: FileOperations;
ignored: (path: RelativePath) => boolean;
oversized: (size: number) => boolean;
commit: (next: EngineState) => Promise<void>;
}
/** Scans are complete or fail. Missing unmaterialized/ignored entries are not deletions. */
export async function scanLocalFiles(
{
next,
database,
guard,
changes: queuedChanges,
files,
ignored,
oversized,
commit
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];
@ -61,7 +64,7 @@ export async function scanLocalFiles(
);
}
const present = new Set(paths);
const snapshots = new Map<RelativePath, { hash: string }>();
const hashes = new Map<RelativePath, string>();
for (const path of paths) {
if (ignored(path)) {
unsyncablePaths.add(path);
@ -77,7 +80,7 @@ export async function scanLocalFiles(
const snapshot = await files.fs.read(path);
if (!snapshot) throw new Error(`File changed during scan: ${path}`);
if (!oversized(snapshot.content.length)) {
snapshots.set(path, { hash: await hash(snapshot.content) });
hashes.set(path, await hash(snapshot.content));
} else {
unsyncablePaths.add(path);
}
@ -91,20 +94,18 @@ export async function scanLocalFiles(
)
);
const knownPaths = new Set(Object.values(next.local));
const unknown = [...snapshots.keys()].filter(
(path) => !knownPaths.has(path)
);
const unknown = [...hashes.keys()].filter((path) => !knownPaths.has(path));
for (const path of unknown) {
const snapshot = snapshots.get(path)!;
const digest = hashes.get(path)!;
const matches = [...missing].filter(
(id) => next.documents[id]?.observedHash === snapshot.hash
(id) => next.documents[id]?.observedHash === digest
);
const sameHash = unknown.filter(
(other) => snapshots.get(other)?.hash === snapshot.hash
(other) => hashes.get(other) === digest
);
let id: DocumentId | undefined;
if (
snapshot.hash !== EMPTY_HASH &&
digest !== EMPTY_HASH &&
matches.length === 1 &&
sameHash.length === 1
) {
@ -131,24 +132,21 @@ export async function scanLocalFiles(
if (next.excluded[id] === undefined)
next.excluded[id] = next.fileManifest.entries[id] ?? null;
}
const snapshot = snapshots.get(path);
if (snapshot) {
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 !== snapshot.hash
) {
if (doc.base?.vaultUpdateId === null && doc.base.hash !== digest) {
const recoveredId = uuid();
next.local[recoveredId] = path;
next.documents[recoveredId] = { observedHash: snapshot.hash };
next.documents[recoveredId] = { observedHash: digest };
delete next.local[id];
} else doc.observedHash = snapshot.hash;
} else doc.observedHash = digest;
}
}
guard();
await commit(next);
await database.commit(next);
queuedChanges.splice(0, changes.length);
guard();
return unsyncablePaths;