Ship a repair command and a container

verify reports integrity problems and repair rebuilds derivatives from the
Originals, which is what makes the file layout trustworthy without a database.
The image runs TypeScript on Node 24 with no build step, writes only under
MENU_ROOT, and the provision script writes the untracked site.env using the
image's own hasher so the server needs no Node.
This commit is contained in:
tanshu committed 2026-09-22 21:24:47 +05:30
1 parent 58ecfa0dc3
commit bb9ddb437f
12 files changed
+419 -3

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
/**
* Regenerates derivatives that are missing while their Original is intact.
*
* npm run repair
*/
import { repairSite } from '../storage/repair.ts';
import { loadCliSite } from './site.ts';
const { dataDir } = await loadCliSite();
const report = await repairSite({ dataDir });
for (const line of report.fixed) console.log(`fixed: ${line}`);
for (const line of report.unresolved) console.error(`unresolved: ${line}`);
console.log(`${report.fixed.length} file(s) repaired, ${report.unresolved.length} unresolved`);
if (report.unresolved.length > 0) process.exitCode = 1;
+33
View File
@@ -0,0 +1,33 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { ConfigError, parseSiteConfig, type SiteConfig } from '../config.ts';
export interface CliSite {
config: SiteConfig;
root: string;
dataDir: string;
}
/** The same environment variables the server reads, for the CLI commands. */
export async function loadCliSite(): Promise<CliSite> {
const root = path.resolve(process.env['MENU_ROOT'] ?? '.dev');
const configPath = path.resolve(
process.env['MENU_SITE_CONFIG'] ?? 'config/hopsngrains.site.json',
);
let raw: unknown;
try {
raw = JSON.parse(await readFile(configPath, 'utf8'));
} catch (error) {
throw new ConfigError(`could not read ${configPath}: ${(error as Error).message}`);
}
return {
config: parseSiteConfig(raw),
root,
dataDir: path.join(root, 'data'),
};
}
export function fail(message: string): never {
console.error(message);
process.exit(1);
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Reports integrity problems in the data directory without changing anything.
* Fatal problems are what the server refuses to start on.
*
* npm run verify
*/
import { hasFatal, validateSite } from '../storage/validate.ts';
import { loadCliSite } from './site.ts';
const { config, dataDir } = await loadCliSite();
const problems = await validateSite({
dataDir,
sections: config.sections.map((section) => section.slug),
});
if (problems.length === 0) {
console.log(`ok: ${dataDir}`);
} else {
for (const problem of problems) console.log(`${problem.level}: ${problem.message}`);
console.log(`${problems.length} problem(s) in ${dataDir}`);
}
if (hasFatal(problems)) process.exitCode = 1;
+1 -1
View File
@@ -57,7 +57,7 @@ export interface AppOptions {
export async function buildApp(options: AppOptions): Promise<FastifyInstance> {
const app = Fastify({
ignoreTrailingSlash: true,
routerOptions: { ignoreTrailingSlash: true },
trustProxy: true,
logger: { level: options.logLevel },
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Regenerates missing derivatives from the Originals. Originals are kept
* exactly so that a lost Viewing image or Thumbnail is recoverable, which is
* what makes the file layout the source of truth rather than the manifests.
*/
import { access, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { createImagePipeline } from '../images/pipeline.ts';
import { originalFileName, variantFileNames } from './manifest.ts';
import { sitePaths, type ImagePipeline, type StoredSet } from './sets.ts';
import { readSets } from './validate.ts';
export interface RepairReport {
fixed: string[];
unresolved: string[];
}
async function exists(target: string): Promise<boolean> {
try {
await access(target);
return true;
} catch {
return false;
}
}
export interface RepairOptions {
dataDir: string;
pipeline?: ImagePipeline;
}
export async function repairSite(options: RepairOptions): Promise<RepairReport> {
const paths = sitePaths(options.dataDir);
const pipeline = options.pipeline ?? createImagePipeline();
const report: RepairReport = { fixed: [], unresolved: [] };
const { sets } = await readSets(options.dataDir);
for (const set of sets as StoredSet[]) {
const originals = path.join(paths.originals, set.section, set.id);
for (const [index, page] of set.manifest.pages.entries()) {
const names = variantFileNames(page);
const where = `${set.state}/${set.section}/${set.slug ?? set.id} page ${index + 1} (${page.id})`;
const wanted: { file: string; key: 'webp' | 'jpeg' | 'thumb' }[] = [
{ file: path.join(set.dir, names.webp), key: 'webp' },
{ file: path.join(set.dir, names.jpeg), key: 'jpeg' },
{ file: path.join(set.dir, names.thumb), key: 'thumb' },
];
const missing = [];
for (const candidate of wanted) {
if (!(await exists(candidate.file))) missing.push(candidate);
}
if (missing.length === 0) continue;
const originalPath = path.join(originals, originalFileName(page));
if (!(await exists(originalPath))) {
report.unresolved.push(`${where}: ${missing.length} file(s) missing and the Original is gone`);
continue;
}
const data = await readFile(originalPath);
const derivatives = await pipeline.process(data, page.originalName);
if (derivatives.hash !== page.hash) {
report.unresolved.push(
`${where}: the Original no longer matches the manifest hash (${page.hash} → ${derivatives.hash}); re-upload the Page`,
);
continue;
}
for (const candidate of missing) {
await writeFile(candidate.file, derivatives[candidate.key]);
report.fixed.push(`${where}: rewrote ${path.basename(candidate.file)}`);
}
}
}
return report;
}
+15 -2
View File
@@ -37,8 +37,14 @@ async function listDirectoryNames(dir: string): Promise<string[]> {
}
}
export async function validateSite(options: ValidateOptions): Promise<Problem[]> {
const paths = sitePaths(options.dataDir);
export interface SiteContents {
sets: StoredSet[];
problems: Problem[];
}
/** Reads every set directory once; shared by validation and repair. */
export async function readSets(dataDir: string): Promise<SiteContents> {
const paths = sitePaths(dataDir);
const problems: Problem[] = [];
const sets: StoredSet[] = [];
@@ -60,6 +66,13 @@ export async function validateSite(options: ValidateOptions): Promise<Problem[]>
}
}
return { sets, problems };
}
export async function validateSite(options: ValidateOptions): Promise<Problem[]> {
const paths = sitePaths(options.dataDir);
const { sets, problems } = await readSets(options.dataDir);
for (const set of sets) {
const where = `${set.state}/${set.manifest.section}/${set.slug ?? set.manifest.id}`;
if (!options.sections.includes(set.manifest.section)) {