teqo-backup/scripts/lib/archiveIntegrityPlan.mjs
Francisco Solla c15f09531a feat(fotos): miniaturas AVIF pré-geradas do álbum público (C248)
Derivada AVIF 720px q60 armazenada como objeto-irmão determinístico de cada
foto aprovada (flickr-<id>-grade.avif), servida direto quando o cliente
negocia Accept: image/avif; fallback JPEG on-the-fly em ausência/falha, heal
preguiçoso via after(), warm no afterChange da aprovação e backfill
pnpm archive:thumbnails idempotente com recibo e guardas *_CONFIRM.

Sem migration, sem UI, sem mudança de URL pública ou pull-down.
Closes #1424
2026-10-02 17:40:41 -03:00

229 lines
8.6 KiB
JavaScript

/**
* C246 — pure planning/reporting of the photo-archive integrity sweep: the argv
* parser, the scan/repair summaries and the human lines + JSON receipt. No I/O
* of its own: the inspector, the Flickr client and the Payload writes are
* injected, so the unit spec drives everything with fakes. The bounded map that
* used to live here moved to `cli.mjs` when the C248 backfill became its second
* consumer.
*/
import { parseIdList } from './cli.mjs'
import { formatArchiveBytes } from './flickrPlan.mjs'
const USAGE =
'uso: `pnpm archive:integrity [--apply] [--only <ids>] [--limit <n>] [--concurrency <n>] [--out <dir>]`'
export const ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR = 'data/archive'
export const ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY = 3
export const archiveIntegrityReportStamp = (runAt) => String(runAt).replace(/[:.]/g, '-')
/**
* `--only 80,83` → `[80, 83]` (photo ids, the row primary key). Exact digits
* only: a typo must refuse the run, never silently widen or empty the selection.
*
* @param {string} value
* @returns {number[]}
*/
export const parseArchiveIntegrityOnly = (value) => parseIdList({ value, usage: USAGE })
/**
* `pnpm archive:integrity` argv parser — pure, so the unit spec pins it (the
* CLI owns the exit and the help text). The default mode is the read-only
* sweep; `--apply` adds the repair pass. Errors carry the usage line.
*
* @param {string[]} [argv]
* @returns {{ apply: boolean, only: number[] | null, limit: number | null, concurrency: number, out: string, help: boolean }}
*/
export const parseArchiveIntegrityCliArgs = (argv = process.argv.slice(2)) => {
const options = {
apply: false,
only: null,
limit: null,
concurrency: ARCHIVE_INTEGRITY_DEFAULT_CONCURRENCY,
out: ARCHIVE_INTEGRITY_DEFAULT_OUT_DIR,
help: false,
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
const value = () => {
const next = argv[++index]
if (next === undefined || next.startsWith('--')) {
throw new Error(`faltou valor para ${arg} — ${USAGE}.`)
}
return next
}
if (arg === '--help' || arg === '-h') options.help = true
else if (arg === '--apply') options.apply = true
else if (arg === '--only') options.only = parseArchiveIntegrityOnly(value())
else if (arg === '--limit') options.limit = Number(value())
else if (arg === '--concurrency') options.concurrency = Number(value())
else if (arg === '--out') options.out = value()
else throw new Error(`argumento desconhecido: ${arg} — ${USAGE}.`)
}
if (options.help) return options
if (options.limit !== null && (!Number.isInteger(options.limit) || options.limit < 1)) {
throw new Error(`--limit deve ser >= 1 — ${USAGE}.`)
}
if (!Number.isInteger(options.concurrency) || options.concurrency < 1) {
throw new Error(`--concurrency deve ser >= 1 — ${USAGE}.`)
}
if (options.out.split(/[\\/]/).includes('..')) {
throw new Error('--out não pode escapar do diretório do repo (sem "..").')
}
return options
}
/**
* Aggregates one sweep: what was read whole, what is missing/corrupt, and the
* rows kept out of the repair (`removed` is a takedown, C242). `pending` counts
* ONLY the broken `approved` rows — the public gap the read-only sweep fails on.
* A broken `draft` (already out of the public, e.g. withdrawn by a previous
* repair) and a broken `removed` are recorded but never block: the convergence
* after an `--apply` must be reachable.
*
* @param {Array<{ id: number, flickrId: string, filename: string | null, publicationStatus: string, status: 'ok' | 'missing' | 'corrupt', stage?: string | null, reason?: string | null, bytes?: number | null }>} results
*/
export const summarizeArchiveIntegrityScan = (results) => {
const missing = []
const corrupted = []
const draftBroken = []
const removedBroken = []
let ok = 0
let removedScanned = 0
let pending = 0
let bytes = 0
for (const result of results) {
bytes += Number(result.bytes) || 0
const publicationStatus = result.publicationStatus
const removed = publicationStatus === 'removed'
if (removed) removedScanned += 1
if (result.status === 'ok') {
if (!removed) ok += 1
continue
}
const entry = {
id: result.id,
flickrId: result.flickrId,
filename: result.filename ?? null,
publicationStatus,
stage: result.stage ?? null,
reason: result.reason ?? null,
}
if (removed) {
removedBroken.push({ ...entry, status: result.status })
continue
}
if (result.status === 'missing') missing.push(entry)
else corrupted.push(entry)
if (publicationStatus === 'approved') pending += 1
else draftBroken.push({ ...entry, status: result.status })
}
return {
scanned: results.length,
ok,
missing,
corrupted,
draftBroken,
removedScanned,
removedBroken,
pending,
bytes,
}
}
const PUBLICATION_LABEL = { approved: 'aprovada', draft: 'rascunho', removed: 'removida' }
/**
* Human lines for the sweep; the JSON file keeps the full report. Every broken
* row is named with id/arquivo/estado e o motivo.
*
* @param {Record<string, any>} report
* @returns {string[]}
*/
export const formatArchiveIntegrityScanReport = (report) => {
const label = '[archive:integrity]'
const scan = report.scan
const describe = (item) =>
`${item.id} (${item.filename ?? 'sem arquivo'}, ${PUBLICATION_LABEL[item.publicationStatus] ?? item.publicationStatus})`
const lines = [
`${label} varredura: ${scan.scanned} foto(s) · íntegras: ${scan.ok} · ausentes: ${scan.missing.length} · corrompidas: ${scan.corrupted.length} · quebradas fora do público: ${scan.draftBroken.length}`,
`${label} pendência pública (aprovadas quebradas): ${scan.pending}`,
`${label} removidas (takedown, não reparadas): ${scan.removedScanned} · com objeto quebrado: ${scan.removedBroken.length}`,
`${label} objeto lido: ${formatArchiveBytes(scan.bytes)} em ${(report.durationMs / 1000).toFixed(1)}s`,
]
for (const item of scan.missing) {
lines.push(` - ausente ${describe(item)}`)
}
for (const item of scan.corrupted) {
lines.push(
` - corrompida ${describe(item)}${item.stage ? ` [${item.stage}]` : ''}: ${item.reason ?? 'erro'}`,
)
}
for (const item of scan.removedBroken) {
lines.push(` - removida quebrada ${describe(item)} [${item.status}]: ${item.reason ?? 'erro'}`)
}
return lines
}
/**
* Aggregates one repair pass over the broken non-removed rows: what came back
* (`recovered`), what left the public with a record (`unrecoverable`) and what
* failed and needs another run.
*
* @param {Array<{ id: number, flickrId: string, filename: string | null, action: 'recovered' | 'unrecoverable' | 'failed', sourceUrl?: string | null, sha256?: string | null, bytes?: number | null, previousStatus?: string | null, newStatus?: string | null, stage?: string | null, reason?: string | null }>} results
*/
export const summarizeArchiveIntegrityRepair = (results) => {
const recovered = []
const unrecoverable = []
const failed = []
for (const result of results) {
if (result.action === 'recovered') recovered.push(result)
else if (result.action === 'unrecoverable') unrecoverable.push(result)
else failed.push(result)
}
return { recovered, unrecoverable, failed }
}
/**
* Human lines for the repair pass. Recovered photos carry provenance (source
* URL, bytes, sha256 prefix); unrecoverable ones carry the record of the exit
* from the public; failures name their stage.
*
* @param {Record<string, any>} report
* @returns {string[]}
*/
export const formatArchiveIntegrityRepairReport = (report) => {
const label = '[archive:integrity]'
const repair = report.repair
const lines = [
`${label} reparo: recuperadas: ${repair.recovered.length} · irrecuperáveis (fora do público): ${repair.unrecoverable.length} · falhas: ${repair.failed.length}`,
]
for (const item of repair.recovered) {
lines.push(
` - recuperada ${item.id} (${item.filename ?? 'sem arquivo'}) ${formatArchiveBytes(item.bytes)}` +
`${item.sha256 ? ` sha256:${String(item.sha256).slice(0, 12)}…` : ''}` +
`${item.sourceUrl ? ` ← ${item.sourceUrl}` : ''}`,
)
}
for (const item of repair.unrecoverable) {
lines.push(
` - irrecuperável ${item.id} (${item.filename ?? 'sem arquivo'})` +
`${item.previousStatus ? ` ${item.previousStatus} → ${item.newStatus ?? 'draft'}` : ''}: ${item.reason ?? 'fonte indisponível'}`,
)
}
for (const item of repair.failed) {
lines.push(
` - falha ${item.id} (${item.filename ?? 'sem arquivo'})${item.stage ? ` [${item.stage}]` : ''}: ${item.reason ?? 'erro'}`,
)
}
return lines
}