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:
1 parent
58ecfa0dc3
commit
bb9ddb437f
12 files changed
+419
-3
No files matched your search
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
Reference in new issue
Block a user