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:
2026-08-29 20:42:20 +01:00
co-authored by Claude Opus 5
parent b634de5604
commit 01652f3480
4 changed files with 193 additions and 4 deletions
+121 -1
View File
@@ -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;
}