a moved file relocates its ladder instead of rebuilding it, and orphans go
Moving a file into a folder made the sync look at a new path, find nothing, and convert it again — minutes of work to produce bytes that already existed a directory away — while the old folder stayed behind forever, because nothing ever deleted one. A move preserves size and mtime, and both are already in the manifest and in the stat. So the sync indexes what is already derived on that pair, and a file that has moved has its ladder RENAMED across. An ambiguous match is refused rather than guessed. Two originals can share a size and an mtime — a camera dump can write a whole card in one second — and rebuilding is merely slow where attaching the wrong photographs to a name is wrong. Orphans are removed afterwards, and only on a WHOLE-TREE sync. A scoped run knows about one folder, so everything outside it looks abandoned; deleting on that basis would take out the library the first time somebody re-synced a subfolder. Safe otherwise because derived/ is reproducible by definition, which is what its name has meant since these two folders stopped being called `user` and `treated`. Both are counted, recorded in the ledger and shown only when they happened, so an ordinary run stays one short line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+121
-1
@@ -1,5 +1,5 @@
|
||||
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { join, relative } from 'node:path';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { sidecarPort } from '@@/sidecar-registry';
|
||||
import { mediaPath } from './media';
|
||||
|
||||
@@ -486,3 +486,123 @@ export async function reconcile(email: string, scope = ''): Promise<Reconciliati
|
||||
|
||||
return { originals: files.length, current, missing, stale };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every derived folder that already exists, indexed by what it was built FROM.
|
||||
*
|
||||
* ── What this is for ──
|
||||
*
|
||||
* The mapping from original to derived is by PATH, so moving a file into a folder makes the sync look
|
||||
* somewhere new, find nothing, and convert it again — minutes of work to produce bytes that already
|
||||
* exist a directory away, while the old folder stays behind forever because nothing deletes it.
|
||||
*
|
||||
* A move preserves size and mtime, so the pair identifies the same file at a new path. That is what
|
||||
* this index is: build it once before a sync, and a file that has moved can have its ladder RENAMED
|
||||
* into place instead of rebuilt.
|
||||
*
|
||||
* ── Why the pair, and not a hash ──
|
||||
*
|
||||
* A hash would be exact and would also mean reading every byte of every original on every sync, which
|
||||
* is the cost this is trying to avoid. Size and mtime are already in the manifest and already in the
|
||||
* stat, so the index is free.
|
||||
*
|
||||
* It is not unique in principle — two files can share both — so an ambiguous match is REFUSED below
|
||||
* rather than guessed at. Rebuilding is slow; attaching the wrong pictures to a name is wrong.
|
||||
*/
|
||||
export type DerivedIndex = Map<string, string[]>;
|
||||
|
||||
const indexKey = (bytes: number, mtimeMs: number) => `${bytes}:${mtimeMs}`;
|
||||
|
||||
/** Walk `derived/`, reading each manifest. Directories are the unit — a manifest is one built original. */
|
||||
export async function indexDerived(root: string, prefix = '', into: DerivedIndex = new Map()): Promise<DerivedIndex> {
|
||||
const entries = await readdir(join(root, prefix), { withFileTypes: true }).catch(() => []);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
|
||||
const raw = await readFile(join(root, rel, MANIFEST), 'utf8').catch(() => null);
|
||||
if (raw) {
|
||||
try {
|
||||
const manifest = JSON.parse(raw) as Manifest;
|
||||
const key = indexKey(manifest.source.bytes, manifest.source.mtimeMs);
|
||||
into.set(key, [...(into.get(key) ?? []), rel]);
|
||||
} catch {
|
||||
// Unreadable manifest: not indexed, so the file it belongs to rebuilds. One corrupt folder
|
||||
// costs one conversion rather than making the whole index untrustworthy.
|
||||
}
|
||||
// A built original is a leaf — its contents are renditions, not more originals.
|
||||
continue;
|
||||
}
|
||||
|
||||
await indexDerived(root, rel, into);
|
||||
}
|
||||
|
||||
return into;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this original simply MOVED? If so, put its ladder where the file now is.
|
||||
*
|
||||
* Returns true when the folder was relocated and is now correct for this path.
|
||||
*
|
||||
* Refuses an ambiguous match. Two originals sharing a size and an mtime is unlikely and not
|
||||
* impossible — a camera dump can write a whole card in one second — and attaching the wrong
|
||||
* photographs to a name is a worse outcome than converting again.
|
||||
*/
|
||||
export async function relocateDerived(
|
||||
index: DerivedIndex,
|
||||
derivedRoot: string,
|
||||
relativePath: string,
|
||||
source: { bytes: number; mtimeMs: number },
|
||||
): Promise<boolean> {
|
||||
const candidates = index.get(indexKey(source.bytes, source.mtimeMs)) ?? [];
|
||||
if (candidates.length !== 1) return false;
|
||||
|
||||
const from = candidates[0]!;
|
||||
if (from === relativePath) return false;
|
||||
|
||||
const fromDir = join(derivedRoot, from);
|
||||
const toDir = join(derivedRoot, relativePath);
|
||||
|
||||
// Only if the destination is genuinely empty. A folder already there is either current — in which
|
||||
// case nothing needed doing — or half-built, and overwriting it with a rename is not a repair.
|
||||
if (await stat(toDir).catch(() => null)) return false;
|
||||
|
||||
try {
|
||||
await mkdir(dirname(toDir), { recursive: true });
|
||||
await rename(fromDir, toDir);
|
||||
// Claimed, so a second original with the same size and mtime cannot take it too.
|
||||
index.delete(indexKey(source.bytes, source.mtimeMs));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove derived folders whose original is gone.
|
||||
*
|
||||
* ── Only for a whole-tree sync ──
|
||||
*
|
||||
* A scoped sync knows about one folder, so everything outside it would look abandoned. Deleting on
|
||||
* that basis would take out the entire library the first time somebody re-synced a subfolder, which is
|
||||
* the kind of mistake that is only noticed later.
|
||||
*
|
||||
* Safe to run because `derived/` is reproducible by definition — that is what its name has meant since
|
||||
* the day the two folders were called `originals` and `derived` rather than `user` and `treated`.
|
||||
*/
|
||||
export async function pruneDerived(originalsRoot: string, derivedRoot: string): Promise<string[]> {
|
||||
const alive = new Set(await listOriginals(originalsRoot));
|
||||
const built = await indexDerived(derivedRoot);
|
||||
|
||||
const removed: string[] = [];
|
||||
for (const paths of built.values()) {
|
||||
for (const path of paths) {
|
||||
if (alive.has(path)) continue;
|
||||
await rm(join(derivedRoot, path), { recursive: true, force: true }).catch(() => {});
|
||||
removed.push(path);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user