Compare commits
9 commits
ac39c81765
...
8e32c7cee7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e32c7cee7 | ||
|
|
afc22e5bf3 | ||
|
|
e4e51a3651 | ||
|
|
4aab740895 | ||
|
|
53f8e4a3cd | ||
|
|
c15f09531a | ||
|
|
4feea40c01 | ||
|
|
255751f5ce | ||
|
|
a3a49c3462 |
|
|
@ -0,0 +1 @@
|
|||
**C246-hotfix — checksum errado do Garage em uploads multipart >50 MB (2026-10-02):** a operação do C246 destravou um bug de storage que explicava as fotos 80/83: o adapter `@payloadcms/storage-s3` usa upload **multipart** acima de 50 MB e o Garage grava um `x-amz-checksum-crc32` que **não corresponde ao corpo** (medido: objeto de 53.762.718 bytes com checksum armazenado `j/yKJQ==` e conteúdo real `NE5FZQ==`); o SDK valida o checksum na resposta do GET e aborta, então toda leitura do objeto dava 500 — o conteúdo sempre esteve correto. As duas panorâmicas de 7952px (~54 MB) do acervo eram os únicos objetos nessa faixa. O reparo do C246 re-uploadava pelo mesmo caminho multipart e a verificação continuava falhando (o mesmo checksum errado era regravado). Correção estrutural: `requestChecksumCalculation: 'WHEN_REQUIRED'` no client do `s3Storage` (`src/payload.config.ts`) — o SDK para de anexar checksums de upload (o PUT único já gravava certo; o multipart era o defeito); as leituras seguem validando o checksum que o objeto tiver e a varredura de integridade (download + decode) continua sendo a checagem honesta. Ops imediato (sem deploy): reescrita dos dois objetos com PUT único dos bytes verificados do original do Flickr (sha256 `9108ed5c…`, idêntico nas duas fontes) — canário 2/2 íntegras e `/fotos/80/midia` + `/fotos/83/midia` respondendo 200 com os 53.762.718 bytes completos. Varredura completa anterior: 6.492 fotos, 6.490 íntegras, só as 2 corrompidas. Sem migration, sem UI.
|
||||
1
docs/changelog/2026-10-02-c246-reparo-timeout.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C246-followup — teto do download do reparo e caminhos do runbook (2026-10-02):** o primeiro uso real do `pnpm archive:integrity --apply` em produção (canário 80/83) abortou os dois reparos no `download` do Flickr: o original é uma panorâmica de 7952px com **~54 MB** e a egress direta do homeserver estava a ~260 KB/s (~3,5 min), acima do teto de 2 min herdado da ingestão C231. O teto do download do reparo vira `ARCHIVE_INTEGRITY_DOWNLOAD_TIMEOUT_MS = 15 min` (constante exportada e pinada no unit), que ainda pega conexão travada sem cortar link lento-mas-funcional; falha de download continua `failed` retentável (nunca rebaixa). O runbook §C246 passa a usar `~/archive-reports` (o `/srv/archive-reports` do texto original não existe e não é gravável pela `fsolla`) e ganha o `docker pull` do migrator antes dos comandos — a limpeza pós-deploy remove a tag local e o registry preserva a imagem. Sem migration/UI; o restante da entrega (varredura + reparo + rebaixamento) fica intocado.
|
||||
1
docs/changelog/2026-10-02-c248.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C248 — Miniaturas AVIF pré-geradas do álbum público (previews rápidos nos cards) (2026-10-02):** a grade `/fotos` deixa de pagar resize por request: cada foto aprovada ganha uma derivada AVIF 720px q60 armazenada como **objeto-irmão determinístico** no mesmo store privado do original (`flickr-<id>.<ext>` → `flickr-<id>-grade.avif`), servida direto pela rota `/fotos/<id>/midia?tamanho=grade` quando o cliente manda `Accept: image/avif` (token explícito; `*/*`/sem header segue no JPEG). A rota ganha a negociação pura (`acceptsAvif`, `Vary: Accept` mantendo `private, no-store` e `noindex`), um dono de I/O estendido em `privateMediaResponse` (`privateMediaObjectExists`/`writePrivateObject`/`encodePrivateImageFile`/`encodePrivateMediaImage`, este extraído do resize on-the-fly sem mudança de comportamento) e o heal preguiçoso: derivada ausente/ilegível cai no fallback JPEG de sempre e a geração é reagendada em `after()`; a aprovação aquece a derivada best-effort no `afterChange` (falha nunca bloqueia a publicação; catalogação/índice facial/C246 não disparam porque o status não muda). O backfill one-shot `pnpm archive:thumbnails` (plan read-only / `--apply` / `--verify`) é idempotente por HEAD, elegível = aprovadas com arquivo, classifica `generated | already-present | missing-origin | corrupt-origin | failed` com recibo em `data/archive/reports/`, guardas `ARCHIVE_THUMBNAILS_CONFIRM=1` + `TEQO_ENV` exato + S3 completo em alvo não-local, e reusa a MESMA função de geração do servidor; o débito do C246 foi quitado movendo `mapWithConcurrency` e o parser `--only` para `scripts/lib/cli.mjs` no segundo consumidor. Sem migration, sem UI, sem mudança de URL pública nem no pull-down; runbook §C248. Testes: unit do naming/negociação/plan-lib, int da geração/heal e da matriz da rota (AVIF presente → `image/avif`; ausente/sem `Accept` → JPEG; hit não agenda heal, miss agenda), e2e `frontendFotos` mantido no JPEG com `Vary` pinado.
|
||||
1
docs/changelog/2026-10-02-c249.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**C249 — archive:publish com preflight de integridade antes de aprovar drafts (2026-10-02):** o lote de aprovação do acervo deixa de confiar cegamente no `publicationStatus` e passa a inspecionar cada draft pelo MESMO dono da varredura do C246 (`inspectPrivateMediaObject` + `resolvePrivateMediaStaticDir`, import dinâmico como no sweep): objeto baixado pelo caminho da rota pública e decodificado com sharp antes de qualquer `payload.update`. Objeto fora de `ok` **não é aprovado** — fica `draft`, entra no recibo como `skippedBroken` (id, stage `missing`/`download`/`decode`, motivo) e o comando sai **1**; o modo plano (sem `--apply`) também inspeciona (read-only) e sai 1 se houver quebrados, para o operador ver antes. A fila passa a selecionar `filename` além de `id`; o lote vira o seam exportado `runArchivePublishBatch` (temp por foto removido no `finally`, sem cache entre plan/apply, progresso a cada 200) e o script ganha guard de entrypoint `isDirectRun`, então o int importa o caminho real sem tocar o banco inteiro. O puro `scripts/lib/archivePublishPlan.mjs` ganha `skippedBroken`/`skippedBrokenCount` no summary e as linhas `! foto <id> fora do lote (<stage>): <reason>` no recibo; pulada nunca conta como falha de update. Guardas do publish intactas (`ARCHIVE_PUBLISH_CONFIRM` + `TEQO_ENV`; sem S3 novo/flag nova — sem S3 o alvo não-local classifica tudo `missing` e nada aprova, fail-closed). Sem migration, sem UI, sem contrato público; `scripts/publish-archive-photos.mjs` entra no `SCRIPTS_SPEC_PINNED` (high-risk exato → unit/int full + e2e curado). Runbook C242/C246 atualizado (o preflight nomeia/pula; a varredura segue sendo quem repara). Testes: unit do summarize/format com `skippedBroken` e do pin de help; int sobre `teqo_test` com objeto íntegro aprovado, corrompido `decode` e removido `missing` fora do lote (recibo nomeando) e plano sem escrita. Impl plan: `docs/plans/acervo-publish-preflight-integridade-impl.md`.
|
||||
1
docs/changelog/2026-10-02-hero-webp-avif-otimizador.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**Fix — hero da home sem imagens: chaves AVIF presas no otimizador do Next (2026-10-02):** o relato "as imagens pararam de aparecer" reproduziu em produção após o deploy do SHA `ac39c817`: no browser, `RUI`/`WAGNER`/`Jeronimo` (e `Lula` em certos viewports) do hero não carregavam — requests a `/_next/image` pendentes para sempre (`complete=false`, `naturalWidth=0`) e Cloudflare 524 após ~100–125s no URL exato; a matriz em prod (Accept de browser, q=75) envenenou exatamente `RUI - 2-2.avif w=256`, `WAGNER - 2-9 final.avif w=256`, `Jeronimo.avif w=256` e `Lula.avif w=384` (o conjunto que um load 1920×1080 DPR1 pede em paralelo), enquanto todas as outras larguras (16–3840) e imagens respondiam 200 em ~0,3–6s, e o mesmo `RUI w=256` em avif/q=76 respondia 200 em ~0,26s. Causa-raiz: o `Batcher` do `ResponseCache` (`node_modules/next/dist/lib/batcher.js`) deduplica por cache key e guarda a promise em `this.pending` sem timeout — uma geração que pendurou (os masters AVIF do hero decodificados via libheif no sharp 0.34.2, caminho do advisory crítico GHSA-2xp9-vwfh-vxw4, "Unauthenticated RCE in Image Optimization API when AVIF files are used", `next >=10 <15.5.24`, cuja mitigação oficial no patch `409772ec`/15.5.24 é adicionar AVIF a `BYPASS_TYPES`; o hang transiente não foi reproduzido localmente — sharp glibc e musl/Alpine, sequencial e 4 concorrentes, 15 rodadas — nem em staging idêntico) deixou a chave presa para sempre até restart, e o fix anterior (`16f95975`) havia regerado os masters como AVIF display-size sem que nenhum guard olhasse formato/mídia. O fix converte os 8 AVIF locais referenciados via `next/image` para WebP — 5 masters do hero regerados display-size a partir dos próprios AVIF aprovados (`pnpm images:resize --format webp --quality 85`: `RUI`/`WAGNER`/`Jeronimo` 768px, `Lula` 1024px, `JOA00162` 1536px; 538KB vs 351KB) e 3 cards da home apontados para os `.webp` existentes —, deleta os 9 `.avif` de `public/` (8 referenciados + `Marca-Lula.avif` órfão) e faz `scripts/build-radio-artes.mjs` ler `JOA00162.webp`; o caminho vira libwebp (loader seguro no patch oficial) e as URLs do otimizador mudam de chave, matando as 4 chaves envenenadas no deploy sem purge nem restart. Guardas: ban de literal `.avif` em `src/` (duas grafias, incluindo `image:`) e zero `.avif` em `public/` em `tests/unit/imageDelivery.unit.spec.ts`, e hero exigindo `.webp` em `tests/unit/campaignHero.unit.spec.tsx` — vermelhos sem o fix (2–3 falhas), verdes com (8 passed); otimizador local 200 `image/webp` a 256w em 0,7–1,6ms e derivados menores (ex.: `RUI` 256: 9,8KB vs 12,2KB); e2e `frontend.e2e.spec.ts` 42 passed; `pnpm gate:fast` 468 arquivos/5178 testes. Post-mortem em `docs/postmortems/2026-10-02-hero-webp-avif-otimizador.md` (prevenções caras registradas como Issues: upgrade Next ≥15.5.24, Cache Rule/observabilidade Cloudflare para `/_next/image` com runbook de purge, e healthcheck/probe sintético de chaves presas; não fazer: patch local no Batcher, `unoptimized` global, upgrade neste PR). Sem migration, sem schema, sem URL pública alterada.
|
||||
|
|
@ -1438,7 +1438,7 @@ docker compose --profile maintenance run --rm \
|
|||
```
|
||||
|
||||
Os recibos JSON ficam em `data/archive/reports/` do container — monte um volume
|
||||
(`-v /srv/archive-reports:/app/data/archive`) para preservá-los. Guardas: fora de
|
||||
(`-v ~/archive-reports:/app/data/archive`) para preservá-los. Guardas: fora de
|
||||
teste o `TEQO_ENV=staging|production` é obrigatório com o **nome exato** do banco
|
||||
(`teqo_staging`/`teqo_1313`); `ALLOW_REMOTE_DB` é recusado; sem as 4 `S3_*` o
|
||||
`--apply` recusa (o acervo não estaria acessível no alvo); `ARCHIVE_CATALOG_CONFIRM=1`
|
||||
|
|
@ -1528,12 +1528,20 @@ aparelho do visitante (o navegador envia só o vetor). A flag
|
|||
```
|
||||
(dry-run sem `--apply` mostra o estado; o guard exige `TEQO_ENV` casando o
|
||||
banco exato.)
|
||||
3. **Aprovar o acervo** (drafts → approved; `removed` nunca é tocado):
|
||||
3. **Aprovar o acervo** (drafts → approved; `removed` nunca é tocado). Desde o
|
||||
C249 cada draft passa pelo **preflight de integridade** do C246 antes de
|
||||
aprovar: o objeto é baixado e decodificado pelo mesmo caminho da rota
|
||||
pública; objeto ausente/corrompido fica **fora do lote**, é nomeado no recibo
|
||||
(`skippedBroken` com stage + motivo) e o comando sai **1**. O modo plano
|
||||
(sem `--apply`) também inspeciona (read-only) e sai 1 se houver quebrados; o
|
||||
custo do lote é download+decode de toda a fila, então comece pelo canário:
|
||||
```bash
|
||||
docker compose --profile maintenance run --rm \
|
||||
-e TEQO_ENV=production -e ARCHIVE_PUBLISH_CONFIRM=1 \
|
||||
teqo-1313-migrate pnpm archive:publish --apply --limit 20 # canário
|
||||
docker compose --profile maintenance run --rm \
|
||||
-e TEQO_ENV=production -e ARCHIVE_PUBLISH_CONFIRM=1 \
|
||||
teqo-1313-migrate pnpm archive:publish --apply
|
||||
# canário: --limit 20
|
||||
```
|
||||
Ao fim, buste a tag pública em um processo que a possui (o write do CLI não
|
||||
revalida o servidor Next):
|
||||
|
|
@ -1694,24 +1702,29 @@ canário primeiro:
|
|||
ssh homeserver
|
||||
cd ~/stack
|
||||
# 1) canário read-only nas fotos do achado (80/83) — não escreve:
|
||||
# (a limpeza pós-deploy remove a tag local do migrator; o registry a preserva)
|
||||
docker pull "$(docker inspect -f '{{.Config.Image}}' teqo-1313 | sed 's/teqo-1313:/teqo-1313-migrator:/')"
|
||||
docker compose --profile maintenance run --rm \
|
||||
-v /srv/archive-reports:/app/data/archive \
|
||||
-v ~/archive-reports:/app/data/archive \
|
||||
teqo-1313-migrate pnpm archive:integrity --only 80,83
|
||||
# 2) reparo do canário (exige TEQO_ENV + confirmação; as 4 S3_* e FLICKR_API_KEY):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-e TEQO_ENV=production -e ARCHIVE_INTEGRITY_CONFIRM=1 \
|
||||
-v /srv/archive-reports:/app/data/archive \
|
||||
-v ~/archive-reports:/app/data/archive \
|
||||
teqo-1313-migrate pnpm archive:integrity --apply --only 80,83
|
||||
# 3) conferir as duas URLs públicas respondendo 200 (antes do lote):
|
||||
# https://jorgesolla1313.com.br/fotos/80/midia e /fotos/83/midia
|
||||
# 3) conferir as duas URLs públicas com GET completo (o HEAD não lê o corpo e
|
||||
# não valida o checksum — dá 200 mesmo com objeto corrompido):
|
||||
# curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
|
||||
# https://jorgesolla1313.com.br/fotos/80/midia
|
||||
# (idem para /fotos/83/midia)
|
||||
# 4) varredura completa (read-only; ~6,5k downloads no bucket local; sai 1 se achar):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-v /srv/archive-reports:/app/data/archive \
|
||||
-v ~/archive-reports:/app/data/archive \
|
||||
teqo-1313-migrate pnpm archive:integrity
|
||||
# 5) reparo do que a varredura acusou (mesma linha com --apply):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-e TEQO_ENV=production -e ARCHIVE_INTEGRITY_CONFIRM=1 \
|
||||
-v /srv/archive-reports:/app/data/archive \
|
||||
-v ~/archive-reports:/app/data/archive \
|
||||
teqo-1313-migrate pnpm archive:integrity --apply
|
||||
# 6) convergência: re-rodar a varredura; 0 pendências = aceite
|
||||
```
|
||||
|
|
@ -1723,6 +1736,20 @@ varredura mediria o disco efêmero do container); `ALLOW_REMOTE_DB` é recusado.
|
|||
`--concurrency` (default 3) e `--limit`/`--only` são os canários; o recibo traz
|
||||
varridas/ok/ausentes/corrompidas/removidas, e no reparo recuperadas/
|
||||
irrecuperáveis/falhas. A varredura **não** conserta nada: só o `--apply` escreve.
|
||||
O download do original no reparo tem teto de **15 min** — um original grande
|
||||
(~50 MB, ex. panorâmica 7952px) leva minutos no link lento da casa; falha de
|
||||
download é `failed` retentável, nunca rebaixamento.
|
||||
|
||||
**Originais >50 MB e o checksum do Garage (incidente 2026-10-02):** o adapter
|
||||
`storage-s3` sobe arquivos grandes via multipart e o Garage gravava um
|
||||
`x-amz-checksum-crc32` que não bate com o corpo — todo GET do objeto falhava na
|
||||
validação do SDK (500 no proxy público), mesmo com o conteúdo correto. O client
|
||||
do plugin ficou com `requestChecksumCalculation: 'WHEN_REQUIRED'` (não anexa
|
||||
checksum de upload; a leitura segue validando o que o objeto tiver). Se um
|
||||
objeto legado aparecer com esse sintoma (`checksum mismatch` no recibo e no
|
||||
GET), a correção pontual é reescrever os mesmos bytes com **PUT único** pelo
|
||||
client S3 (o PUT único grava o checksum certo) — ver changelog
|
||||
`2026-10-02-c246-garage-checksum-multipart.md`.
|
||||
|
||||
Depois de um `--apply` que rebaixou alguma foto, buste a tag pública em um
|
||||
processo que a possui (o write do CLI não revalida o servidor Next):
|
||||
|
|
@ -1734,13 +1761,74 @@ curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
|
|||
|
||||
Fotos reparadas que continuam `approved` voltam a servir sozinhas (mesma chave,
|
||||
rota `force-dynamic`) — e o índice facial reprocessa sozinho o que tiver
|
||||
marcador stale (`pnpm faces:index`). Uma foto rebaixada a `draft` **não** deve
|
||||
voltar em lote: o `pnpm archive:publish` aprova todo draft sem checar o objeto,
|
||||
então só rode o publish depois de uma varredura limpa (a foto rebaixada só sai
|
||||
de draft quando a fonte for recuperada e o operador decidir publicá-la).
|
||||
marcador stale (`pnpm faces:index`). Uma foto rebaixada a `draft` **não volta
|
||||
por engano** no lote: desde o C249 o `pnpm archive:publish` inspeciona cada
|
||||
draft pelo mesmo `inspectPrivateMediaObject` desta varredura antes de aprovar —
|
||||
a quebrada fica fora do lote, sai nomeada no recibo (`skippedBroken`) e o
|
||||
comando termina 1. O aviso antigo ("só rode o publish depois de uma varredura
|
||||
limpa") deixou de ser a única proteção; ainda vale rodar a varredura limpa
|
||||
primeiro, porque ela é quem **repara** (o publish não repara, só pula e nomeia).
|
||||
Rollback: sem migration nesta entrega; o comando nunca apaga objeto nem linha —
|
||||
uma foto rebaixada por engano volta a `approved` pela ficha no admin (com o
|
||||
canal de remoção configurado).
|
||||
canal de remoção configurado) ou pelo publish depois de o objeto inspecionar
|
||||
limpo.
|
||||
|
||||
## C248 — miniaturas AVIF do álbum público (backfill das derivadas)
|
||||
|
||||
O comando `pnpm archive:thumbnails` gera a miniatura AVIF 720px q60 de cada foto
|
||||
**aprovada** do acervo como um objeto-irmão determinístico no mesmo store
|
||||
privado do original (`flickr-<id>.<ext>` → `flickr-<id>-grade.avif`); nada de
|
||||
banco, nada de original alterado. A rota `/fotos/<id>/midia?tamanho=grade`
|
||||
serve a derivada direto quando o cliente manda `Accept: image/avif`; sem o
|
||||
token, ou com a derivada ausente/ilegível, ela responde o JPEG on-the-fly de
|
||||
sempre e regenera a derivada em background (`after()`). A aprovação de uma foto
|
||||
no admin também aquece a derivada (best-effort; falha nunca bloqueia). O lote
|
||||
cobre o que foi aprovado antes disso e o que o hook perder (CLI em lote,
|
||||
restart).
|
||||
|
||||
```bash
|
||||
# no homeserver:
|
||||
ssh homeserver
|
||||
cd ~/stack
|
||||
# 1) plano read-only: elegíveis do álbum aprovado e quantas derivadas faltam
|
||||
docker compose --profile maintenance run --rm \
|
||||
-v /srv/archive-reports:/app/data/archive \
|
||||
teqo-1313-migrate pnpm archive:thumbnails
|
||||
# 2) canário (exige TEQO_ENV + confirmação; as 4 S3_* no alvo):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-e TEQO_ENV=production -e ARCHIVE_THUMBNAILS_CONFIRM=1 \
|
||||
-v /srv/archive-reports:/app/data/archive \
|
||||
teqo-1313-migrate pnpm archive:thumbnails --apply --limit 20
|
||||
# 3) lote completo (encode AVIF local; ~6,5k fotos):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-e TEQO_ENV=production -e ARCHIVE_THUMBNAILS_CONFIRM=1 \
|
||||
-v /srv/archive-reports:/app/data/archive \
|
||||
teqo-1313-migrate pnpm archive:thumbnails --apply
|
||||
# 4) aceite: cobertura 100% (read-only; sai 1 se faltar alguma):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-v /srv/archive-reports:/app/data/archive \
|
||||
teqo-1313-migrate pnpm archive:thumbnails --verify
|
||||
# 5) conferir uma resposta real negociada (espera content-type image/avif):
|
||||
curl -sD - -o /dev/null -H 'Accept: image/avif' \
|
||||
"https://jorgesolla1313.com.br/fotos/<id>/midia?tamanho=grade"
|
||||
```
|
||||
|
||||
Guardas: fora de teste o `--apply` exige `TEQO_ENV` casando o **nome exato** do
|
||||
banco (`teqo_staging`/`teqo_1313`), `ARCHIVE_THUMBNAILS_CONFIRM=1` e — em alvo
|
||||
não-local — as 4 `S3_*` (o original vive no bucket); `ALLOW_REMOTE_DB` é
|
||||
recusado. O plano e o `--verify` são read-only. Reexecutar converge: derivada
|
||||
existente é pulada por HEAD; original ausente/ilegível vira "pulada" com o
|
||||
motivo no recibo (a C246 recupera o original e um novo `--apply` regenera);
|
||||
falha de rede/upload fica registrada como falha e retenta na próxima execução.
|
||||
**Serialize com `archive:catalog` e `archive:integrity`** (C245/C246 tocam os
|
||||
mesmos originais) e rode em janela própria. `--concurrency` default 2 (o mesmo
|
||||
teto do processo web), `--only`/`--limit` são os canários; recibos em
|
||||
`data/archive/reports/`.
|
||||
|
||||
Nada a revalidar depois do lote: a rota descobre a derivada por chave
|
||||
determinística a cada request (`force-dynamic`, `no-store`). Rollback: sem
|
||||
migration nesta entrega; basta remover os objetos `flickr-<id>-grade.avif` do
|
||||
bucket (a rota volta ao resize on-the-fly sozinha) — o original nunca é tocado.
|
||||
|
||||
## C230-followup — importação do Instagram para a Central de Conteúdos
|
||||
|
||||
|
|
|
|||
156
docs/plans/acervo-publish-preflight-integridade-impl.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# Impl: C249 — archive:publish: preflight de integridade antes de aprovar drafts
|
||||
|
||||
Status: aprovado (modo --auto)
|
||||
Atualizado em: 2026-10-02
|
||||
Issue: #1427
|
||||
Intenção: docs/plans/acervo-publish-preflight-integridade.md
|
||||
Appetite restante: herdado (~0,5 dia eng; sem migration, sem UI, sem contrato público). Custo distribuído ~70% preflight/CLI, ~20% provas, ~10% runbook.
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** `pnpm archive:publish` (plan e `--apply`) passa cada draft elegível pelo `inspectPrivateMediaObject` do C246; objeto fora de `ok` **não é aprovado**, entra no recibo como `skippedBroken` (id, stage, reason) e o comando sai 1 se houver puladas — o lote nunca devolve mídia quebrada ao público.
|
||||
- **O que NÃO negociar (aceite inviolável):** aplicar nunca aprova draft com objeto quebrado; recibo nomeia cada pulada com stage/motivo; exit 1 se houver puladas; int cobre aprovadas e puladas; guardas do publish intactos (`ARCHIVE_PUBLISH_CONFIRM`/`TEQO_ENV`); runbook atualizado. Não reparar objeto (dono é C246), não tocar `removed`, não varrer outras collections/vídeos, sem migration/campo novo, sem cache/revalidação além do publish atual.
|
||||
- **O que reavaliar (hipóteses da direção):**
|
||||
- A composição do sweep (import dinâmico da utility + `resolvePrivateMediaStaticDir` + `inspect` injetado) realmente é reusável no publish sem novo módulo de varredura (D1).
|
||||
- O plano também inspeciona (custo O(fila) de download+decode) — confirmado pela intenção, mas registrar o custo (D4).
|
||||
- Como o int prova a orquestração sem varrer o banco de teste inteiro — o script precisa de um seam importável (D6).
|
||||
- Publish não exige S3 hoje; sem S3 num alvo não-local toda a fila vira `missing` (fail-closed) — decidir se vira guarda nova (D7).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CMD["pnpm archive:publish<br/>[--apply] [--limit n]"] --> ENV{mode}
|
||||
ENV -->|apply| G["guardas atuais:<br/>ARCHIVE_PUBLISH_CONFIRM + TEQO_ENV"]
|
||||
ENV -->|plan| Q
|
||||
G --> Q["payload.find drafts<br/>select id + filename"]
|
||||
Q --> PF["preflight: inspectPrivateMediaObject<br/>por draft (temp por foto, finally cleanup)"]
|
||||
PF --> OK{"status == ok?"}
|
||||
OK -->|"ok + apply"| UPD["payload.update<br/>publicationStatus: approved"]
|
||||
OK -->|"missing/corrupt"| SKIP["result: skippedBroken<br/>{photoId, stage, reason}"]
|
||||
OK -->|"ok + plan"| PREV["preview elegível"]
|
||||
UPD --> SUM["summarizeArchivePublishResults<br/>approved | failed | failures | skippedBroken"]
|
||||
SKIP --> SUM
|
||||
SUM --> REP["formatArchivePublishReport + recibo JSON"]
|
||||
REP --> EXIT{"skippedBrokenCount > 0?"}
|
||||
EXIT -->|sim| E1["die → exit 1"]
|
||||
EXIT -->|"não (apply)"| E2{"approved == 0 e results > 0?"}
|
||||
E2 -->|sim| E3["die (comportamento atual)"]
|
||||
E2 -->|não| E0["exit 0"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) compor o preflight dentro do `publish-archive-photos.mjs` reusando o dono da varredura C246 e estender a lib pura só com summarize/format | B) criar um módulo/lib novo de varredura no publish | C) import estático da utility no topo do script.
|
||||
|
||||
**Recomendação:** A — o C246 já é o dono do _como_ inspecionar (download pelo caminho de serviço + decode sharp); o publish só precisa do _quando_ (antes de aprovar) e do recibo. Manter `scripts/lib/archivePublishPlan.mjs` puro preserva o padrão unit-testável do repo (fakes dirigem summarize/format) e o import dinâmico da utility evita abrir S3 no boot do CLI.
|
||||
|
||||
**Rejeitadas:** B porque duplica o dono da varredura e cria um segundo caminho de inspeção que pode divergir do sweep; C porque a utility puxa `server-only`/S3/sharp no boot — o sweep documenta exatamente essa armadilha com o import dinâmico (`scripts/check-archive-integrity.mjs:195-196`).
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
**D1. Onde vive o preflight.**
|
||||
Opções: A) função/loop no próprio `scripts/publish-archive-photos.mjs`, com o import dinâmico de `inspectPrivateMediaObject`/`resolvePrivateMediaStaticDir` e `inspect` injetado | B) novo `scripts/lib/archivePublishScan.mjs` | C) import estático da utility.
|
||||
Recomendação: A — o dono do _probe_ continua sendo `src/utilities/privateMedia/privateMediaResponse.ts:335`; o publish reusa a mesma composição do sweep (`dynamic import` + `resolvePrivateMediaStaticDir(payload, 'archivePhoto')` + injeção de `inspect`), sem varredura própria.
|
||||
Rejeitadas: B duplica o dono e a composição; C quebra o boot sem S3/local disk path.
|
||||
|
||||
**D2. Forma do resultado e do recibo.**
|
||||
Opções: A) estender `results` com `{photoId, status:'skippedBroken', stage:'missing'|'download'|'decode', reason}` e `summary` com `skippedBroken` (array) + `skippedBrokenCount` | B) campo novo no schema (`publicationStatus: 'broken'` ou similar) | C) só log no console, sem recibo.
|
||||
Recomendação: A — o `skippedBroken` vive no recibo (JSON + linhas), não no schema; `summarizeArchivePublishResults` passa a ramificar por status (hoje qualquer não-`approved` vira `failed`, `archivePublishPlan.mjs:55-61`) para que pulada nunca seja contada como falha.
|
||||
Rejeitadas: B exige migration desnecessária e cria estado novo de publicação; C não atende o aceite ("recibo nomeia cada pulada").
|
||||
|
||||
**D3. Exit code e interação com o `die` atual.**
|
||||
Opções: A) após gravar o recibo, `skippedBrokenCount > 0` → `die(...)` com mensagem específica, antes da checagem legada `approved === 0 && results.length > 0` | B) `process.exitCode = 1` silencioso | C) manter exit 0 com puladas.
|
||||
Recomendação: A — `die` (`dieWithLabel`, `scripts/lib/cli.mjs:18-21`) já é o padrão de falha do CLI, imprime o label e sai 1; como o recibo é gravado antes, nada se perde. `approved > 0` com puladas continua saindo 1 (aceite). A checagem legada permanece como fallback para falhas puras de update. No modo plano, o exit 1 por puladas também vale (o operador/CI precisa do sinal antes do `--apply`), depois de imprimir o recibo.
|
||||
Rejeitadas: B não explica o motivo no stderr; C viola o aceite.
|
||||
|
||||
**D4. Plano também inspeciona.**
|
||||
Opções: A) sim, com o mesmo preflight no modo plano | B) só no `--apply`, plano lista a fila sem olhar o objeto | C) inspecionar no plano apenas com `--limit` (canário).
|
||||
Recomendação: A — a intenção pede explicitamente "e no plano, para o operador ver antes"; o plano continua read-only (nenhum `payload.update`), mas paga download+decode de toda a fila. O custo é assumido: o recibo do plano usa o mesmo `summary` da lib pura (o censo `eligible` não conta como falha; `approved`/`failed` ficam 0) e o `--limit` segue como canário. Cortar o preflight do plano contrariaria a intenção — não cortar.
|
||||
Rejeitadas: B falha o pedido explícito do operador; C faz o caminho default mentir (o operador não vê o que o apply faria).
|
||||
|
||||
**D5. Arquivos temporários.**
|
||||
Opções: A) temp por foto, removido no `finally`, dono do diretório é a própria função de lote (espelho de `inspectRow` do sweep, `scripts/check-archive-integrity.mjs:137-158`) | B) cache de um diretório compartilhado entre plan/apply | C) temp dentro do repo (`data/archive/tmp`).
|
||||
Recomendação: A — cada foto é baixada para `<tempRoot>/<photoId>` e o diretório é removido no `finally`; o `tempRoot` (`mkdtemp(tmpdir(), 'archive-publish-')`) é criado e removido pela função. Sem cache entre modos: o objeto pode mudar entre o plan e o apply (é exatamente o risco que o preflight cobre).
|
||||
Rejeitadas: B introduz stale state justamente na janela que importa; C polui o repo e corre risco de sobra em crash.
|
||||
|
||||
**D6. Seam de teste do lote (int).**
|
||||
Opções: A) exportar `runArchivePublishBatch({ payload, queue, inspect, staticDir, apply, log })` do próprio `scripts/publish-archive-photos.mjs` e guardar `main()`/`loadCliEnv()` com `isDirectRun` (`import.meta.url` × `pathToFileURL(process.argv[1])`) | B) spawnar a CLI contra `teqo_test` e ler o recibo | C) duplicar o loop no teste.
|
||||
Recomendação: A — o int dirige o lote real (Payload real + `inspectPrivateMediaObject` real + `resolvePrivateMediaStaticDir` real) com a **própria fila**, sem tocar drafts de outros specs; o padrão de script importável já existe no repo (`scripts/build-chart-from-data.mjs:301`, `scripts/copy-face-vision-assets.mjs:52-56` e o unit `tests/unit/buildChartFromData.unit.spec.ts:9`).
|
||||
Rejeitadas: B é frágil — o int roda em forks paralelos (`vitest.config.mts:31-51`) e a CLI aprovaria _todos_ os drafts do banco, interferindo em specs vizinhos (ex.: os estados do `archiveIntegrity.int.spec.ts`); C testa uma cópia, não o caminho de produção.
|
||||
|
||||
**D7. Guarda de S3 no publish.**
|
||||
Opções: A) não adicionar guarda nova; manter as guardas atuais e documentar no runbook que o publish roda no maintenance com o env do stack (como o sweep) | B) exigir as 4 `S3_*` em alvo não-local, espelhando `mirroredMediaRequired` do sweep | C) exigir S3 só no `--apply`.
|
||||
Recomendação: A — o não escopo da intenção proíbe mexer nas guardas/confirm; sem S3 o fallback é o disco local do container (vazio em produção), então todo draft classifica `missing`, nada é aprovado e o comando sai 1 — ou seja, o fail-closed já existe e é o lado seguro. O runbook fixa o modo de execução correto. A guarda B fica como gatilho de revisitação se houver operador rodando publish fora do maintenance.
|
||||
Rejeitadas: B amplia contrato de CLI não pedido (e o alvo local de dev/test não tem S3 de propósito); C deixa o plan medir o storage errado silenciosamente.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`summarizeArchivePublishResults`** (`scripts/lib/archivePublishPlan.mjs:50`): aceita os 3 status; `approved`/`failed`/`failures` como hoje e `skippedBroken` (array de `{photoId, stage, reason}`) + `skippedBrokenCount`; nunca conta pulada como falha. Pura, sem I/O.
|
||||
- **`formatArchivePublishReport`** (`scripts/lib/archivePublishPlan.mjs:66`): no apply imprime `aprovadas: N | falharam: M | puladas (quebradas): K` e uma linha por pulada com `foto <id> fora do lote (<stage>): <reason>`; no plan imprime `drafts quebrados (fora do lote): K` + as mesmas linhas (sem `approved`/`failed`). Tolerante a summary sem as chaves legadas (`?.`).
|
||||
- **`runArchivePublishBatch`** (novo export de `scripts/publish-archive-photos.mjs`): recebe `{ payload, queue, inspect, staticDir, apply, log }`; para cada foto cria `<tempRoot>/<photoId>`, injeta `media: { filename: photo.filename ?? null }`, remove o diretório no `finally` e retorna `results` (`approved` via `payload.update` com `overrideAccess` como hoje, `failed` no catch do update, `skippedBroken` vindo do inspect com `stage`/`reason`). `apply:false` classifica sem escrever (usado pelo plan). Progresso a cada `PAGE_SIZE` (200) via `log` injetável.
|
||||
- **`main`** (`scripts/publish-archive-photos.mjs:94`): fila `payload.find` passa a `select: { id: true, filename: true }` (`:136`); após o boot, import dinâmico de `{ inspectPrivateMediaObject, resolvePrivateMediaStaticDir }` de `src/utilities/privateMedia/privateMediaResponse.ts` e `staticDir = resolvePrivateMediaStaticDir(payload, 'archivePhoto')`; slug via `ARCHIVE_PHOTO_SLUG` (`src/lib/archivePhoto.ts:14`). Plan e apply montam `summary = summarizeArchivePublishResults(results)` (no plano o censo `eligible` é ignorado; `approved`/`failed` ficam 0) e `preview` inalterado (`{photoId}`). Exit: `skippedBrokenCount > 0` → `die` específico (plan e apply); depois a checagem legada `approved === 0 && results.length > 0`; senão `process.exit(0)`.
|
||||
- **Entrypoint guard** (mesmo script): `loadCliEnv()` e `main().catch(die)` passam para dentro de `if (isDirectRun)` (`pathToFileURL`), tornando o import pelo int livre de efeito colateral. Help atualizado: o texto dos modos declara o preflight ("objetos inspecionados; draft quebrado fica fora do lote e é nomeado no recibo; sai 1 se houver puladas").
|
||||
- **Migration:** nenhuma (sem schema, sem campo novo).
|
||||
- **Access / Consent:** nenhum; o lote continua trusted operator (`overrideAccess: true`, `scripts/publish-archive-photos.mjs:137-138`), sem sessão e sem Consent.
|
||||
- **UI:** nenhuma.
|
||||
- **Testes:** `tests/unit/archivePublishPlan.unit.spec.ts` (casos novos de summarize/format); novo `tests/int/archivePublish.int.spec.ts` modelado em `tests/int/archiveIntegrity.int.spec.ts` (`vi.mock('next/cache')`, `ARCHIVE_PHOTO_JPEG_BYTES`, `ingestArchivePhoto`, `resolvePrivateMediaStaticDir`, corromper com `writeFile`/remover com `rm`, limpeza em `afterAll`); `tests/unit/faceCli.unit.spec.ts:96-128` permanece como pin dos spawns de guarda (extensão só da asserção de help).
|
||||
- **Docs:** `docs/ops/teqo-1313-deploy.md` — passo 3 do C242 (`:1531-1543`) e aviso do C246 (`:1735-1743`); changelog `docs/changelog/2026-10-02-c249.md`.
|
||||
- **CI:** editar `scripts/lib/archivePublishPlan.mjs` (SCRIPTS_SPEC_PINNED, `scripts/lib/test-affected-core.mjs:49`) marca high-risk (`:131-165`) → unit/int full + e2e curado; sem edição de manifest.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- Forma escolhida: o recibo JSON mantém `results` como array na ordem da fila e `summary.skippedBroken` como array de `{photoId, stage, reason}` + contagem explícita `skippedBrokenCount` para a linha humana. Ordem cronológica preserva a leitura do operador e o diff do recibo; a contagem evita `reduce` no formatador.
|
||||
- Rejeitadas: mapa aninhado por stage (perde a ordem e complica o recibo), `reason` só no log (aceite exige nomeação no recibo), incluir bytes/URL no recibo (PII/superfície desnecessária).
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Preflight + CLI — quota ~70% (~0,35 dia).** `summarizeArchivePublishResults`/`formatArchivePublishReport` com a chave nova; `runArchivePublishBatch` + guard de entrypoint; fila com `filename`; import dinâmico da utility; report/exit do plan e do apply; help. Prova: `pnpm test:unit -- tests/unit/archivePublishPlan.unit.spec.ts` e `pnpm gate:fast` verdes (os spawns de guarda existentes seguem passando sem edição de comportamento).
|
||||
2. **Provas — quota ~20% (~0,1 dia).** Unit: contar/formatar `skippedBroken` nos dois modos e garantir que pulada não entra em `failures`. Int `tests/int/archivePublish.int.spec.ts`: fixture íntegro → aprovado no banco e em `results`; fixture com objeto sobrescrito por lixo → permanece `draft` com `skippedBroken {stage:'decode', reason não vazio}`; fixture com objeto removido → permanece `draft` com `stage:'missing'`; `summarize`/`format` nomeiam os ids com stage/motivo. Prova: `pnpm test:int -- tests/int/archivePublish.int.spec.ts` verde + `pnpm test:unit -- tests/unit/faceCli.unit.spec.ts` (guardas intactos).
|
||||
3. **Runbook + gates — quota ~10% (~0,05 dia).** Atualizar o aviso do C246 (o publish agora inspeciona antes de aprovar; draft quebrado fica fora e é nomeado) e o passo 3 do C242 (custo do preflight, `--limit` canário, exit 1 com puladas, execução no maintenance com o env do stack); changelog. Gates: `pnpm gate:fast`; `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Reparar objeto no publish (é C246) — só pular e nomear.
|
||||
- Tocar `removed` (a query continua `publicationStatus = draft`) ou varrer `media`/vídeos/outras collections.
|
||||
- Mudar guardas/confirm (D7): nada de `S3_*` novo, nada de flag nova.
|
||||
- Migration/campo novo: `skippedBroken` vive no recibo.
|
||||
- Cache/revalidação: o publish segue só imprimindo o curl manual; sem `revalidateTag` no CLI.
|
||||
- `--concurrency`/`--only`/retry no publish: canário continua `--limit`; loop sequencial como hoje — revisitar só se a parede de tempo do lote completo (~6,5k) virar problema real (registrado no runbook).
|
||||
- Unificar sweep e publish num "framework" de varredura: duplica/abstrai dono sem volatilidade.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Custo do apply sobe de updates de banco para download+decode de toda a fila:** `--limit` canário, log de progresso a cada 200, expectativa documentada no runbook; o lote é one-off operado no maintenance.
|
||||
- **Produção sem S3 no processo:** todo objeto vira `missing` e nada é aprovado (fail-closed, exit 1) — o runbook fixa a execução no maintenance com o env file do stack (mesmo requisito do sweep); sem guarda nova por não escopo (D7).
|
||||
- **Import do script pelo int executar `main`/`loadCliEnv`:** guard `isDirectRun` cobre; o teste importa só a função.
|
||||
- **Int aprovando drafts de specs vizinhos:** o seam recebe a fila explicitamente; nunca roda a CLI sobre o banco inteiro (D6).
|
||||
- **`filename` nulo:** classifica `missing` pelo dono C246 (`privateMediaResponse.ts:362`) — draft sem objeto nunca aprova.
|
||||
- **Regressão de contagem:** unit pina que `skippedBroken` não entra em `failed`/`failures`.
|
||||
- **Exit 1 no plan surpreender:** mensagem clara + runbook.
|
||||
- **PII no recibo:** apenas id/stage/reason; sem bytes, sem URL, sem nome.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: apply nunca aprova objeto quebrado; recibo nomeia cada pulada com stage/motivo; exit 1 se houver puladas; int cobre aprovadas e puladas.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: sem migration/schema, sem Consent/access novo, sem mudança de URL pública; guardas do publish intactos; identificadores em inglês; sem PII no recibo.
|
||||
- [ ] Testes de domínio previstos: unit da lib pura (summarize/format com `skippedBroken`), int com Payload real (aprovado × pulado com stage/motivo), spawns de guarda existentes verdes.
|
||||
- [ ] Gates: `pnpm gate:fast` verde; int local do spec novo; `pnpm push`.
|
||||
- [ ] Runbook (`teqo-1313-deploy.md` C242/C246) e changelog `docs/changelog/2026-10-02-c249.md` atualizados.
|
||||
- [ ] CI ciente: high-risk por `scripts/lib/archivePublishPlan.mjs` pinado roda unit/int full + e2e curado sem edição de manifest.
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
| Critério | Nota | Justificativa |
|
||||
| ----------------------------------------------------- | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1. Decisões caras têm rejeitadas? | 5 | D1–D7 registram Opções/Recomendação/Rejeitadas, inclusive o seam de teste e a não-guarda de S3. |
|
||||
| 2. Abordagem cabe no appetite da intenção? | 5 | Sem migration/UI/contrato; ~0,5 dia com preflight reusando o dono C246 e a lib pura existente. |
|
||||
| 3. Rabbit holes nomeados? | 5 | Reparo, `removed`, schema, concorrência, cache e "framework" de varredura estão explicitamente fora, com gatilho de revisitação para o custo. |
|
||||
| 4. Depth check: reusa shells/helpers existentes? | 4 | Reusa `inspectPrivateMediaObject`/`resolvePrivateMediaStaticDir`, a lib pura e os padrões de int/spawn; a única fronteira nova é o export testável + guard de entrypoint, justificado por D6. |
|
||||
| 5. Intenção (aceite de produto) permanece satisfeita? | 5 | A engenharia não reescreveu o outcome: só o caminho de aprovação ganhou o portão de integridade e o recibo/exit correspondentes. |
|
||||
|
||||
## Débitos diferidos (triage do /simplify, 2026-10-02)
|
||||
|
||||
Todos score ≤3 (cheap_polish/defer_trigger) — registrados aqui com gatilho, sem Issue nova:
|
||||
|
||||
- **Probe por foto duplicado** (`inspectPhoto` no publish × `inspectRow` no sweep C246, `scripts/check-archive-integrity.mjs:137`): extrair um helper compartilhado só quando houver 3º consumidor ou mudança na composição `inspect`/`tempRoot` (D5 manteve a separação).
|
||||
- **Sem guarda `S3_*` no publish** (D7): em alvo não-local sem S3 o preflight mede o disco local; o fail-closed (`missing` → nada aprova) só não cobre o caso estreito de o `staticDir` local conter mídia. Gatilho: operador rodando publish fora do maintenance ou `staticDir` local com mídia em alvo não-local → exigir as 4 `S3_*` como o sweep faz.
|
||||
- **Exit 1 com puladas sem prova automatizada da fiação do `die`**: unit cobre summarize/format, int cobre o seam, faceCli cobre guardas; falta um spawn-test do plan/apply com `skippedBrokenCount > 0`. Gatilho: próximo toque no bloco report/exit do publish ou padronização de spawn-test de exit code no repo.
|
||||
145
docs/plans/album-miniaturas-avif-impl.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Impl: C248 — Miniaturas AVIF pré-geradas do álbum público (previews rápidos nos cards)
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-10-02
|
||||
Issue: #1424
|
||||
Intenção: docs/plans/album-miniaturas-avif.md
|
||||
Appetite restante: herdado (~1–2 dias eng + máquina do backfill; cortes: sem UI, sem migration, sem variante do lightbox, sem CDN/cache novo, sem tocar no original)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** a grade pública (`?tamanho=grade`) deixa de pagar sharp por request quando existe a derivada AVIF 720px q60 armazenada; qualquer ausência/falha cai no fallback on-the-fly atual, nunca em imagem quebrada; navegador sem AVIF recebe JPEG; pull-down imediato preservado; fotos novas cobertas pelo fluxo de aprovação + heal; backfill one-shot idempotente com recibo e guarda `*_CONFIRM`.
|
||||
- **O que NÃO negociar:** URL congelada `/fotos/<id>/midia?tamanho=grade`; `private, no-store`; bucket privado Garage sem CDN; pull-down imediato (o gate continua sendo o read cacheado `getApprovedArchivePhotoById`); fallback sempre que a derivada faltar; AVIF q60 na régua do repo; filename determinístico por foto; nenhum original alterado; nenhuma URL/UI nova.
|
||||
- **O que reavaliar (achados confirmados no código):**
|
||||
- (a) A rota já lê a linha fresca (`findByID`, `route.ts:46-54`) para filename/mime — é esse filename que deriva a chave da derivada; nenhuma query nova.
|
||||
- (b) O resize on-the-fly vive no dono `buildPrivateMediaImageResponse` (`privateMediaResponse.ts:230-296`), que já abre o objeto pelo caminho S3/disco (`openPrivateObject`) — a derivação reusa esse dono, não cria segundo pipeline de I/O.
|
||||
- (c) Não existe negociação de imagem (`Accept`/`Vary`) em lugar nenhum do repo; o default seguro é JPEG e o Playwright (`Accept: */*`) mantém o e2e atual verde.
|
||||
- (d) A régua AVIF q60 mora em `scripts/lib/imageResize.mjs:39` (`.mjs`, não importável de `src/`): as constantes vão para `lib/` com unit pin e comentário do dono.
|
||||
- (e) `afterChange` fire-and-forget pode morrer no `process.exit` das CLIs (ex.: `archive:publish` aprova em lote num processo efêmero) — o heal preguiçoso na rota é a garantia; o hook é warm-up.
|
||||
- (f) C246 re-ingere o original na MESMA chave (mesma foto, `repairArchivePhotoObject`) e a C245 só escreve metadados — a chave determinística da derivada sobrevive; objeto ausente/corrupto é skip honesto e o heal regenera quando recuperado.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["GET /fotos/<id>/midia?tamanho=grade"] --> B{"gate: getApprovedArchivePhotoById (read cacheado)"}
|
||||
B -- "não aprovada" --> Z["404 silencioso (no-store, noindex)"]
|
||||
B -- aprovada --> C{"download=1?"}
|
||||
C -- sim --> D["original / download (como hoje)"]
|
||||
C -- não --> E{"acceptsAvif(Accept)?"}
|
||||
E -- "não (default, */*)" --> F["fallback: sharp → JPEG q80 on-the-fly (dono privateMediaResponse)"]
|
||||
E -- sim --> G["tenta flickr-<flickrId>-grade.avif (buildPrivateMediaResponse, image/avif)"]
|
||||
G -- "404/erro/ilegível" --> H["after(): ensureArchivePhotoGrade — heal preguiçoso"] --> F
|
||||
G -- "200" --> I["serve a derivada armazenada"]
|
||||
F --> J["Vary: Accept + private, no-store + X-Robots-Tag: noindex"]
|
||||
I --> J
|
||||
K["afterChange: transição PARA approved"] --> L["ensureArchivePhotoGrade (best-effort, não bloqueia)"]
|
||||
M["pnpm archive:thumbnails --apply"] --> N["generateArchivePhotoGrade (mapWithConcurrency)"]
|
||||
L --> O[("bucket privado / staticDir: flickr-<flickrId>-grade.avif")]
|
||||
N --> O
|
||||
H --> O
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) derivada como objeto-irmão determinístico no mesmo store privado, descoberta no caminho da rota, com extensão do dono `privateMediaResponse` | B) `imageSizes`/variante do Payload na collection `archivePhoto` (com backfill de regeneração) | C) segundo pipeline de mídia (fila/CDN/collection própria).
|
||||
**Recomendação:** A — o bucket/disco privado já é o único store do original, o filename do original já é determinístico e imutável (C246 sobrescreve a mesma key), e o dono do I/O privado (`privateMediaResponse`) já concentra abrir/escrever/encodar; a derivada vira um irmão de chave (`flickr-<id>.jpg` → `flickr-<id>-grade.avif`), sem schema nem segunda fonte de verdade. Um HEAD a mais no caminho da derivada substitui o decode do original no caminho atual (hoje: HEAD+GET+sharp; com derivada: HEAD+GET).
|
||||
**Rejeitadas:** B porque `imageSizes` só gera no upload, exigiria regeneração/backfill acoplado ao Payload e criaria estado de linha para um objeto derivado (migration desnecessária); C porque não existe fila/worker no stack self-hosted e CDN/cache novo é anti-goal explícito.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`archivePhotoThumbnail`** (`src/lib/archivePhotoThumbnail.ts`, novo — puro, client-safe): `ARCHIVE_PHOTO_GRADE_EXTENSION`/`ARCHIVE_PHOTO_GRADE_MIME_TYPE` (`image/avif`)/`ARCHIVE_PHOTO_GRADE_QUALITY` (60, régua `scripts/lib/imageResize.mjs:39`) e `archivePhotoGradeFilename(originalFilename)` — irmão determinístico (`flickr-<id>.jpg` → `flickr-<id>-grade.avif`); largura reusa `ARCHIVE_PHOTO_THUMB_WIDTH` (720, `archivePhotoPublicCatalog.ts:43`). Compartilhado por rota, utility de geração, hook e script.
|
||||
- **`acceptsAvif`** (`src/lib/privateMedia.ts`, edição): negociação pura do header — token exato `image/avif` (case-insensitive, `q` ausente ou > 0); `*/*`, `image/*` e header ausente ⇒ falso (default JPEG). Unit-pinada no spec existente.
|
||||
- **`privateMediaObjectExists` / `writePrivateObject` / `encodePrivateMediaImage`** (`src/utilities/privateMedia/privateMediaResponse.ts`, edição): extensões do dono do I/O privado. `privateMediaObjectExists` = HEAD S3 / `stat` local, nunca lança; `writePrivateObject` = `PutObjectCommand` (ContentType) no S3 ou temp+rename no `staticDir` local (mesmo guard de path do `resolveLocalPath`); `encodePrivateMediaImage({ media, staticDir, width, format, quality })` extrai o stream→sharp (`failOn:'error'`, `rotate`, resize `withoutEnlargement`) que hoje é inline no fallback, e `buildPrivateMediaImageResponse` passa a chamá-lo com `jpeg q80` (comportamento idêntico, unit/e2e pinam).
|
||||
- **`archivePhotoThumbnails`** (`src/utilities/archivePhotos/archivePhotoThumbnails.ts`, novo — `server-only`): `generateArchivePhotoGrade(payload, { filename })` baixa o original com `downloadPrivateMediaToFile`, encoda AVIF 720 q60 (`encodePrivateImageFile`) e grava com `writePrivateObject`; classifica `generated | skipped (missing-origin | corrupt-origin | no-filename) | failed`; nunca lança. `ensureArchivePhotoGrade(payload, { filename })` adiciona probe (`privateMediaObjectExists`), in-flight Map, limite de concorrência 2 e memo de falha 5 min — molde `contentPieceFrameJob` (`contentPieceFrameJob.ts:74-105,249-283`). Sem advisory lock/transação: não escreve DB e a chave é idempotente (last-write-wins do mesmo conteúdo).
|
||||
- **Rota** (`src/app/(frontend)/fotos/[id]/midia/route.ts`, edição): mantém id/gate/`findByID`/download; em `tamanho=grade` sem `download` e `acceptsAvif(request.headers.get('accept'))`, tenta a derivada (`buildPrivateMediaResponse` com `{ filename: gradeName, mimeType: image/avif }`, `rangeHeader: null`) e, em 404/erro, cai no fallback atual e agenda `after(() => ensureArchivePhotoGrade(...))`; `Vary: Accept` na resposta da grade (derivada e fallback).
|
||||
- **Hook** (`src/collections/ArchivePhoto.ts`, edição): `warmArchivePhotoGradeAfterChange` no `afterChange` — só na transição PARA `approved` (`previousDoc?.publicationStatus !== 'approved'`) com `filename` → `void ensureArchivePhotoGrade(...).catch(() => undefined)`; cataloguing/face-index/C246-repair não disparam (status não muda); falha jamais impede a aprovação.
|
||||
- **Backfill** (`scripts/backfill-archive-thumbnails.mjs` + `scripts/lib/archiveThumbnailPlan.mjs`, novos; script `"archive:thumbnails"` no `package.json`): plan default read-only, `--apply` (gera faltantes), `--verify` (cobertura); `--only/--limit/--concurrency/--out/--help`; reusa `generateArchivePhotoGrade`, `privateMediaObjectExists`, `mapWithConcurrency` e `writeRepoFile`; recibo JSON em `data/archive/reports/` (gitignored) com processadas/puladas/falhas.
|
||||
- **Pins** (`scripts/lib/test-affected-core.mjs`, edição): `scripts/lib/archiveThumbnailPlan.mjs` em `SCRIPTS_SPEC_PINNED` (o unit spec importa; `ciSkipInvariants.unit.spec.ts` exige a igualdade exata). O manifest e2e já cobre os prefixos tocados (`src/lib/archivePhoto`, `src/utilities/archivePhotos`, `src/utilities/privateMedia`, `src/collections/ArchivePhoto.ts`, `src/app/(frontend)/fotos`) → `frontendFotos`/`frontendFotosSelfie`; sem entrada nova.
|
||||
- **Testes** (edição/novos): unit `tests/unit/archivePhotoThumbnail.unit.spec.ts` (naming/constantes) + extensão de `tests/unit/privateMedia.unit.spec.ts` (`acceptsAvif`) + `tests/unit/archiveThumbnailPlan.unit.spec.ts`; int extensão de `tests/int/archivePhotoPublicRead.int.spec.ts` (matriz da rota) + `tests/int/archivePhotoThumbnails.int.spec.ts` (geração/ensure/heal); e2e `tests/e2e/frontendFotos.e2e.spec.ts` (só `Vary`; JPEG mantido).
|
||||
- **Runbook** (`docs/ops/teqo-1313-deploy.md`, edição): seção C248 (canário, staging→produção, `--verify`); changelog `docs/changelog/2026-10-02-c248.md` no fechamento.
|
||||
- **Migration:** nenhuma — a derivada é objeto no store privado, sem campo/índice/collection; a chave deriva do `filename` que a rota já lê (stop-condition: surgir necessidade de linha/marcador ⇒ parar e sinalizar).
|
||||
- **Access / Consent:** nenhuma mudança — a rota segue com o gate público cacheado e o `overrideAccess` documentado (releitura de filename); a geração é escrita de objeto por ator confiável já autorizado (mesmas credenciais S3 do ingest, sem permissão nova); sem `Consent`/`Contact`.
|
||||
- **UI:** Impeccable A — nenhuma tela/fluxo; zero dispatch de designer; o card segue com o mesmo `<img>` (`ArchivePhotoCard.tsx:29`), que negocia `Accept` sozinho.
|
||||
|
||||
### Dados → forma
|
||||
|
||||
- Sem dados de produto novos (a grade e o overlay não mudam). Único artefato: o recibo operacional do lote em `data/archive/reports/archive-thumbnails-<stamp>.json` — `{ runAt, mode: plan|apply|verify, target, eligible, generated, skipped[], failed[], results[], durationMs }` consumido por operador.
|
||||
- Rejeitadas: painel/admin (não pedido), CSV (ninguém consome), segunda fonte de contagem (o `--verify` recomputa do HEAD, que é a verdade).
|
||||
|
||||
## Decisões de engenharia
|
||||
|
||||
**D1 — Chave/nome da derivada e descoberta (caro)**
|
||||
|
||||
- Opções: A) objeto-irmão no mesmo store privado, chave derivada do `media.filename` (`flickr-<flickrId>-grade.avif`), descoberta por tentativa de GET/HEAD no caminho da rota, miss ⇒ fallback; B) linha no DB (segunda linha em `archivePhoto` ou collection nova) com filename determinístico, descoberta por query; C) prefixo/pasta separada (`grade/...`).
|
||||
- Recomendação: A — o original já tem nome determinístico e imutável (`archivePhotoStorageFilename`, `archivePhoto.ts:91-104`), o C246 sobrescreve a MESMA key, e a rota já tem o filename fresco em mãos; não há migration, não há estado a dessincronizar e o HEAD extra substitui (não soma ao) o HEAD que o caminho atual já faz no original. Miss = 404 do objeto → fallback on-the-fly + heal (D3); derivada ilegível (erro de leitura/checksum) também cai no fallback.
|
||||
- Rejeitadas: B — migration/linha órfã, derivadas apareceriam nas listas/select da collection e o probe viraria query por um objeto que o bucket já sabe responder; C — hierarquia nova sem ganho (o espaço de chaves já é flat e o `resolveLocalPath` local teria que criar subdir), com risco de colisão de prefixo em varreduras futuras.
|
||||
|
||||
**D2 — Contrato de negociação de formato (caro)**
|
||||
|
||||
- Opções: A) negociar por `Accept: image/avif` explícito (token exato, `q > 0`), `Vary: Accept`, default JPEG quando o header falta ou não pede AVIF; B) servir AVIF sempre que existir (exige `<picture>` na UI para o fallback); C) parâmetro de URL (`?formato=avif`).
|
||||
- Recomendação: A — cumpre "nenhum preview quebrado" sem UI, e `private, no-store` torna o `Vary` cinto de segurança (nenhum cache compartilhado guarda a resposta, mas intermediários heurísticos não podem misturar). Requerer o token explícito é o default fail-safe: Playwright/API clients (`*/*`) continuam no JPEG e o e2e existente não muda; browsers modernos mandam `image/avif` e ganham a derivada.
|
||||
- Rejeitadas: B — browser sem decodificador AVIF mostraria imagem quebrada e a correção seria `<picture>`/UI, fora do escopo; C — cria contrato/param novo em URL congelada.
|
||||
|
||||
**D3 — Seam da geração automática (caro)**
|
||||
|
||||
- Opções: A) `afterChange` aguardando a geração (bloqueia a aprovação); B) híbrido: `afterChange` fire-and-forget na transição PARA `approved` + heal preguiçoso na rota no miss (`after()`) + backfill one-shot; C) só heal preguiçoso na rota (sem hook e sem backfill); D) fila/job novo.
|
||||
- Recomendação: B — o hook cobre a aprovação interativa (foto nova já nasce com derivada, sem custo no request de preview); o heal preguiçoso é a garantia para os casos em que o fire-and-forget morre (CLI de publicação em lote faz `process.exit`, restart de deploy) e para fotos recuperadas pela C246; o backfill aquece as ~6.492 aprovadas existentes. Falha de geração nunca lança e nunca impede a aprovação (o fallback atende).
|
||||
- Rejeitadas: A — acopla a latência da aprovação ao encode AVIF (1–3s/foto; lote inviável) e transformaria bug de encode em erro de publicação; C — não cumpre o aceite "cobertas automaticamente pelo fluxo de entrada/aprovação" e deixa as 6.492 reféns do primeiro visitante, sem recibo de backfill; D — não existe worker/queue no stack e criar um seria pipeline genérico (anti-goal).
|
||||
|
||||
**D4 — Lote/backfill, guardas e recibo (caro)**
|
||||
|
||||
- Opções: A) CLI `pnpm archive:thumbnails` com plan-lib puro, modos plan/`--apply`/`--verify`, `ARCHIVE_THUMBNAILS_CONFIRM`, recibo JSON e a MESMA função de geração do servidor; B) endpoint/admin action para disparar o lote; C) script inline sem lib pura/testes.
|
||||
- Recomendação: A — segue o padrão C245/C246 (`assertWriteConfirm` + `assertEnvironmentDatabaseTarget` + `mirroredMediaRequired`, recibo em `data/archive/reports/`, skip honesto); elegível = `publicationStatus === 'approved'` com `filename` (produto Q3); idempotente por HEAD (já existe = "pulada"; ausente/corrupto na origem = "pulada" com motivo; falha de rede/upload = "falha" e a próxima execução retenta); concorrência default 2 (o teto real do encode por processo, o mesmo slot do web) com `--only/--limit` de canário; `mapWithConcurrency` e o parser `--only` compartilhados movidos para `scripts/lib/cli.mjs` no 2º consumidor (débito S3 do C246 quitado neste PR).
|
||||
- Rejeitadas: B — superfície de operação de máquina sem recibo/guarda, e a rota pública não deve disparar lote; C — quebra o padrão de plan-lib testável e o pin do CI.
|
||||
- **Serialização C245/C246:** o backfill roda serializado com `archive:catalog`/`archive:integrity` (nota no runbook); original ausente/corrupto = skip com motivo, regenerado quando o C246 recuperar (heal ou novo `--apply`); nenhum lock novo — a chave é idempotente e o pior caso é trabalho redundante.
|
||||
|
||||
**D5 — Testes e o que muda no e2e existente (barato, mas explícito)**
|
||||
|
||||
- Opções: A) unit do contrato puro + int da rota/geração (storage local) + e2e existente mantendo JPEG e ganhando só o `Vary`; B) só int; C) e2e novo do AVIF gerado ponta-a-ponta.
|
||||
- Recomendação: A — o naming e a negociação são contrato puro (unit); a matriz da rota (derivada presente + `Accept` avif → 200 `image/avif`/`Vary`/`no-store`; derivada ausente → 200 `image/jpeg`; sem `Accept` → JPEG; draft/removida → 404) e a geração no `staticDir` local (gera, segunda chamada pula, origem corrompida = skip sem derivada) são determinísticas em int; o e2e `frontendFotos` já pinava `content-type: image/jpeg` (`frontendFotos.e2e.spec.ts:359`) porque o APIRequestContext manda `*/*` — a expectativa fica e só se acrescenta `vary: Accept` (determinístico).
|
||||
- Rejeitadas: B — deixa chave/negociação sem pin de contrato; C — o hook é fire-and-forget e o heal é assíncrono: e2e do AVIF gerado viraria poll/flake; a cobertura fiel é int (função direta) + e2e do fallback.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer/servidor — quota ~45%:** `lib/archivePhotoThumbnail` + `acceptsAvif` + extensões do dono `privateMediaResponse` + `archivePhotoThumbnails` + rota + hook. Testes: unit do naming/negociação; int da rota (matriz acima, mockando `next/server` `after` como `contentPiece.int.spec.ts:21-26`) e da geração local. Prova local (tracer bullet): aprovar UMA foto seedada → derivada nasce no `staticDir`; `Accept: image/avif` → 200 avif; sem header → 200 jpeg; remover a derivada → fallback e regeneração agendada.
|
||||
2. **UI (n/a) — quota 0%:** sem tela/fluxo novo, sem dispatch de designer (Impeccable A); o card não muda.
|
||||
3. **Lote + runbook — quota ~30%:** `scripts/lib/archiveThumbnailPlan.mjs` + `scripts/backfill-archive-thumbnails.mjs` + `"archive:thumbnails"` + pin `SCRIPTS_SPEC_PINNED` + unit do plan-lib + seção no `teqo-1313-deploy.md`. Prova local: `pnpm archive:thumbnails` (plan, read-only) lista elegíveis; `--apply --limit 5` gera e o recibo conta; reexecutar converge ("puladas"); `--verify` fecha.
|
||||
4. **Gates — quota ~25%:** `pnpm gate:fast`; `pnpm test:int`; e2e local `pnpm test:e2e --no-deps --project=frontendFotos`; `pnpm push`; changelog C248 no fechamento. **Ops (fora do merge):** no homeserver, canário `--limit` em staging → lote em produção pelo serviço maintenance (env do stack) → `--verify`; anexar recibo à Issue #1424.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Variante média do lightbox, matriz de tamanhos/formatos (WebP/JPEG armazenados), tuning de qualidade/effort — só 720px AVIF q60 da grade.
|
||||
- `imageSizes` do Payload, regeneração de variantes, collection/tabela de derivadas, migration, marcador no DB ou no admin.
|
||||
- CDN/cache público, `s-maxage`/`immutable`, service worker, `next/image`, `<picture>` ou qualquer mudança de UI.
|
||||
- Tocar no original, no `download=1` (segue original/nome legível), no range do original ou no gate/pull-down.
|
||||
- Reaper/GC de objetos órfãos, invalidação proativa pós-C246, backfill concorrente ilimitado, fila/job genérico de mídia.
|
||||
- Nova env de runtime (a chave da derivada é derivada; nada novo para o build) e importmap (sem componente admin).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **CPU de AVIF no processo web (heal por visita):** limite de concorrência 2 + memo de falha 5 min + `after()` (nunca na latência da resposta) + backfill como aquecimento antes de anunciar; medir p95 da grade localmente.
|
||||
- **Fire-and-forget perdido (CLI/restart):** heal preguiçoso na rota cobre na primeira visualização; backfill cobre em lote; recibo mostra o que ficou pendente.
|
||||
- **Derivada parcial no disco local (dev/test):** `writePrivateObject` grava temp + rename atômico no mesmo diretório.
|
||||
- **Derivada corrompida/desatualizada:** qualquer erro de leitura cai no fallback; C246 sobrescreve o original da MESMA foto (conteúdo visual equivalente a 720px) e a regeneração só acontece se a chave sumir — o pior caso é JPEG on-the-fly, nunca imagem quebrada.
|
||||
- **Browser sem AVIF / cache misto:** `acceptsAvif` exige token explícito (`*/*` ⇒ JPEG), `Vary: Accept` + `private, no-store` mantidos; e2e pin.
|
||||
- **Backfill vs C245/C246:** rodar serializado (runbook); ausente/corrupto = skip com motivo; reexecução converge; `removed`/`draft` ficam fora da fila.
|
||||
- **Credenciais S3 sem PutObject:** a geração usa o mesmo cliente/bucket que o ingest já escreve (Payload upload) — sem permissão nova; falha de permissão vira `failed` no recibo, sem afetar a rota (fallback).
|
||||
- **Lote de ~6.5k lento:** concorrência 2, canário `--only/--limit`, recibo com duração/bytes; execução noturna no homeserver (sem egress externo).
|
||||
- **CI:** a edição em `src/collections/ArchivePhoto.ts` e `package.json` é high-risk ⇒ e2e curado (`frontendFotos` já mapeado); `scripts/lib/archiveThumbnailPlan.mjs` entra no pin exato exigido por `ciSkipInvariants`.
|
||||
|
||||
## Débitos diferidos (gatilhos)
|
||||
|
||||
- **Corrida probe→in-flight no `ensureArchivePhotoGrade`** (débito do simplify): o probe HEAD precede o check de in-flight (molde C226), então um joiner cujo HEAD falhou pode re-encodar uma derivada recém-gerada. **Gatilho:** geração duplicada observada (log/contador) ou no lote de 6.5k.
|
||||
- **Sem e2e do AVIF gerado ponta-a-ponta** (D5): cobertura int direto + e2e do fallback. **Gatilho:** regressão AVIF em produção ou runner estável para poll sem flake.
|
||||
- **`privateMediaObjectExists` engole erro como "false"**: no `--verify`, bucket inacessível lê como "faltando" (falha fechada, sem verde falso); distinguir `unreachable` no recibo é polish. **Gatilho:** diagnóstico confuso em run real do `--verify`.
|
||||
- **`--concurrency` do CLI capado em 2** (slot global do processo web): limitante documentado do throughput do lote. **Gatilho:** primeiro run real do lote lento demais → rever o slot por contexto (web vs CLI).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto coberto: derivada servida direto quando existe e o cliente aceita AVIF; fallback JPEG em ausência/erro/header sem AVIF; pull-down imediato pelo mesmo gate cacheado; fotos novas cobertas (hook + heal) e acervo existente pelo backfill; idempotência com recibo; nenhum original/URL/UI alterados.
|
||||
- [ ] Invariantes: `lib` puro → `utilities` server-only → `app`; sem migration; `overrideAccess` só nos precedentes documentados (releitura de filename; script ator confiável); guardas `*_CONFIRM` + `TEQO_ENV` exato + S3 completo fail-closed; identifiers em inglês / copy pt-BR; nenhuma escrita no DB de produção pelo agente.
|
||||
- [ ] Testes de domínio previstos (unit/int) onde o read/write path muda: naming/negociação puros; rota (avif presente/ausente/sem accept/404) e geração/heal em int com storage local; e2e `frontendFotos` mantém JPEG e ganha `Vary`.
|
||||
- [ ] `pnpm gate:fast` verde; int verde; e2e `frontendFotos` local verde; pin/manifest conferidos; `pnpm push`; runbook + changelog no PR.
|
||||
|
||||
## Self-score decision-quality
|
||||
|
||||
**5/5** — (1) as decisões caras foram fechadas com opções/recomendação/rejeitadas: D1 chave/descoberta, D2 contrato Accept/Vary, D3 seam da geração, D4 shape/guardas do lote, D5 testes/e2e; (2) depth check — reusa `privateMediaResponse` (I/O + resize), `imageResize` (régua q60 via pin), guardas de `cli.mjs` e o molde `contentPieceFrameJob`/`recover-media`/`archiveIntegrityPlan`, sem abstraction nova com 1 call site e sem twin de pipeline; (3) a hipótese da intenção foi corrigida contra o código (filename fresco já disponível, `after()` indisponível no CLI ⇒ heal é a garantia, régua q60 mora em `.mjs`); (4) rabbit holes cortados (matriz de formatos, CDN, `imageSizes`, UI, GC de órfãos); (5) riscos com mitigação verificável (concorrência/memo, temp+rename, skip honesto, e2e mantido) e aceite com checkboxes.
|
||||
77
docs/postmortems/2026-10-02-hero-webp-avif-otimizador.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# Post-mortem: hero da home sem imagens — chaves AVIF presas no otimizador do Next
|
||||
|
||||
> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente.
|
||||
|
||||
## Registro
|
||||
|
||||
| Campo | Valor |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| Data do post-mortem | 2026-10-02 |
|
||||
| Severidade | alta (imagens principais da home ausentes para parte dos visitantes; sem indisponibilidade geral nem perda de dados) |
|
||||
| Ambiente | prod |
|
||||
| Issue(s) | sem Issue |
|
||||
| PR do fix | pendente — PR deste fluxo |
|
||||
| Detectado por | humano (relato na sessão de bug-fix de 2026-10-02 ~17:15 -03: "as imagens pararam de aparecer") |
|
||||
|
||||
## Timeline
|
||||
|
||||
| Momento | Data/hora | Evento |
|
||||
| --------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Início provável | 2026-10-02 ~16:35 -03 (~19:35Z) | Deploy de produção do SHA `ac39c817` recria o container (job de produção do `deploy.yml` rodou 19:26:40Z–19:35:53Z; container recriado ~19:35Z ≈ 16:35 -03). Antes disso o sintoma não existia; staging (mesmo SHA, deploy 15:17Z) nunca reproduziu. |
|
||||
| Detecção | 2026-10-02 ~17:15 -03 (≈20:15Z) | Relato humano na sessão de bug-fix ("as imagens pararam de aparecer"). No browser, requests a `/_next/image` ficavam pendentes para sempre (`complete=false`, `naturalWidth=0`); o Cloudflare devolvia 524 após ~100–125s no mesmo URL exato. Sem Issue. |
|
||||
| Correção implementada | 2026-10-02 | Worktree `fix/16`: 5 masters do hero regerados em WebP display-size, 3 refs de cards da home apontadas para `.webp` existentes, 9 `.avif` de `public/` removidos, `scripts/build-radio-artes.mjs` lendo `JOA00162.webp` e 3 guards de formato. `pnpm gate:fast` verde (468 arquivos, 5178 testes); e2e `frontend.e2e.spec.ts` 42 passed. |
|
||||
| Correção mergeada | pendente | PR deste fluxo aberto com o fix, os guards e este post-mortem; merge pendente do required check + auto-merge. |
|
||||
| Deploy | pendente | Merge em `main` dispara o deploy; produção só após approve humano no environment `production`. |
|
||||
| Verificado em prod | pendente | Aguardando deploy e confirmação humana. |
|
||||
|
||||
## O bug
|
||||
|
||||
Em produção, no browser, as imagens `RUI`/`WAGNER`/`Jeronimo` (e `Lula` em certos viewports) do hero da home pública não carregavam: os requests para `/_next/image` ficavam pendentes para sempre (`complete=false`, `naturalWidth=0`) e o mesmo URL exato nunca respondia — o Cloudflare devolvia 524 após ~100–125s. A matriz de evidências em produção (Accept de browser, q=75) mostrou que estavam envenenadas exatamente `RUI - 2-2.avif w=256`, `WAGNER - 2-9 final.avif w=256`, `Jeronimo.avif w=256` e `Lula.avif w=384`; **todas** as outras larguras dos mesmos arquivos (16–3840) e todas as outras imagens (`JOA00162` em todas as larguras, `fundo.avif`, as 2 fotos `.avif` da home, o HEIC do Instagram) respondiam 200 em ~0,3–6s. As 4 chaves correspondem exatamente ao conjunto que um load da home a 1920×1080 DPR1 pede em paralelo (aliados 211px→256; Lula 284px→384). A chave com mimeType avif (Accept: `image/avif`) do mesmo `RUI w=256` respondia 200 em 0,26s, e `q=76` respondia 200 em 0,28s — a geração estava saudável; só as 4 chaves exatas estavam presas na memória do processo. O sintoma afetava a página principal para parte dos visitantes, sem indisponibilidade geral nem perda de dados. **Sintoma — não a causa.**
|
||||
|
||||
## Causa-raiz
|
||||
|
||||
### 5 whys
|
||||
|
||||
1. **Por que a imagem não aparecia?** Porque o request para a chave exata do otimizador nunca respondia (524).
|
||||
2. **Por que nunca respondia?** Porque o `Batcher` do `ResponseCache` do Next (`node_modules/next/dist/lib/batcher.js`) deduplica por cache key e guarda a promise em `this.pending`; uma geração que pendura deixa a promise sem timeout para sempre — toda request futura daquela chave entra na mesma promise morta até o processo reiniciar. Evidência de código: `node_modules/next/dist/server/response-cache/index.js` (Batcher com `schedulerFn`), sem timeout.
|
||||
3. **Por que a geração pendurou?** Os 5 masters do hero eram AVIF e o Next 15.4.11 os decodifica via libheif (sharp 0.34.2). O advisory crítico GHSA-2xp9-vwfh-vxw4 ("Unauthenticated RCE in Image Optimization API when AVIF files are used"; afeta `next >=10 <15.5.24`) tem como mitigação oficial, no patch vercel/next.js commit `409772ec` (release 15.5.24), adicionar AVIF a `BYPASS_TYPES` (não otimizar AVIF) e bloquear os loaders libvips não-seguros. O hang transiente **não foi reproduzido** localmente (testado sharp glibc e musl/Alpine, sequencial e 4 concorrentes, 15 rodadas; staging idêntico também não reproduz) — é race de ambiente/carga no startup; ficou registrado como não reproduzido.
|
||||
4. **Por que AVIF chegou ao otimizador?** Porque o fix anterior (`16f95975`, 2026-10-01) regerou os masters como AVIF no tamanho de exibição para resolver a lentidão; o caminho AVIF→libheif permaneceu, e nenhum guard olhava formato/mídia.
|
||||
5. **Por que passou?** Porque não havia observabilidade de `/_next/image` nem guard de formato; o advisory era conhecido e o upgrade do Next é débito registrado como caro (`docs/plans/b195-f1-wizard-form-duplicado-streaming-impl.md`).
|
||||
|
||||
## Correção
|
||||
|
||||
- **Masters convertidos para WebP:** os 8 AVIF locais referenciados via `next/image` viraram WebP — os 5 masters do hero foram regerados display-size a partir dos próprios AVIF aprovados (mesmo enquadramento; `pnpm images:resize --format webp --quality 85`: `RUI`/`WAGNER`/`Jeronimo` 768px, `Lula` 1024px, `JOA00162` 1536px; 538KB vs 351KB de AVIF — o master não vai ao cliente) e os 3 cards da home (`fundo`, `53569851134…`, `52396285023…`) passaram a referenciar os `.webp` existentes.
|
||||
- **AVIF removido de `public/`:** os 9 `.avif` foram deletados (8 referenciados + `Marca-Lula.avif` órfão).
|
||||
- **`scripts/build-radio-artes.mjs`** passou a ler `JOA00162.webp`.
|
||||
- **Arquivos:** `src/components/CampaignHero.tsx`, `src/app/(frontend)/(home)/page.tsx`, `scripts/build-radio-artes.mjs`, `public/*.webp` (5 regerados), `public/*.avif` (9 removidos).
|
||||
|
||||
Resolve a causa: o caminho de decodificação vira libwebp (o patch oficial trata webp como loader seguro) e as URLs do otimizador mudam de chave, matando as 4 chaves envenenadas no próximo deploy — sem purge nem restart manual.
|
||||
|
||||
## Verificação
|
||||
|
||||
- Teste de regressão: `tests/unit/imageDelivery.unit.spec.ts` (2 casos novos: ban de literal `.avif` em `src/` incluindo `image:` e zero `.avif` em `public/`) e `tests/unit/campaignHero.unit.spec.tsx` (caso "serve masters WebP"). Prova vermelho/verde independente: sem o fix, 2–3 falhas (recebe `/RUI - 2-2.avif`; `.avif` presente; dimensão com arquivo ausente); com o fix, 8 passed.
|
||||
- Suíte: `pnpm gate:fast` verde (468 arquivos, 5178 testes); e2e `tests/e2e/frontend.e2e.spec.ts` 42 passed (modo dev local; o comando com dependências estourou 900s e o recipe do config `--no-deps --project=frontend` passou em 1,1 min).
|
||||
- Otimizador local: home com 0 ocorrências de avif; os 8 masters WebP servidos via `/_next/image` a 256w em 200 `image/webp` (0,7–1,6ms) e derivados ligeiramente menores que os do AVIF (ex.: `RUI` 256: 9,8KB vs 12,2KB).
|
||||
- Prod (read-only, antes do deploy): as 4 chaves AVIF seguem presas (timeout/524) e a home ainda serve AVIF (260 ocorrências).
|
||||
- CI: pendente (PR ainda não aberto). Prod: pendente (deploy e confirmação humana).
|
||||
|
||||
## Prevenção
|
||||
|
||||
| Estratégia | Custo | Estado |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------- |
|
||||
| Ban de literal `.avif` em `src/` (as duas grafias, incluindo `image:`), zero `.avif` em `public/` e hero exigindo `.webp` (`imageDelivery.unit.spec.ts` / `campaignHero.unit.spec.tsx`) | barata | implementada agora (este PR) |
|
||||
| Upgrade Next ≥15.5.24 (AVIF em `BYPASS_TYPES` + loaders libvips seguros) | cara | documentada — Issue, não implementada neste fluxo |
|
||||
| Cache Rule/observabilidade Cloudflare para `/_next/image` + runbook de purge | cara | documentada — Issue, não implementada neste fluxo |
|
||||
| Healthcheck/probe sintético de chaves presas com alerta | cara | documentada — Issue, não implementada neste fluxo |
|
||||
|
||||
**Estratégia implementada:** os 3 guards de formato acima (ban de AVIF em `src/` com as duas grafias, zero AVIF em `public/`, hero exigindo `.webp`), somados aos guards de dimensão/TTL do fix anterior.
|
||||
|
||||
**Estratégia documentada (cara):** (c) upgrade Next ≥15.5.24 (AVIF em `BYPASS_TYPES` + loaders seguros) — Issue; (d) Cache Rule/observabilidade Cloudflare para `/_next/image` + runbook de purge — Issue; (e) healthcheck/probe sintético de chaves presas com alerta — Issue. **Não fazer:** patch local no Batcher, `unoptimized` global, upgrade do Next neste PR.
|
||||
|
||||
## Lições
|
||||
|
||||
- **Default de dependência vulnerável:** os masters AVIF num Next <15.5.24 significavam decodificar via libheif exatamente o caminho do advisory crítico GHSA-2xp9-vwfh-vxw4 (RCE não autenticada na Image Optimization API); a mitigação oficial é bypass (`BYPASS_TYPES`), não um remendo local.
|
||||
- **Mitigação upstream é bypass, não patch local:** o patch `409772ec` (15.5.24) trata AVIF como tipo a não otimizar; patch no Batcher e `unoptimized` global foram descartados — o caminho certo é o upgrade registrado como débito/Issue.
|
||||
- **O Batcher transforma hang transiente em outage permanente por chave:** a promise sem timeout em `this.pending` (`node_modules/next/dist/lib/batcher.js`) faz toda request futura da mesma cache key herdar a promise morta até restart do processo — o URL exato nunca respondia enquanto as outras larguras fluíam.
|
||||
- **Guard de formato teria pegado:** um ban simples de `.avif` no `src/` e um check de `public/` teriam barrado a regressão no PR anterior — é exatamente a prevenção barata implementada agora, junto dos guards de dimensão/TTL.
|
||||
- **Staging idêntico não reproduz race — observabilidade é a rede:** o mesmo SHA não reproduziu em staging, o hang não reproduziu local (glibc/musl, sequencial/4 concorrentes, 15 rodadas) e a detecção foi humana ~40 min depois do deploy; sem probe/alerta de `/_next/image`, o 524 do Cloudflare (~100–125s) é o único sinal e chega tarde.
|
||||
|
|
@ -44,6 +44,7 @@
|
|||
"archive:publish": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/publish-archive-photos.mjs",
|
||||
"ops:global": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/ops-global.mjs",
|
||||
"archive:integrity": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/check-archive-integrity.mjs",
|
||||
"archive:thumbnails": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/backfill-archive-thumbnails.mjs",
|
||||
"seed:face-consents": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-consents.mjs",
|
||||
"seed:face-figures": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-face-figures.mjs",
|
||||
"db:seed:minimal": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-minimal.mjs",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 466 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 612 KiB After Width: | Height: | Size: 60 KiB |
BIN
public/Lula.avif
BIN
public/Lula.webp
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 47 KiB |
264
scripts/backfill-archive-thumbnails.mjs
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
/**
|
||||
* C248 — backfill of the stored grade thumbnails of the public album: one
|
||||
* 720px AVIF q60 per APPROVED archive photo, written as a sibling object of
|
||||
* the stored original (same key space, derived name), so the public grid
|
||||
* serves it directly instead of resizing on every request.
|
||||
*
|
||||
* Read-only by default; `--apply` generates the missing grades and `--verify`
|
||||
* checks the coverage of the approved album. The generation is the SAME
|
||||
* function the route's lazy heal and the approval hook call, so the lote and
|
||||
* the serving path can never drift. Re-running converges: an existing grade
|
||||
* is skipped by HEAD, a missing/corrupt original is an honest skip with its
|
||||
* reason and a network/upload failure is a named failure for the next run.
|
||||
*
|
||||
* Safety model (fail-closed, mirrors `archive:integrity`):
|
||||
* - --apply requires the explicit intent flag ARCHIVE_THUMBNAILS_CONFIRM=1
|
||||
* on a non-local/production target and the declared TEQO_ENV matching the
|
||||
* exact database name; a remote target also requires the complete S3_* set
|
||||
* (the original lives in the private bucket there).
|
||||
* - Only APPROVED photos with a stored file are eligible: draft/removed rows
|
||||
* are out (generating for them would be discarded work) and neither the
|
||||
* original nor any row is ever written.
|
||||
* - Serialize with `archive:catalog` / `archive:integrity` (the C245/C246
|
||||
* ops touch the same originals): a missing original is regenerated after
|
||||
* the C246 recovery with a new `--apply`.
|
||||
*
|
||||
* Modes:
|
||||
* pnpm archive:thumbnails plan — lists the eligible album and
|
||||
* how many grades are missing (HEAD)
|
||||
* pnpm archive:thumbnails --apply generates the missing grades
|
||||
* pnpm archive:thumbnails --verify coverage acceptance check
|
||||
*
|
||||
* Options: --only <ids>, --limit <n> (canary), --concurrency <n> (default 3),
|
||||
* --out <dir> (receipts, default `data/archive`), --help.
|
||||
*
|
||||
* Runbook (production): on the homeserver, inside the compose maintenance
|
||||
* service with the stack env — see docs/ops/teqo-1313-deploy.md §C248.
|
||||
*/
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import {
|
||||
ARCHIVE_THUMBNAIL_DEFAULT_CONCURRENCY,
|
||||
ARCHIVE_THUMBNAIL_DEFAULT_OUT_DIR,
|
||||
archiveThumbnailEligibleRows,
|
||||
archiveThumbnailReportStamp,
|
||||
formatArchiveThumbnailProbeReport,
|
||||
formatArchiveThumbnailRunReport,
|
||||
parseArchiveThumbnailCliArgs,
|
||||
summarizeArchiveThumbnailProbe,
|
||||
summarizeArchiveThumbnailRun,
|
||||
} from './lib/archiveThumbnailPlan.mjs'
|
||||
import {
|
||||
TEQO_ENV_DATABASE_BY_ENV,
|
||||
assertEnvironmentDatabaseTarget,
|
||||
assertWriteConfirm,
|
||||
databaseTarget,
|
||||
dieWithLabel,
|
||||
loadCliEnv,
|
||||
mapWithConcurrency,
|
||||
mirroredMediaRequired,
|
||||
writeRepoFile,
|
||||
} from './lib/cli.mjs'
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const die = dieWithLabel('archive:thumbnails')
|
||||
|
||||
const WRITE_CONFIRM_FLAG = 'ARCHIVE_THUMBNAILS_CONFIRM'
|
||||
|
||||
const envLines = Object.entries(TEQO_ENV_DATABASE_BY_ENV)
|
||||
.map(([environment, database]) => `${environment} (${database})`)
|
||||
.join(' | ')
|
||||
|
||||
const HELP = `
|
||||
Uso: pnpm archive:thumbnails [opções]
|
||||
|
||||
Gera as miniaturas AVIF 720px q60 do álbum público somente para fotos
|
||||
APROVADAS com arquivo armazenado — o irmão determinístico
|
||||
flickr-<id>-grade.avif no mesmo store privado do original (nada de banco,
|
||||
nada de original alterado). Reexecutar converge: miniatura existente é
|
||||
pulada por HEAD; original ausente/ilegível é pulada com motivo; falha de
|
||||
rede/upload fica registrada para a próxima execução.
|
||||
|
||||
Modos (mutuamente exclusivos; o default é o plano):
|
||||
(sem flag) plano read-only — lista elegíveis e quantas faltam
|
||||
--apply gera as miniaturas faltantes (escrita só de objetos)
|
||||
--verify checagem de cobertura (exit 1 se faltar alguma)
|
||||
|
||||
Opções:
|
||||
--only <ids> restringe a ids de foto separados por vírgula (ex.: --only 80,83)
|
||||
--limit <n> --apply: processa no máximo n fotos (canário); no plano/verify, limita a checagem
|
||||
--concurrency <n> workers simultâneos (default ${ARCHIVE_THUMBNAIL_DEFAULT_CONCURRENCY})
|
||||
--out <dir> diretório dos recibos (default ${ARCHIVE_THUMBNAIL_DEFAULT_OUT_DIR})
|
||||
--help esta ajuda
|
||||
|
||||
Ambiente:
|
||||
DATABASE_URL alvo (obrigatório em todos os modos)
|
||||
S3_* obrigatório no --apply de alvo não-local (o original vive no bucket)
|
||||
ARCHIVE_THUMBNAILS_CONFIRM=1 intenção explícita de escrita em alvo não-local/produção
|
||||
TEQO_ENV=${envLines} casa o banco exato no --apply
|
||||
|
||||
Serialização: rode em janela própria, sem archive:catalog / archive:integrity
|
||||
concorrentes (C245/C246 tocam os mesmos originais). Após um --apply em produção,
|
||||
nada a revalidar: a rota descobre a miniatura por chave determinística.
|
||||
`
|
||||
|
||||
const writeReport = async (options, report, suffix = '') => {
|
||||
const stamp = archiveThumbnailReportStamp(report.runAt)
|
||||
const relativePath = join(options.out, 'reports', `archive-thumbnails-${stamp}${suffix}.json`)
|
||||
await writeRepoFile({
|
||||
label: 'archive:thumbnails',
|
||||
root: process.cwd(),
|
||||
relativePath,
|
||||
body: JSON.stringify(report, null, 2),
|
||||
})
|
||||
return relativePath
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArchiveThumbnailCliArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
console.log(HELP)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
|
||||
const mode = options.apply ? 'apply' : options.verify ? 'verify' : 'plan'
|
||||
const runAt = new Date().toISOString()
|
||||
console.log(`[archive:thumbnails] alvo: ${databaseTarget()} | modo: ${mode}`)
|
||||
|
||||
if (mode === 'apply') {
|
||||
assertWriteConfirm({
|
||||
label: 'archive:thumbnails',
|
||||
flag: WRITE_CONFIRM_FLAG,
|
||||
command: 'pnpm archive:thumbnails --apply',
|
||||
})
|
||||
assertEnvironmentDatabaseTarget()
|
||||
|
||||
const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts')
|
||||
const storage = resolveS3StorageEnv(process.env)
|
||||
if (mirroredMediaRequired({ s3Enabled: storage.enabled })) {
|
||||
die(
|
||||
'escrita em produção/remoto exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas o original não está acessível no alvo.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const { listArchivePhotos } = await import('../src/utilities/flickr/archivePhotoIngest.ts')
|
||||
const { generateArchivePhotoGrade } =
|
||||
await import('../src/utilities/archivePhotos/archivePhotoThumbnails.ts')
|
||||
const { privateMediaObjectExists, resolvePrivateMediaStaticDir } =
|
||||
await import('../src/utilities/privateMedia/privateMediaResponse.ts')
|
||||
const { ARCHIVE_PHOTO_SLUG } = await import('../src/lib/archivePhoto.ts')
|
||||
const { archivePhotoGradeFilename } = await import('../src/lib/archivePhotoThumbnail.ts')
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
const startedAt = Date.now()
|
||||
const staticDir = resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG)
|
||||
|
||||
const rows = await listArchivePhotos(payload)
|
||||
const eligible = archiveThumbnailEligibleRows(rows)
|
||||
const scoped = options.only ? eligible.filter((row) => options.only.includes(row.id)) : eligible
|
||||
const selected = options.limit ? scoped.slice(0, options.limit) : scoped
|
||||
|
||||
console.log(
|
||||
`[archive:thumbnails] álbum aprovado: ${eligible.length} foto(s) elegível(is); processando ${selected.length}`,
|
||||
)
|
||||
|
||||
const gradeExists = (row) =>
|
||||
privateMediaObjectExists({
|
||||
filename: archivePhotoGradeFilename(row.filename),
|
||||
staticDir,
|
||||
})
|
||||
|
||||
if (mode === 'plan' || mode === 'verify') {
|
||||
const probeResults = await mapWithConcurrency(selected, options.concurrency, async (row) => ({
|
||||
id: row.id,
|
||||
flickrId: row.flickrId,
|
||||
filename: row.filename,
|
||||
present: await gradeExists(row),
|
||||
}))
|
||||
const probe = summarizeArchiveThumbnailProbe(probeResults)
|
||||
const report = {
|
||||
runAt,
|
||||
mode,
|
||||
target: databaseTarget(),
|
||||
eligibleTotal: eligible.length,
|
||||
concurrency: options.concurrency,
|
||||
probe,
|
||||
results: probeResults,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report, `-${mode}`)
|
||||
for (const line of formatArchiveThumbnailProbeReport(report)) console.log(line)
|
||||
console.log(`\n[archive:thumbnails] recibo JSON: ${reportPath}`)
|
||||
if (mode === 'verify' && probe.missing > 0) {
|
||||
die(`${probe.missing} miniatura(s) faltando — aceite do C248 não satisfeito.`)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const results = await mapWithConcurrency(selected, options.concurrency, async (row) => {
|
||||
const outcome = (await gradeExists(row))
|
||||
? { status: 'skipped', reason: 'already-present' }
|
||||
: await generateArchivePhotoGrade(payload, { filename: row.filename })
|
||||
const detail =
|
||||
outcome.status === 'skipped' && outcome.detail
|
||||
? ` [${outcome.reason}]: ${outcome.detail}`
|
||||
: outcome.status === 'failed'
|
||||
? ` [${outcome.reason}]`
|
||||
: ''
|
||||
console.log(`[archive:thumbnails] ${outcome.status}${detail} ${row.flickrId}`)
|
||||
return { id: row.id, flickrId: row.flickrId, filename: row.filename, outcome }
|
||||
})
|
||||
|
||||
const summary = summarizeArchiveThumbnailRun(results)
|
||||
const report = {
|
||||
runAt,
|
||||
mode,
|
||||
target: databaseTarget(),
|
||||
eligibleTotal: eligible.length,
|
||||
concurrency: options.concurrency,
|
||||
summary,
|
||||
results: results.map((result) => ({
|
||||
id: result.id,
|
||||
flickrId: result.flickrId,
|
||||
filename: result.filename,
|
||||
status: result.outcome.status,
|
||||
reason: result.outcome.reason ?? null,
|
||||
detail: result.outcome.detail ?? null,
|
||||
bytes: result.outcome.bytes ?? null,
|
||||
})),
|
||||
skipped: results
|
||||
.filter((result) => result.outcome.status === 'skipped')
|
||||
.map((result) => ({
|
||||
id: result.id,
|
||||
filename: result.filename,
|
||||
reason: result.outcome.reason,
|
||||
detail: result.outcome.detail ?? null,
|
||||
})),
|
||||
failed: results
|
||||
.filter((result) => result.outcome.status === 'failed')
|
||||
.map((result) => ({
|
||||
id: result.id,
|
||||
filename: result.filename,
|
||||
reason: result.outcome.reason,
|
||||
})),
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report)
|
||||
for (const line of formatArchiveThumbnailRunReport(report)) console.log(line)
|
||||
console.log(`\n[archive:thumbnails] recibo JSON: ${reportPath}`)
|
||||
|
||||
if (summary.failed > 0) {
|
||||
die(`${summary.failed} falha(s) — o recibo lista os motivos; reexecutar retenta.`)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
die(error?.message || String(error))
|
||||
})
|
||||
|
|
@ -108,7 +108,7 @@ async function verticalMark(height) {
|
|||
|
||||
/** Foto oficial do candidato (cutout com alfa). */
|
||||
async function candidatePhoto(height) {
|
||||
const trim = await sharp(`${REPO}public/JOA00162.avif`)
|
||||
const trim = await sharp(`${REPO}public/JOA00162.webp`)
|
||||
.trim({ threshold: 5 })
|
||||
.png()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ import {
|
|||
archiveIntegrityReportStamp,
|
||||
formatArchiveIntegrityRepairReport,
|
||||
formatArchiveIntegrityScanReport,
|
||||
mapWithConcurrency,
|
||||
parseArchiveIntegrityCliArgs,
|
||||
summarizeArchiveIntegrityRepair,
|
||||
summarizeArchiveIntegrityScan,
|
||||
|
|
@ -62,6 +61,7 @@ import {
|
|||
databaseTarget,
|
||||
dieWithLabel,
|
||||
loadCliEnv,
|
||||
mapWithConcurrency,
|
||||
mirroredMediaRequired,
|
||||
writeRepoFile,
|
||||
} from './lib/cli.mjs'
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
/**
|
||||
* C246 — pure planning/reporting of the photo-archive integrity sweep: the argv
|
||||
* parser, the bounded-concurrency map, 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.
|
||||
* 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 =
|
||||
|
|
@ -22,26 +25,7 @@ export const archiveIntegrityReportStamp = (runAt) => String(runAt).replace(/[:.
|
|||
* @param {string} value
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export const parseArchiveIntegrityOnly = (value) => {
|
||||
const text = String(value ?? '').trim()
|
||||
if (text === '')
|
||||
throw new Error(`--only vazio — informe ao menos um id (ex.: --only 80,83) — ${USAGE}.`)
|
||||
const ids = []
|
||||
for (const part of text.split(',')) {
|
||||
const item = part.trim()
|
||||
if (!/^\d+$/.test(item)) {
|
||||
throw new Error(
|
||||
`--only espera ids numéricos separados por vírgula (recebi "${value}") — ${USAGE}.`,
|
||||
)
|
||||
}
|
||||
const id = Number(item)
|
||||
if (!Number.isSafeInteger(id) || id < 1) {
|
||||
throw new Error(`--only recebeu um id fora do intervalo: "${item}" — ${USAGE}.`)
|
||||
}
|
||||
if (!ids.includes(id)) ids.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
export const parseArchiveIntegrityOnly = (value) => parseIdList({ value, usage: USAGE })
|
||||
|
||||
/**
|
||||
* `pnpm archive:integrity` argv parser — pure, so the unit spec pins it (the
|
||||
|
|
@ -92,36 +76,6 @@ export const parseArchiveIntegrityCliArgs = (argv = process.argv.slice(2)) => {
|
|||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded-concurrency map that preserves input order. The sweep downloads and
|
||||
* decodes 6k+ photos, so unbounded `Promise.all` would be reckless; the pool
|
||||
* keeps at most `concurrency` workers alive and returns the results in the
|
||||
* same order as the input (receipts stay deterministic).
|
||||
*
|
||||
* @template T, R
|
||||
* @param {readonly T[]} items
|
||||
* @param {number} concurrency
|
||||
* @param {(item: T, index: number) => Promise<R>} worker
|
||||
* @returns {Promise<R[]>}
|
||||
*/
|
||||
export const mapWithConcurrency = async (items, concurrency, worker) => {
|
||||
const results = new Array(items.length)
|
||||
const size = Math.max(1, Math.min(Math.trunc(Number(concurrency)) || 1, items.length))
|
||||
let next = 0
|
||||
|
||||
const runner = async () => {
|
||||
for (;;) {
|
||||
const index = next
|
||||
next += 1
|
||||
if (index >= items.length) return
|
||||
results[index] = await worker(items[index], index)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: size }, runner))
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -23,7 +23,13 @@ import { FLICKR_USER_AGENT, isFlickrSourceGone } from './flickrApi.mjs'
|
|||
|
||||
/** A photo original is MBs; the guard only catches a stalled/runaway server. */
|
||||
const PHOTO_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024
|
||||
const PHOTO_DOWNLOAD_TIMEOUT_MS = 2 * 60_000
|
||||
/**
|
||||
* Flickr originals can pass 50 MB (a 7952px panorama was 54 MB) and the
|
||||
* homeserver's direct egress runs ~260 KB/s — ~3,5 min for that file — so a
|
||||
* 2 min ceiling aborted a legitimate repair (C246 ops, 2026-10-02). 15 min
|
||||
* still catches a stall without capping a slow-but-working link.
|
||||
*/
|
||||
export const ARCHIVE_INTEGRITY_DOWNLOAD_TIMEOUT_MS = 15 * 60_000
|
||||
/** Long edge of the decode proof; the same size the face index feeds the engine. */
|
||||
const DECODE_MAX_EDGE = 1024
|
||||
|
||||
|
|
@ -92,7 +98,7 @@ export const repairArchivePhotoFromSource = async ({
|
|||
url: source.url,
|
||||
destinationPath: filePath,
|
||||
headers: { 'User-Agent': FLICKR_USER_AGENT },
|
||||
timeoutMs: PHOTO_DOWNLOAD_TIMEOUT_MS,
|
||||
timeoutMs: ARCHIVE_INTEGRITY_DOWNLOAD_TIMEOUT_MS,
|
||||
maxBytes: PHOTO_DOWNLOAD_MAX_BYTES,
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
/**
|
||||
* C242 — pure planning/reporting of `pnpm archive:publish`: the argv parser,
|
||||
* the run summary and the human lines + JSON receipt. No I/O of its own, so the
|
||||
* unit spec drives it with fakes.
|
||||
* unit spec drives it with fakes. C249 — the summary/receipt also carry the
|
||||
* integrity preflight verdicts (`skippedBroken`).
|
||||
*/
|
||||
|
||||
const USAGE = 'uso: `pnpm archive:publish [--apply] [--limit <n>] [--out <dir>]`'
|
||||
|
|
@ -45,33 +46,66 @@ export const parseArchivePublishCliArgs = (argv = process.argv.slice(2)) => {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{ photoId: number, status: string, error?: string | null }>} results
|
||||
* C249 — the preflight verdict never counts as an update failure: a draft whose
|
||||
* object is missing/corrupt is `skippedBroken` (stage + reason), kept in its own
|
||||
* list so the receipt names it without pretending an approval was attempted.
|
||||
* `eligible` is the plan-mode census (inspected clean, no write) and is
|
||||
* deliberately not a failure — plan and apply receive the same results, so the
|
||||
* summary composes for both modes.
|
||||
*
|
||||
* @param {Array<{ photoId: number, status: string, stage?: string | null, reason?: string | null, error?: string | null }>} results
|
||||
*/
|
||||
export const summarizeArchivePublishResults = (results) => {
|
||||
const failures = []
|
||||
const skippedBroken = []
|
||||
let approved = 0
|
||||
let failed = 0
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'approved') approved += 1
|
||||
else {
|
||||
if (result.status === 'approved') {
|
||||
approved += 1
|
||||
} else if (result.status === 'skippedBroken') {
|
||||
skippedBroken.push({
|
||||
photoId: result.photoId,
|
||||
stage: result.stage ?? null,
|
||||
reason: result.reason ?? null,
|
||||
})
|
||||
} else if (result.status === 'eligible') {
|
||||
continue
|
||||
} else {
|
||||
failed += 1
|
||||
failures.push({ photoId: result.photoId, error: result.error ?? null })
|
||||
}
|
||||
}
|
||||
|
||||
return { approved, failed, failures }
|
||||
return {
|
||||
approved,
|
||||
failed,
|
||||
failures,
|
||||
skippedBroken,
|
||||
skippedBrokenCount: skippedBroken.length,
|
||||
}
|
||||
}
|
||||
|
||||
export const formatArchivePublishReport = (report) => {
|
||||
const lines = [
|
||||
`[archive:publish] modo: ${report.mode} | alvo: ${report.target}`,
|
||||
`[archive:publish] drafts elegíveis: ${report.queue.totalDrafts}`,
|
||||
`[archive:publish] fila: ${report.queue.items} de ${report.queue.totalDrafts} draft(s)`,
|
||||
]
|
||||
|
||||
if (report.mode !== 'plan') {
|
||||
if (report.mode === 'plan') {
|
||||
lines.push(
|
||||
`[archive:publish] aprovadas: ${report.summary.approved} | falharam: ${report.summary.failed}`,
|
||||
`[archive:publish] drafts quebrados (fora do lote): ${report.summary?.skippedBrokenCount ?? 0}`,
|
||||
)
|
||||
} else {
|
||||
lines.push(
|
||||
`[archive:publish] aprovadas: ${report.summary?.approved ?? 0} | falharam: ${report.summary?.failed ?? 0} | puladas (quebradas): ${report.summary?.skippedBrokenCount ?? 0}`,
|
||||
)
|
||||
}
|
||||
|
||||
for (const skipped of report.summary?.skippedBroken ?? []) {
|
||||
lines.push(
|
||||
`[archive:publish] ! foto ${skipped.photoId} fora do lote (${skipped.stage ?? 'missing'}): ${skipped.reason ?? 'objeto ausente'}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
208
scripts/lib/archiveThumbnailPlan.mjs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* C248 — pure planning/reporting of the stored-grade thumbnail backfill: the
|
||||
* argv parser, the eligible-row filter, the probe/run summaries and the human
|
||||
* lines + JSON receipt. No I/O of its own: the object probe, the generation
|
||||
* and the Payload reads are injected by the CLI, so the unit spec drives
|
||||
* everything with fakes.
|
||||
*/
|
||||
|
||||
import { parseIdList } from './cli.mjs'
|
||||
import { formatArchiveBytes } from './flickrPlan.mjs'
|
||||
|
||||
const USAGE =
|
||||
'uso: `pnpm archive:thumbnails [--apply | --verify] [--only <ids>] [--limit <n>] [--concurrency <n>] [--out <dir>]`'
|
||||
|
||||
export const ARCHIVE_THUMBNAIL_DEFAULT_OUT_DIR = 'data/archive'
|
||||
|
||||
/**
|
||||
* The generation is bounded per process by the same 2-slot gate the web heal
|
||||
* uses (`MAX_CONCURRENT_GENERATIONS`), so a CLI concurrency above 2 would not
|
||||
* add parallel encodes; the default matches the real ceiling.
|
||||
*/
|
||||
export const ARCHIVE_THUMBNAIL_DEFAULT_CONCURRENCY = 2
|
||||
|
||||
export const archiveThumbnailReportStamp = (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 parseArchiveThumbnailOnly = (value) => parseIdList({ value, usage: USAGE })
|
||||
|
||||
/**
|
||||
* `pnpm archive:thumbnails` 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
|
||||
* plan; `--apply` generates the missing grades and `--verify` checks the
|
||||
* coverage of the eligible rows. Errors carry the usage line.
|
||||
*
|
||||
* @param {string[]} [argv]
|
||||
* @returns {{ apply: boolean, verify: boolean, only: number[] | null, limit: number | null, concurrency: number, out: string, help: boolean }}
|
||||
*/
|
||||
export const parseArchiveThumbnailCliArgs = (argv = process.argv.slice(2)) => {
|
||||
const options = {
|
||||
apply: false,
|
||||
verify: false,
|
||||
only: null,
|
||||
limit: null,
|
||||
concurrency: ARCHIVE_THUMBNAIL_DEFAULT_CONCURRENCY,
|
||||
out: ARCHIVE_THUMBNAIL_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 === '--verify') options.verify = true
|
||||
else if (arg === '--only') options.only = parseArchiveThumbnailOnly(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.apply && options.verify) {
|
||||
throw new Error(`--apply e --verify são mutuamente exclusivos — ${USAGE}.`)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* The eligible queue: an APPROVED photo with a stored filename has a public
|
||||
* preview to serve, so it deserves a grade. Draft and removed rows stay out
|
||||
* (the removal guardrail: generating for them would be discarded work), and a
|
||||
* row with no file can never produce one.
|
||||
*
|
||||
* @param {Array<{ id: number, flickrId: string, filename: string | null | undefined, publicationStatus: string }>} rows
|
||||
* @returns {Array<{ id: number, flickrId: string, filename: string }>}
|
||||
*/
|
||||
export const archiveThumbnailEligibleRows = (rows) =>
|
||||
(Array.isArray(rows) ? rows : [])
|
||||
.filter((row) => row.publicationStatus === 'approved' && Boolean(row.filename))
|
||||
.map((row) => ({ id: row.id, flickrId: row.flickrId, filename: row.filename }))
|
||||
|
||||
/**
|
||||
* Aggregates one read-only probe (plan/verify): how many eligible photos have
|
||||
* the stored grade and how many still miss it. The probe never throws by
|
||||
* design (an unreachable store reads as missing), so a completely missing
|
||||
* coverage in the receipt is also the honest signal to check the bucket.
|
||||
*
|
||||
* @param {Array<{ id: number, flickrId: string, present: boolean }>} results
|
||||
*/
|
||||
export const summarizeArchiveThumbnailProbe = (results) => {
|
||||
let present = 0
|
||||
let missing = 0
|
||||
const missingIds = []
|
||||
for (const result of results) {
|
||||
if (result.present) present += 1
|
||||
else {
|
||||
missing += 1
|
||||
missingIds.push(result.id)
|
||||
}
|
||||
}
|
||||
return { eligible: results.length, present, missing, missingIds }
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates one `--apply` run: what was generated, what was honestly skipped
|
||||
* (missing/corrupt origin, already present) and what failed and deserves a
|
||||
* retry. `bytes` sums only the fresh encodes.
|
||||
*
|
||||
* @param {Array<{ id: number, outcome: { status: string, reason?: string, detail?: string, bytes?: number } }>} results
|
||||
*/
|
||||
export const summarizeArchiveThumbnailRun = (results) => {
|
||||
let generated = 0
|
||||
let skipped = 0
|
||||
let failed = 0
|
||||
let bytes = 0
|
||||
const skippedByReason = {}
|
||||
for (const result of results) {
|
||||
const outcome = result.outcome ?? {}
|
||||
if (outcome.status === 'generated') {
|
||||
generated += 1
|
||||
bytes += Number(outcome.bytes) || 0
|
||||
} else if (outcome.status === 'skipped') {
|
||||
skipped += 1
|
||||
const reason = outcome.reason ?? 'unknown'
|
||||
skippedByReason[reason] = (skippedByReason[reason] ?? 0) + 1
|
||||
} else {
|
||||
// Unknown outcomes are failures: a new status must never hide in "skips".
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
return { eligible: results.length, generated, skipped, failed, bytes, skippedByReason }
|
||||
}
|
||||
|
||||
const SKIP_LABEL = {
|
||||
'already-present': 'já existia',
|
||||
'missing-origin': 'original ausente',
|
||||
'corrupt-origin': 'original ilegível',
|
||||
'no-filename': 'sem arquivo',
|
||||
'recent-failure': 'falha recente',
|
||||
}
|
||||
|
||||
/**
|
||||
* Human lines of the plan/verify probe; the JSON keeps the full report.
|
||||
*
|
||||
* @param {Record<string, any>} report
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export const formatArchiveThumbnailProbeReport = (report) => {
|
||||
const label = '[archive:thumbnails]'
|
||||
const probe = report.probe
|
||||
return [
|
||||
`${label} álbum aprovado: ${probe.eligible} foto(s) · miniatura presente: ${probe.present} · faltando: ${probe.missing}`,
|
||||
...(probe.missing > 0
|
||||
? [
|
||||
`${label} ${report.mode === 'plan' ? 'rode `pnpm archive:thumbnails --apply` para gerar as faltantes' : 'cobertura incompleta — rode o --apply'}.`,
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Human lines of the `--apply` receipt, listing every skip and failure with
|
||||
* its reason.
|
||||
*
|
||||
* @param {Record<string, any>} report
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export const formatArchiveThumbnailRunReport = (report) => {
|
||||
const label = '[archive:thumbnails]'
|
||||
const summary = report.summary
|
||||
const lines = [
|
||||
`${label} lote: ${summary.eligible} foto(s) elegível(is) · geradas: ${summary.generated} · puladas: ${summary.skipped} · falhas: ${summary.failed} (${formatArchiveBytes(summary.bytes)})`,
|
||||
]
|
||||
for (const [reason, count] of Object.entries(summary.skippedByReason)) {
|
||||
lines.push(` - ${SKIP_LABEL[reason] ?? reason}: ${count}`)
|
||||
}
|
||||
for (const item of report.skipped ?? []) {
|
||||
lines.push(
|
||||
` - pulada ${item.id} (${item.filename}) [${SKIP_LABEL[item.reason] ?? item.reason}]${item.detail ? `: ${item.detail}` : ''}`,
|
||||
)
|
||||
}
|
||||
for (const item of report.failed ?? []) {
|
||||
lines.push(` - falha ${item.id} (${item.filename}): ${item.reason ?? 'erro'}`)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
|
@ -486,3 +486,68 @@ export const writeRepoFile = async ({ label, root, relativePath, body }) => {
|
|||
await writeFile(path, body)
|
||||
console.log(`[${label}] wrote ${relativePath} (${Buffer.byteLength(body)} bytes)`)
|
||||
}
|
||||
|
||||
/**
|
||||
* `--only 80,83` → `[80, 83]` (numeric row ids). Exact digits only: a typo
|
||||
* must refuse the run, never silently widen or empty the selection. The two
|
||||
* archive plan-libs (C246/C248) share this one copy; the caller passes its
|
||||
* flag and usage line so the error message stays domain-specific.
|
||||
*
|
||||
* @param {{ value: string, flag?: string, usage?: string }} options
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export const parseIdList = ({ value, flag = '--only', usage = '' }) => {
|
||||
const text = String(value ?? '').trim()
|
||||
const suffix = usage ? ` — ${usage}` : ''
|
||||
if (text === '') {
|
||||
throw new Error(`${flag} vazio — informe ao menos um id (ex.: ${flag} 80,83)${suffix}.`)
|
||||
}
|
||||
const ids = []
|
||||
for (const part of text.split(',')) {
|
||||
const item = part.trim()
|
||||
if (!/^\d+$/.test(item)) {
|
||||
throw new Error(
|
||||
`${flag} espera ids numéricos separados por vírgula (recebi "${value}")${suffix}.`,
|
||||
)
|
||||
}
|
||||
const id = Number(item)
|
||||
if (!Number.isSafeInteger(id) || id < 1) {
|
||||
throw new Error(`${flag} recebeu um id fora do intervalo: "${item}"${suffix}.`)
|
||||
}
|
||||
if (!ids.includes(id)) ids.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded-concurrency map that preserves input order (C248 — moved here from
|
||||
* `archiveIntegrityPlan.mjs` on the debt's trigger: the second consumer landed;
|
||||
* the C246 sweep and the C248 thumbnail backfill share this one copy). The
|
||||
* sweeps download and decode thousands of photos, so unbounded `Promise.all`
|
||||
* would be reckless; the pool keeps at most `concurrency` workers alive and
|
||||
* returns the results in the same order as the input (receipts stay
|
||||
* deterministic).
|
||||
*
|
||||
* @template T, R
|
||||
* @param {readonly T[]} items
|
||||
* @param {number} concurrency
|
||||
* @param {(item: T, index: number) => Promise<R>} worker
|
||||
* @returns {Promise<R[]>}
|
||||
*/
|
||||
export const mapWithConcurrency = async (items, concurrency, worker) => {
|
||||
const results = new Array(items.length)
|
||||
const size = Math.max(1, Math.min(Math.trunc(Number(concurrency)) || 1, items.length))
|
||||
let next = 0
|
||||
|
||||
const runner = async () => {
|
||||
for (;;) {
|
||||
const index = next
|
||||
next += 1
|
||||
if (index >= items.length) return
|
||||
results[index] = await worker(items[index], index)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: size }, runner))
|
||||
return results
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export const SCRIPTS_SPEC_PINNED = [
|
|||
'scripts/check-test-locations.mjs',
|
||||
'scripts/cityReportSnapshot.mjs',
|
||||
'scripts/institutionSnapshot.mjs',
|
||||
'scripts/publish-archive-photos.mjs',
|
||||
'scripts/themeSnapshot.mjs',
|
||||
'scripts/lib/agent-forgejo.mjs',
|
||||
'scripts/lib/agent-plan-lifecycle.mjs',
|
||||
|
|
@ -47,6 +48,7 @@ export const SCRIPTS_SPEC_PINNED = [
|
|||
'scripts/lib/archiveIntegrityPlan.mjs',
|
||||
'scripts/lib/archiveIntegrityRepair.mjs',
|
||||
'scripts/lib/archivePublishPlan.mjs',
|
||||
'scripts/lib/archiveThumbnailPlan.mjs',
|
||||
'scripts/lib/archiveVisionApi.mjs',
|
||||
'scripts/lib/auto-unblock.mjs',
|
||||
'scripts/lib/briefingContent.mjs',
|
||||
|
|
|
|||
|
|
@ -5,9 +5,17 @@
|
|||
* (decision of the owner, 2026-10-01); `removed` is NEVER touched — a takedown
|
||||
* stays a takedown.
|
||||
*
|
||||
* C249 — every eligible draft goes through the C246 integrity probe before any
|
||||
* approval: the object is downloaded through the same serving path and decoded;
|
||||
* a missing/undecodable object stays `draft`, is named in the receipt as
|
||||
* `skippedBroken` (stage + reason) and the command exits 1. The runbook warning
|
||||
* stopped being the only protection.
|
||||
*
|
||||
* Modes:
|
||||
* pnpm archive:publish plan/dry-run (default) — the eligible queue; no write
|
||||
* pnpm archive:publish --apply approves the drafts; requires
|
||||
* pnpm archive:publish plan/dry-run (default) — the eligible
|
||||
* queue + preflight; no write
|
||||
* pnpm archive:publish --apply approves the drafts whose object
|
||||
* inspects clean; requires
|
||||
* ARCHIVE_PUBLISH_CONFIRM=1 outside local dev
|
||||
* and the declared TEQO_ENV matching the
|
||||
* exact database
|
||||
|
|
@ -23,10 +31,14 @@
|
|||
* curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" \
|
||||
* -H "x-revalidate-secret: $REVALIDATE_SECRET"
|
||||
*/
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { ARCHIVE_PHOTO_SLUG } from '../src/lib/archivePhoto.ts'
|
||||
import { isArchivePhotoRemovalChannelUrl } from '../src/lib/archivePhotoCatalog.ts'
|
||||
|
||||
import {
|
||||
|
|
@ -46,8 +58,6 @@ import {
|
|||
writeRepoFile,
|
||||
} from './lib/cli.mjs'
|
||||
|
||||
loadCliEnv()
|
||||
|
||||
const die = dieWithLabel('archive:publish')
|
||||
|
||||
const WRITE_CONFIRM_FLAG = 'ARCHIVE_PUBLISH_CONFIRM'
|
||||
|
|
@ -63,9 +73,14 @@ Uso: pnpm archive:publish [opções]
|
|||
Aprova em lote as fotos DRAFT do acervo (publicationStatus: draft → approved),
|
||||
para o álbum público e a busca por selfie. Fotos "removed" nunca são tocadas.
|
||||
|
||||
Antes de aprovar, cada draft passa pelo preflight de integridade do C246: o
|
||||
objeto é baixado pelo mesmo caminho da rota pública e decodificado. Objeto
|
||||
ausente/corrompido fica fora do lote, é nomeado no recibo (stage + motivo) e o
|
||||
comando sai 1.
|
||||
|
||||
Modos:
|
||||
(sem flag) plano/dry-run — fila elegível; sem escrita
|
||||
--apply aprova os drafts (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local)
|
||||
(sem flag) plano/dry-run — fila elegível + preflight; sem escrita
|
||||
--apply aprova os drafts com objeto íntegro (exige ${WRITE_CONFIRM_FLAG}=1 fora do dev local)
|
||||
|
||||
Opções:
|
||||
--limit <n> processa no máximo n fotos (canário)
|
||||
|
|
@ -79,9 +94,9 @@ Ambiente:
|
|||
casando o banco exato. O plano é read-only.
|
||||
`
|
||||
|
||||
const writeReport = async (options, report, suffix = '') => {
|
||||
const writeReport = async (options, report) => {
|
||||
const stamp = archivePublishReportStamp(report.runAt)
|
||||
const relativePath = join(options.out, 'reports', `archive-publish-${stamp}${suffix}.json`)
|
||||
const relativePath = join(options.out, 'reports', `archive-publish-${stamp}.json`)
|
||||
await writeRepoFile({
|
||||
label: 'archive:publish',
|
||||
root: process.cwd(),
|
||||
|
|
@ -91,6 +106,104 @@ const writeReport = async (options, report, suffix = '') => {
|
|||
return join(process.cwd(), relativePath)
|
||||
}
|
||||
|
||||
const emitReport = async (options, report, hint = null) => {
|
||||
const reportPath = await writeReport(options, report)
|
||||
for (const line of formatArchivePublishReport(report)) console.log(line)
|
||||
if (hint) console.log(`\n${hint}`)
|
||||
console.log(`[archive:publish] recibo JSON: ${reportPath}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* C249 — one read-only preflight probe: download + decode through the SAME
|
||||
* owner the C246 sweep uses, with a temp dir created and removed per photo.
|
||||
* Nothing here writes to the archive; the verdict is `ok` or `skippedBroken`.
|
||||
*/
|
||||
const inspectPhoto = async ({ photo, tempRoot, staticDir, inspect }) => {
|
||||
const dir = join(tempRoot, String(photo.id))
|
||||
await mkdir(dir, { recursive: true })
|
||||
const filename = photo.filename ?? null
|
||||
try {
|
||||
const inspection = await inspect({
|
||||
media: { filename },
|
||||
staticDir,
|
||||
destinationPath: join(dir, filename ?? `foto-${photo.id}.bin`),
|
||||
})
|
||||
if (inspection.status === 'ok') return { photoId: photo.id, status: 'ok' }
|
||||
return {
|
||||
photoId: photo.id,
|
||||
status: 'skippedBroken',
|
||||
stage: inspection.status === 'missing' ? 'missing' : inspection.stage,
|
||||
reason: inspection.status === 'missing' ? 'objeto ausente' : inspection.reason,
|
||||
}
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C249 — the batch, exported so the int spec drives the real Payload/utility
|
||||
* path with its own queue (never the whole database). Every photo is
|
||||
* preflighted before any approval; `apply: false` classifies without writing.
|
||||
* The receipt owns the human/JSON naming — this function only returns verdicts.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {import('payload').Payload} options.payload
|
||||
* @param {Array<{ id: number, filename?: string | null }>} options.queue
|
||||
* @param {Function} options.inspect `inspectPrivateMediaObject`
|
||||
* @param {string} options.staticDir local fallback dir of the collection
|
||||
* @param {boolean} options.apply whether the clean drafts are approved
|
||||
* @param {(line: string) => void} [options.log]
|
||||
* @returns {Promise<Array<{ photoId: number, status: 'ok' | 'eligible' | 'approved' | 'failed' | 'skippedBroken', stage?: string, reason?: string, error?: string }>>}
|
||||
*/
|
||||
export const runArchivePublishBatch = async ({
|
||||
payload,
|
||||
queue,
|
||||
inspect,
|
||||
staticDir,
|
||||
apply,
|
||||
log = console.log,
|
||||
}) => {
|
||||
const results = []
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'archive-publish-'))
|
||||
try {
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const photo = queue[index]
|
||||
const preflight = await inspectPhoto({ photo, tempRoot, staticDir, inspect })
|
||||
|
||||
if (preflight.status !== 'ok') {
|
||||
results.push(preflight)
|
||||
} else if (!apply) {
|
||||
results.push({ photoId: photo.id, status: 'eligible' })
|
||||
} else {
|
||||
try {
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id: photo.id,
|
||||
data: { publicationStatus: 'approved' },
|
||||
depth: 0,
|
||||
// Intentional bypass: same trusted operator.
|
||||
overrideAccess: true,
|
||||
})
|
||||
results.push({ photoId: photo.id, status: 'approved' })
|
||||
} catch (error) {
|
||||
results.push({
|
||||
photoId: photo.id,
|
||||
status: 'failed',
|
||||
error: error?.message ?? String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if ((index + 1) % PAGE_SIZE === 0) {
|
||||
log(`[archive:publish] … ${index + 1}/${queue.length}`)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArchivePublishCliArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
|
|
@ -114,6 +227,8 @@ async function main() {
|
|||
}
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const { inspectPrivateMediaObject, resolvePrivateMediaStaticDir } =
|
||||
await import('../src/utilities/privateMedia/privateMediaResponse.ts')
|
||||
const payload = await getPayload({ config })
|
||||
const startedAt = Date.now()
|
||||
|
||||
|
|
@ -127,66 +242,53 @@ async function main() {
|
|||
}
|
||||
|
||||
const drafts = await payload.find({
|
||||
collection: 'archivePhoto',
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { publicationStatus: { equals: 'draft' } },
|
||||
sort: ['id'],
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: { id: true },
|
||||
select: { id: true, filename: true },
|
||||
// Intentional bypass: the publish CLI is a trusted operator with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
const queue = options.limit ? drafts.docs.slice(0, options.limit) : drafts.docs
|
||||
|
||||
if (mode === 'apply' && queue.length === 0) {
|
||||
console.log('[archive:publish] nada a fazer — nenhuma foto draft elegível.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const staticDir = resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG)
|
||||
const results = await runArchivePublishBatch({
|
||||
payload,
|
||||
queue,
|
||||
inspect: inspectPrivateMediaObject,
|
||||
staticDir,
|
||||
apply: mode === 'apply',
|
||||
})
|
||||
|
||||
const summary = summarizeArchivePublishResults(results)
|
||||
|
||||
if (mode === 'plan') {
|
||||
const report = {
|
||||
runAt,
|
||||
mode,
|
||||
target: databaseTarget(),
|
||||
queue: { totalDrafts: drafts.docs.length, items: queue.length },
|
||||
summary: { approved: 0, failed: 0, failures: [] },
|
||||
summary,
|
||||
preview: queue.slice(0, 20).map((doc) => ({ photoId: doc.id })),
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report)
|
||||
for (const line of formatArchivePublishReport(report)) console.log(line)
|
||||
console.log(`\n[archive:publish] recibo JSON: ${reportPath}`)
|
||||
await emitReport(options, report)
|
||||
if (summary.skippedBrokenCount > 0) {
|
||||
die(
|
||||
`${summary.skippedBrokenCount} foto(s) draft com objeto quebrado/ausente — fora do lote; veja o recibo.`,
|
||||
)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (queue.length === 0) {
|
||||
console.log('[archive:publish] nada a fazer — nenhuma foto draft elegível.')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const results = []
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const photo = queue[index]
|
||||
try {
|
||||
await payload.update({
|
||||
collection: 'archivePhoto',
|
||||
id: photo.id,
|
||||
data: { publicationStatus: 'approved' },
|
||||
depth: 0,
|
||||
// Intentional bypass: same trusted operator.
|
||||
overrideAccess: true,
|
||||
})
|
||||
results.push({ photoId: photo.id, status: 'approved' })
|
||||
} catch (error) {
|
||||
results.push({
|
||||
photoId: photo.id,
|
||||
status: 'failed',
|
||||
error: error?.message ?? String(error),
|
||||
})
|
||||
}
|
||||
|
||||
if ((index + 1) % PAGE_SIZE === 0) {
|
||||
console.log(`[archive:publish] … ${index + 1}/${queue.length}`)
|
||||
}
|
||||
}
|
||||
|
||||
const summary = summarizeArchivePublishResults(results)
|
||||
const report = {
|
||||
runAt,
|
||||
mode,
|
||||
|
|
@ -195,19 +297,28 @@ async function main() {
|
|||
summary,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report)
|
||||
for (const line of formatArchivePublishReport(report)) console.log(line)
|
||||
console.log(
|
||||
'\n[archive:publish] lembre de bustar a tag pública (processo Next): curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" -H "x-revalidate-secret: $REVALIDATE_SECRET"',
|
||||
await emitReport(
|
||||
options,
|
||||
report,
|
||||
'[archive:publish] lembre de bustar a tag pública (processo Next): curl -X POST "https://jorgesolla1313.com.br/api/revalidate?tag=archivePhotos" -H "x-revalidate-secret: $REVALIDATE_SECRET"',
|
||||
)
|
||||
console.log(`[archive:publish] recibo JSON: ${reportPath}`)
|
||||
|
||||
if (summary.skippedBrokenCount > 0) {
|
||||
die(
|
||||
`${summary.skippedBrokenCount} foto(s) draft com objeto quebrado/ausente ficaram fora do lote — veja o recibo.`,
|
||||
)
|
||||
}
|
||||
if (summary.approved === 0 && results.length > 0) {
|
||||
die('nenhuma foto foi aprovada — falhas listadas no recibo.')
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
die(error?.message || String(error))
|
||||
})
|
||||
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
|
||||
if (isDirectRun) {
|
||||
loadCliEnv()
|
||||
main().catch((error) => {
|
||||
die(error?.message || String(error))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,14 +52,14 @@ const problemItems: CampaignCarouselItem[] = [
|
|||
id: 'escala-6x1',
|
||||
title: 'Pelo fim da escala 6x1!',
|
||||
body: 'Jornadas longas aumentam o risco de AVC e infarto. Descanso é saúde pública. A bancada de Lula derrotou a direita na Câmara: o fim da 6×1 avançou. Mas a briga continua no Senado, onde a direita tenta barrar o avanço. No dia 4 de outubro, o 1313 é o seu sim ao descanso.',
|
||||
image: '/fundo.avif',
|
||||
image: '/fundo.webp',
|
||||
imageAlt: 'Jorge Solla defendendo direitos dos trabalhadores na Câmara dos Deputados',
|
||||
},
|
||||
{
|
||||
id: 'sus',
|
||||
title: 'Pra valorizar o SUS',
|
||||
body: 'O SUS é o sistema de saúde de todo brasileiro. Com Lula, a saúde voltou a receber recursos: Nova PAC de R$ 30,5 bilhões e Mais Médicos retomado. Mas o teto de gastos da direita ainda trava o orçamento. O 1313 é seu voto em defesa do SUS no Congresso!',
|
||||
image: '/53569851134_02afc18fb4_o.avif',
|
||||
image: '/53569851134_02afc18fb4_o.webp',
|
||||
imageAlt: 'Jorge Solla em agenda pública de defesa do SUS',
|
||||
imageFrame: {
|
||||
width: 1024,
|
||||
|
|
@ -71,7 +71,7 @@ const problemItems: CampaignCarouselItem[] = [
|
|||
id: 'mataripe',
|
||||
title: 'Pra defender os baianos',
|
||||
body: 'A Refinaria de Mataripe foi vendida no governo Bolsonaro e segue nas mãos de estrangeiros. Com Lula e Jerônimo, a Bahia voltou a andar pra frente; mas recomprar Mataripe exige bancada forte no Congresso. Votar 1313 é devolver à Bahia o que é seu.',
|
||||
image: '/52396285023_561ffc0ff6_o.avif',
|
||||
image: '/52396285023_561ffc0ff6_o.webp',
|
||||
imageAlt: 'Vista da Bahia durante mobilização popular',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import config from '@payload-config'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { after, NextResponse } from 'next/server'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
|
||||
import { ARCHIVE_PHOTO_THUMB_WIDTH } from '@/lib/archivePhotoPublicCatalog'
|
||||
import { PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
|
||||
import {
|
||||
ARCHIVE_PHOTO_GRADE_MIME_TYPE,
|
||||
archivePhotoGradeFilename,
|
||||
} from '@/lib/archivePhotoThumbnail'
|
||||
import { acceptsAvif, PRIVATE_MEDIA_CACHE_CONTROL } from '@/lib/privateMedia'
|
||||
import { getApprovedArchivePhotoById } from '@/utilities/archivePhotos/archivePhotoReads'
|
||||
import { ensureArchivePhotoGrade } from '@/utilities/archivePhotos/archivePhotoThumbnails'
|
||||
import {
|
||||
buildPrivateMediaImageResponse,
|
||||
buildPrivateMediaResponse,
|
||||
resolvePrivateMediaStaticDir,
|
||||
} from '@/utilities/privateMedia/privateMediaResponse'
|
||||
|
||||
|
|
@ -17,11 +23,18 @@ export const dynamic = 'force-dynamic'
|
|||
* C233 — the only public door to an approved photo. The gate is the cached
|
||||
* public read (`archivePhotoIsPublic`), so a draft, a removed photo, an unknown
|
||||
* id and a delete all answer the same silent 404 — the route never leaks which
|
||||
* photos exist. `?tamanho=grade` serves the on-the-fly resized thumbnail,
|
||||
* `?download=1` forces the legible `jorge-solla-1313-foto-<id>.<ext>` name; the
|
||||
* object itself stays in the private bucket (the C193/C199 streaming helper is
|
||||
* the single owner of that I/O). `X-Robots-Tag` keeps the derivative out of the
|
||||
* index: the canonical surface is the album, never the file.
|
||||
* photos exist. `?tamanho=grade` serves the grade thumbnail, `?download=1`
|
||||
* forces the legible `jorge-solla-1313-foto-<id>.<ext>` name; the object itself
|
||||
* stays in the private bucket (the C193/C199 streaming helper is the single
|
||||
* owner of that I/O). `X-Robots-Tag` keeps the derivative out of the index: the
|
||||
* canonical surface is the album, never the file.
|
||||
*
|
||||
* C248 — when the client accepts AVIF and the stored grade exists, the grade
|
||||
* object is served directly (no sharp on the request); on a miss or any read
|
||||
* failure the route answers the on-the-fly JPEG exactly as before and heals
|
||||
* the grade for the next request in `after()`. `Vary: Accept` keeps a heuristic
|
||||
* cache from mixing the two representations even though the response is
|
||||
* `no-store`.
|
||||
*/
|
||||
|
||||
const notFound = (): NextResponse =>
|
||||
|
|
@ -52,14 +65,42 @@ export const GET = async (
|
|||
})
|
||||
.catch(() => null)
|
||||
if (!media?.filename) return notFound()
|
||||
const originalFilename = media.filename
|
||||
|
||||
const searchParams = new URL(request.url).searchParams
|
||||
const thumbnail = searchParams.get('tamanho') === 'grade'
|
||||
const download = searchParams.get('download') === '1'
|
||||
|
||||
const staticDir = resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG)
|
||||
|
||||
if (thumbnail && !download && acceptsAvif(request.headers.get('accept'))) {
|
||||
// The stored grade is a sibling object in the same private store; the
|
||||
// streaming owner answers 404/416 without leaking which object is there.
|
||||
const grade = await buildPrivateMediaResponse({
|
||||
media: {
|
||||
filename: archivePhotoGradeFilename(originalFilename),
|
||||
mimeType: ARCHIVE_PHOTO_GRADE_MIME_TYPE,
|
||||
},
|
||||
staticDir,
|
||||
rangeHeader: null,
|
||||
download: false,
|
||||
dispositionFilename: photo.downloadFilename,
|
||||
}).catch(() => null)
|
||||
|
||||
if (grade?.status === 200) {
|
||||
grade.headers.set('X-Robots-Tag', 'noindex')
|
||||
grade.headers.set('Vary', 'Accept')
|
||||
return grade
|
||||
}
|
||||
|
||||
// Missing/unreadable grade: rebuild it out of this latency window; the
|
||||
// fallback below answers this request exactly as the pre-C248 route did.
|
||||
after(() => ensureArchivePhotoGrade(payload, { filename: originalFilename }))
|
||||
}
|
||||
|
||||
const response = await buildPrivateMediaImageResponse({
|
||||
media,
|
||||
staticDir: resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG),
|
||||
staticDir,
|
||||
width: thumbnail ? ARCHIVE_PHOTO_THUMB_WIDTH : null,
|
||||
download,
|
||||
dispositionFilename: photo.downloadFilename,
|
||||
|
|
@ -69,5 +110,6 @@ export const GET = async (
|
|||
})
|
||||
|
||||
response.headers.set('X-Robots-Tag', 'noindex')
|
||||
if (thumbnail && !download) response.headers.set('Vary', 'Accept')
|
||||
return response
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
isArchivePhotoRemovalChannelUrl,
|
||||
} from '@/lib/archivePhotoCatalog'
|
||||
import { SPEECH_TOPICS } from '@/lib/speechFacets'
|
||||
import { ensureArchivePhotoGrade } from '@/utilities/archivePhotos/archivePhotoThumbnails'
|
||||
import { canReadArchivePhoto, payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { revalidateArchivePhotosListing } from '@/utilities/documents'
|
||||
import { purgeFaceDescriptorsForPhoto } from '@/utilities/faceIndex/faceDescriptorIndex'
|
||||
|
|
@ -294,6 +295,26 @@ const revalidateArchivePhotosListingAfterDelete: CollectionAfterDeleteHook = ({
|
|||
return doc
|
||||
}
|
||||
|
||||
/**
|
||||
* C248 — the approval seam: a photo that ENTERS `approved` gets its stored
|
||||
* grade warmed best-effort. Fire-and-forget by design — the encode is not part
|
||||
* of the publication (a failure never blocks the approval) and the route's
|
||||
* lazy heal covers whatever this loses (a CLI approving in batch exits before
|
||||
* the promise settles, a restart, a transient store error). Every other write
|
||||
* (cataloguing, face index, C246 repair) keeps the status, so it never
|
||||
* triggers a regeneration.
|
||||
*/
|
||||
const warmArchivePhotoGradeAfterChange: CollectionAfterChangeHook = ({ doc, previousDoc, req }) => {
|
||||
if (
|
||||
doc.publicationStatus === 'approved' &&
|
||||
previousDoc?.publicationStatus !== 'approved' &&
|
||||
doc.filename
|
||||
) {
|
||||
void ensureArchivePhotoGrade(req.payload, { filename: doc.filename }).catch(() => undefined)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
/**
|
||||
* C242 — a photo that stops being `approved` cannot keep answering the selfie
|
||||
* search: the anonymous descriptor rows die with the public status (and on
|
||||
|
|
@ -376,7 +397,11 @@ export const ArchivePhoto: CollectionConfig = {
|
|||
},
|
||||
hooks: {
|
||||
beforeValidate: [deriveArchivePhotoCatalogIndex, requireRemovalChannelForApproval],
|
||||
afterChange: [revalidateArchivePhotosListingAfterChange, purgeFaceDescriptorsAfterChange],
|
||||
afterChange: [
|
||||
revalidateArchivePhotosListingAfterChange,
|
||||
purgeFaceDescriptorsAfterChange,
|
||||
warmArchivePhotoGradeAfterChange,
|
||||
],
|
||||
afterDelete: [revalidateArchivePhotosListingAfterDelete, purgeFaceDescriptorsAfterDelete],
|
||||
},
|
||||
fields: [
|
||||
|
|
|
|||
|
|
@ -5,28 +5,28 @@ const SUPPORT_URL = 'https://apoiar.me/jorgesolla'
|
|||
const allyImages = [
|
||||
{
|
||||
id: 'rui',
|
||||
src: '/RUI - 2-2.avif',
|
||||
src: '/RUI - 2-2.webp',
|
||||
width: 177,
|
||||
height: 265,
|
||||
sizes: '(min-width: 1920px) 211px, (min-width: 1366px) calc(6.14vw + 93px), 177px',
|
||||
},
|
||||
{
|
||||
id: 'wagner',
|
||||
src: '/WAGNER - 2-9 final.avif',
|
||||
src: '/WAGNER - 2-9 final.webp',
|
||||
width: 177,
|
||||
height: 265,
|
||||
sizes: '(min-width: 1920px) 211px, (min-width: 1366px) calc(6.14vw + 93px), 177px',
|
||||
},
|
||||
{
|
||||
id: 'jeronimo',
|
||||
src: '/Jeronimo.avif',
|
||||
src: '/Jeronimo.webp',
|
||||
width: 177,
|
||||
height: 265,
|
||||
sizes: '(min-width: 1920px) 211px, (min-width: 1366px) calc(6.14vw + 93px), 177px',
|
||||
},
|
||||
{
|
||||
id: 'lula',
|
||||
src: '/Lula.avif',
|
||||
src: '/Lula.webp',
|
||||
width: 216,
|
||||
height: 213,
|
||||
sizes:
|
||||
|
|
@ -68,7 +68,7 @@ export const CampaignHero = () => (
|
|||
|
||||
<div className="campaign-hero-portrait absolute z-20 overflow-hidden">
|
||||
<Image
|
||||
src="/JOA00162.avif"
|
||||
src="/JOA00162.webp"
|
||||
alt="Jorge Solla, candidato a deputado federal pela Bahia"
|
||||
width={495}
|
||||
height={742}
|
||||
|
|
|
|||
35
src/lib/archivePhotoThumbnail.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* C248 — pure rules of the stored grade thumbnail of an archive photo: the
|
||||
* deterministic sibling filename and the AVIF container/quality of the
|
||||
* derivative. No I/O and no `server-only`: the public media route, the
|
||||
* generation utility, the collection hook and the backfill CLI share this
|
||||
* module, so the naming and the quality live in one place.
|
||||
*
|
||||
* The derivative is a SIBLING object of the stored original, never a second
|
||||
* row: the original keeps its deterministic key (`flickr-<id>.<ext>`) and the
|
||||
* grade is discovered by the exact derived key (`flickr-<id>-grade.avif`).
|
||||
* Nothing is ever listed to find it, so a miss can only mean "not generated
|
||||
* yet" — the route falls back on the fly and heals.
|
||||
*/
|
||||
|
||||
/** AVIF at q60 — the repo ruler for web assets (`scripts/lib/imageResize.mjs`). */
|
||||
export const ARCHIVE_PHOTO_GRADE_QUALITY = 60
|
||||
|
||||
/** The stored grade is AVIF; `image/avif` is already inline-safe in the contract. */
|
||||
export const ARCHIVE_PHOTO_GRADE_MIME_TYPE = 'image/avif'
|
||||
|
||||
/** The single extension of the derived object. */
|
||||
export const ARCHIVE_PHOTO_GRADE_EXTENSION = '.avif'
|
||||
|
||||
/**
|
||||
* `flickr-123.jpg` → `flickr-123-grade.avif`: the suffix goes BEFORE the
|
||||
* extension of the original and a name with no usable extension keeps the
|
||||
* whole name as the base. Deterministic and collision-free with the original
|
||||
* key, so the same photo always resolves to the same derived key.
|
||||
*/
|
||||
export const archivePhotoGradeFilename = (originalFilename: string): string => {
|
||||
const name = originalFilename.trim()
|
||||
const dot = name.lastIndexOf('.')
|
||||
const base = dot > 0 ? name.slice(0, dot) : name
|
||||
return `${base}-grade${ARCHIVE_PHOTO_GRADE_EXTENSION}`
|
||||
}
|
||||
|
|
@ -71,6 +71,33 @@ export const isPrivateMediaMissingError = (error: unknown): boolean => {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* C248 — AVIF negotiation for the stored grade thumbnail. The token must be
|
||||
* EXPLICIT (`image/avif`) with a positive quality: a missing header, a
|
||||
* wildcard or `image/jpeg` stays on the JPEG fallback, so every client that
|
||||
* never opted in (curl, the e2e API request context, a browser without the
|
||||
* decoder) keeps the exact answer it has today. Pure, so the route and the
|
||||
* unit spec share the classification.
|
||||
*/
|
||||
export const acceptsAvif = (acceptHeader: string | null | undefined): boolean => {
|
||||
if (typeof acceptHeader !== 'string') return false
|
||||
for (const part of acceptHeader.split(',')) {
|
||||
const [rawType, ...params] = part.split(';')
|
||||
if (rawType.trim().toLowerCase() !== 'image/avif') continue
|
||||
|
||||
let quality = 1
|
||||
for (const param of params) {
|
||||
const [rawKey, rawValue] = param.split('=')
|
||||
if (rawKey?.trim().toLowerCase() !== 'q') continue
|
||||
const parsed = Number(rawValue)
|
||||
// A malformed weight must never arm AVIF: only a real positive q does.
|
||||
quality = Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
return quality > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isInlineSafe = (mimeType: unknown): mimeType is string =>
|
||||
typeof mimeType === 'string' &&
|
||||
(PRIVATE_MEDIA_INLINE_MIME_TYPES as readonly string[]).includes(mimeType)
|
||||
|
|
|
|||
|
|
@ -223,6 +223,15 @@ export default buildConfig({
|
|||
region: mediaStorage.region,
|
||||
endpoint: mediaStorage.endpoint,
|
||||
forcePathStyle: true,
|
||||
// C246-ops (2026-10-02): Garage stores a WRONG
|
||||
// `x-amz-checksum-crc32` for multipart uploads (the storage-s3
|
||||
// adapter switches to multipart above 50 MB), so every GET of a
|
||||
// >50 MB object failed the SDK's response validation — the two
|
||||
// archive originals affected were 54 MB panoramas. WHEN_REQUIRED
|
||||
// stops the SDK from attaching upload checksums; reads still
|
||||
// validate whatever checksum an object has and the integrity
|
||||
// sweep is the honest corruption check (decode included).
|
||||
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
222
src/utilities/archivePhotos/archivePhotoThumbnails.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import 'server-only'
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
|
||||
import { ARCHIVE_PHOTO_THUMB_WIDTH } from '@/lib/archivePhotoPublicCatalog'
|
||||
import {
|
||||
ARCHIVE_PHOTO_GRADE_MIME_TYPE,
|
||||
ARCHIVE_PHOTO_GRADE_QUALITY,
|
||||
archivePhotoGradeFilename,
|
||||
} from '@/lib/archivePhotoThumbnail'
|
||||
import { isPrivateMediaMissingError } from '@/lib/privateMedia'
|
||||
import { messageOf } from '@/utilities/media/ffmpeg'
|
||||
import {
|
||||
downloadPrivateMediaToFile,
|
||||
encodePrivateImageFile,
|
||||
privateMediaObjectExists,
|
||||
resolvePrivateMediaStaticDir,
|
||||
writePrivateObject,
|
||||
} from '@/utilities/privateMedia/privateMediaResponse'
|
||||
|
||||
/**
|
||||
* C248 — the stored grade thumbnail of an archive photo: the 720px AVIF q60
|
||||
* the public grid serves when it exists. The route tries the deterministic
|
||||
* sibling key first; on a miss it answers the on-the-fly JPEG and calls
|
||||
* `ensureArchivePhotoGrade` in `after()`, so the next request hits the stored
|
||||
* derivative. The collection hook warms the same path when a photo is approved
|
||||
* and the backfill CLI covers the pre-existing archive.
|
||||
*
|
||||
* Nothing here touches the database or the original: the grade is a cache
|
||||
* entry in the same private store, discovered by exact key. Every failure is
|
||||
* classified and never thrown — the fallback is the product promise, so a
|
||||
* broken original or an unreachable store can only mean "skip", never a 500.
|
||||
*/
|
||||
|
||||
/** Concurrent generations per process: a grid miss can ask for several at once. */
|
||||
const MAX_CONCURRENT_GENERATIONS = 2
|
||||
|
||||
/** A failure is not retried on every view (the molde of C226). */
|
||||
const FAILURE_TTL_MS = 5 * 60_000
|
||||
|
||||
const GENERATION_FAILURE_FALLBACK = 'Falha ao gerar a miniatura da foto.'
|
||||
|
||||
/** What one generation pass can produce (the CLI receipt vocabulary). */
|
||||
export type ArchivePhotoGradeGeneration =
|
||||
| { status: 'generated'; filename: string; bytes: number }
|
||||
| {
|
||||
status: 'skipped'
|
||||
reason: 'no-filename' | 'missing-origin' | 'corrupt-origin'
|
||||
detail?: string
|
||||
}
|
||||
| { status: 'failed'; reason: string }
|
||||
|
||||
/** Add the probe/failure-memo results only `ensureArchivePhotoGrade` produces. */
|
||||
export type ArchivePhotoGradeOutcome =
|
||||
| ArchivePhotoGradeGeneration
|
||||
| { status: 'skipped'; reason: 'already-present' | 'recent-failure' }
|
||||
|
||||
// Process-wide state (same shape as the C226 frame job): the in-memory slot
|
||||
// gate keeps one Node server from stampeding sharp, the in-flight map
|
||||
// deduplicates concurrent requests for the same photo and the failure memo
|
||||
// stops one bad original from being re-decoded on every view.
|
||||
let activeGenerations = 0
|
||||
const waiting: Array<() => void> = []
|
||||
const inFlight = new Map<string, Promise<ArchivePhotoGradeOutcome>>()
|
||||
const failures = new Map<string, number>()
|
||||
|
||||
const rememberFailure = (gradeFilename: string): void => {
|
||||
const now = Date.now()
|
||||
for (const [key, at] of failures) {
|
||||
if (now - at >= FAILURE_TTL_MS) failures.delete(key)
|
||||
}
|
||||
failures.set(gradeFilename, now)
|
||||
}
|
||||
|
||||
const acquireGenerationSlot = async (): Promise<() => void> => {
|
||||
if (activeGenerations >= MAX_CONCURRENT_GENERATIONS) {
|
||||
await new Promise<void>((resolve) => waiting.push(resolve))
|
||||
} else {
|
||||
activeGenerations += 1
|
||||
}
|
||||
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) return
|
||||
released = true
|
||||
const next = waiting.shift()
|
||||
if (next) next()
|
||||
else activeGenerations -= 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One grade, from the stored original to the sibling AVIF. The original is
|
||||
* downloaded to a caller-owned temp file first: that is what tells a missing
|
||||
* object (honest skip, the C246 vocabulary) from a corrupt one (decode
|
||||
* failure, honest skip with the reason in the receipt) from a write failure
|
||||
* (retryable). Never throws.
|
||||
*/
|
||||
export const generateArchivePhotoGrade = async (
|
||||
payload: Payload,
|
||||
{ filename }: { filename: string | null | undefined },
|
||||
): Promise<ArchivePhotoGradeGeneration> => {
|
||||
if (!filename) return { status: 'skipped', reason: 'no-filename' }
|
||||
|
||||
const staticDir = resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG)
|
||||
const gradeFilename = archivePhotoGradeFilename(filename)
|
||||
|
||||
const release = await acquireGenerationSlot()
|
||||
let tempDir: string | null = null
|
||||
try {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'archive-photo-grade-'))
|
||||
const sourcePath = join(tempDir, 'source')
|
||||
|
||||
try {
|
||||
await downloadPrivateMediaToFile({
|
||||
media: { filename },
|
||||
staticDir,
|
||||
destinationPath: sourcePath,
|
||||
})
|
||||
} catch (error) {
|
||||
if (isPrivateMediaMissingError(error)) {
|
||||
return { status: 'skipped', reason: 'missing-origin' }
|
||||
}
|
||||
return {
|
||||
status: 'failed',
|
||||
reason: `download: ${messageOf(error, GENERATION_FAILURE_FALLBACK)}`,
|
||||
}
|
||||
}
|
||||
|
||||
let image: Buffer
|
||||
try {
|
||||
image = await encodePrivateImageFile({
|
||||
inputPath: sourcePath,
|
||||
width: ARCHIVE_PHOTO_THUMB_WIDTH,
|
||||
format: 'avif',
|
||||
quality: ARCHIVE_PHOTO_GRADE_QUALITY,
|
||||
})
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
reason: 'corrupt-origin',
|
||||
detail: messageOf(error, GENERATION_FAILURE_FALLBACK),
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await writePrivateObject({
|
||||
filename: gradeFilename,
|
||||
staticDir,
|
||||
data: image,
|
||||
mimeType: ARCHIVE_PHOTO_GRADE_MIME_TYPE,
|
||||
})
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'failed',
|
||||
reason: `upload: ${messageOf(error, GENERATION_FAILURE_FALLBACK)}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'generated', filename: gradeFilename, bytes: image.length }
|
||||
} catch (error) {
|
||||
return { status: 'failed', reason: messageOf(error, GENERATION_FAILURE_FALLBACK) }
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stored grade of a photo, generating it on a miss. The probe and
|
||||
* the generation share the in-flight map, so a grid that asks for the same
|
||||
* missing photo twice runs sharp once. Never throws: a failure degrades to the
|
||||
* route's on-the-fly fallback and is memoized for a short while.
|
||||
*/
|
||||
export const ensureArchivePhotoGrade = async (
|
||||
payload: Payload,
|
||||
{ filename }: { filename: string | null | undefined },
|
||||
): Promise<ArchivePhotoGradeOutcome> => {
|
||||
if (!filename) return { status: 'skipped', reason: 'no-filename' }
|
||||
|
||||
const gradeFilename = archivePhotoGradeFilename(filename)
|
||||
|
||||
const failedAt = failures.get(gradeFilename)
|
||||
if (failedAt !== undefined && Date.now() - failedAt < FAILURE_TTL_MS) {
|
||||
return { status: 'skipped', reason: 'recent-failure' }
|
||||
}
|
||||
|
||||
const staticDir = resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG)
|
||||
const exists = await privateMediaObjectExists({ filename: gradeFilename, staticDir }).catch(
|
||||
() => false,
|
||||
)
|
||||
if (exists) return { status: 'skipped', reason: 'already-present' }
|
||||
|
||||
const running = inFlight.get(gradeFilename)
|
||||
if (running) return running
|
||||
|
||||
const generation = generateArchivePhotoGrade(payload, { filename })
|
||||
.then((result) => {
|
||||
if (result.status !== 'generated') rememberFailure(gradeFilename)
|
||||
return result
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
rememberFailure(gradeFilename)
|
||||
console.warn(
|
||||
`[archive-photo-grade] ${gradeFilename}: ${messageOf(error, GENERATION_FAILURE_FALLBACK)}`,
|
||||
)
|
||||
const reason = messageOf(error, GENERATION_FAILURE_FALLBACK)
|
||||
return { status: 'failed' as const, reason }
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight.delete(gradeFilename)
|
||||
})
|
||||
|
||||
inFlight.set(gradeFilename, generation)
|
||||
return generation
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@ import 'server-only'
|
|||
|
||||
import type { Config } from '@/payload-types'
|
||||
|
||||
import { GetObjectCommand, HeadObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
|
||||
import { createReadStream, createWriteStream } from 'fs'
|
||||
import { stat } from 'fs/promises'
|
||||
import { mkdir, rename, rm, stat, writeFile } from 'fs/promises'
|
||||
import path from 'path'
|
||||
import type { Payload } from 'payload'
|
||||
import { getRangeRequestInfo } from 'payload/internal'
|
||||
|
|
@ -170,6 +170,77 @@ const openPrivateObject = async ({
|
|||
: openLocalObject({ staticDir, filename, rangeHeader })
|
||||
}
|
||||
|
||||
/**
|
||||
* C248 — existence probe of one private object in whichever store is
|
||||
* configured (S3 HEAD or local `stat`). Never throws: an unreachable store
|
||||
* reads as "not there", so the caller falls back to the on-the-fly path and
|
||||
* the generation is retried later; the worst case is redundant, idempotent
|
||||
* work, never a broken response.
|
||||
*/
|
||||
export const privateMediaObjectExists = async ({
|
||||
filename,
|
||||
staticDir,
|
||||
}: {
|
||||
filename: string
|
||||
staticDir: string
|
||||
}): Promise<boolean> => {
|
||||
try {
|
||||
const storage = privateMediaStorage()
|
||||
if (storage) {
|
||||
await storage.client.send(new HeadObjectCommand({ Bucket: storage.bucket, Key: filename }))
|
||||
return true
|
||||
}
|
||||
|
||||
await stat(resolveLocalPath(staticDir, filename))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C248 — writes one derived object to whichever store is configured. On the
|
||||
* local disk the bytes land through a same-directory temp file + rename, so a
|
||||
* crash never leaves a half-written artifact at the canonical key; on S3 the
|
||||
* PUT is atomic by contract. The key and the MIME ride explicitly — there is
|
||||
* no upload row behind a derived object.
|
||||
*/
|
||||
export const writePrivateObject = async ({
|
||||
filename,
|
||||
staticDir,
|
||||
data,
|
||||
mimeType,
|
||||
}: {
|
||||
filename: string
|
||||
staticDir: string
|
||||
data: Buffer
|
||||
mimeType: string
|
||||
}): Promise<void> => {
|
||||
const storage = privateMediaStorage()
|
||||
if (storage) {
|
||||
await storage.client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: storage.bucket,
|
||||
Key: filename,
|
||||
Body: data,
|
||||
ContentType: mimeType,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = resolveLocalPath(staticDir, filename)
|
||||
await mkdir(path.dirname(filePath), { recursive: true })
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
|
||||
try {
|
||||
await writeFile(tempPath, data)
|
||||
await rename(tempPath, filePath)
|
||||
} catch (error) {
|
||||
await rm(tempPath, { force: true }).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const notFound = (): Response =>
|
||||
new Response(null, { status: 404, headers: { 'Cache-Control': PRIVATE_MEDIA_CACHE_CONTROL } })
|
||||
|
||||
|
|
@ -219,6 +290,86 @@ export const buildPrivateMediaResponse = async ({
|
|||
}
|
||||
}
|
||||
|
||||
export type PrivateMediaImageFormat = 'jpeg' | 'avif'
|
||||
|
||||
/** The resize the public grid has always served on the fly (C233). */
|
||||
const FALLBACK_JPEG_QUALITY = 80
|
||||
|
||||
/**
|
||||
* The shared sharp chain: `failOn: 'error'` keeps a truncated source from
|
||||
* becoming a half-decoded image and `rotate()` honors the EXIF orientation the
|
||||
* grid always did. `source` is the local input path, or `undefined` for the
|
||||
* stream path (the caller pipes into the returned transformer).
|
||||
*/
|
||||
const imageEncoder = (
|
||||
source: string | undefined,
|
||||
{ width, format, quality }: { width: number; format: PrivateMediaImageFormat; quality: number },
|
||||
): sharp.Sharp => {
|
||||
// sharp rejects `sharp(undefined, options)`; the stream path omits the input
|
||||
// entirely (the caller pipes into the returned transformer).
|
||||
const transformer = (source ? sharp(source, { failOn: 'error' }) : sharp({ failOn: 'error' }))
|
||||
.rotate()
|
||||
.resize({ width, withoutEnlargement: true })
|
||||
return format === 'avif' ? transformer.avif({ quality }) : transformer.jpeg({ quality })
|
||||
}
|
||||
|
||||
/**
|
||||
* C248 — encodes one already-downloaded local image (the stored grade
|
||||
* generation). The temp file the caller owns is what tells a missing object
|
||||
* from a corrupt one, so this side is only the decode+encode.
|
||||
*/
|
||||
export const encodePrivateImageFile = async ({
|
||||
inputPath,
|
||||
width,
|
||||
format,
|
||||
quality,
|
||||
}: {
|
||||
inputPath: string
|
||||
width: number
|
||||
format: PrivateMediaImageFormat
|
||||
quality: number
|
||||
}): Promise<Buffer> => imageEncoder(inputPath, { width, format, quality }).toBuffer()
|
||||
|
||||
/**
|
||||
* C233/C248 — encodes one image straight from the private store (the public
|
||||
* grid's on-the-fly path). The stream never becomes a temp file: sharp's
|
||||
* constructor takes no stream, so the documented path is piping into the
|
||||
* instance and draining its output.
|
||||
*/
|
||||
const encodePrivateMediaImage = async ({
|
||||
media,
|
||||
staticDir,
|
||||
width,
|
||||
format,
|
||||
quality,
|
||||
}: {
|
||||
media: PrivateMediaFile
|
||||
staticDir: string
|
||||
width: number
|
||||
format: PrivateMediaImageFormat
|
||||
quality: number
|
||||
}): Promise<Buffer> => {
|
||||
const filename = media.filename
|
||||
if (!filename) throw new Error('Mídia privada sem nome de arquivo.')
|
||||
|
||||
const opened = await openPrivateObject({ filename, staticDir, rangeHeader: null })
|
||||
if (!(opened.body instanceof Readable)) {
|
||||
throw new Error(`Objeto privado sem stream: ${filename}`)
|
||||
}
|
||||
|
||||
const encoded = imageEncoder(undefined, { width, format, quality })
|
||||
const source = opened.body
|
||||
const resized = new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
encoded.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
encoded.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
encoded.on('error', reject)
|
||||
source.on('error', reject)
|
||||
})
|
||||
source.pipe(encoded)
|
||||
return resized
|
||||
}
|
||||
|
||||
/**
|
||||
* C233 — one image artifact, optionally resized for a public grid. With no
|
||||
* `width` it delegates to `buildPrivateMediaResponse` (range, stored MIME,
|
||||
|
|
@ -256,27 +407,13 @@ export const buildPrivateMediaImageResponse = async ({
|
|||
if (!filename) return notFound()
|
||||
|
||||
try {
|
||||
const opened = await openPrivateObject({ filename, staticDir, rangeHeader: null })
|
||||
if (!(opened.body instanceof Readable)) {
|
||||
throw new Error(`Objeto privado sem stream: ${filename}`)
|
||||
}
|
||||
|
||||
// sharp's constructor takes no stream; the documented stream path is
|
||||
// piping into the instance and draining its output.
|
||||
const transformer = sharp({ failOn: 'error' })
|
||||
.rotate()
|
||||
.resize({ width, withoutEnlargement: true })
|
||||
.jpeg({ quality: 80 })
|
||||
const source = opened.body
|
||||
const resized = new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
transformer.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
transformer.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
transformer.on('error', reject)
|
||||
source.on('error', reject)
|
||||
const image = await encodePrivateMediaImage({
|
||||
media,
|
||||
staticDir,
|
||||
width,
|
||||
format: 'jpeg',
|
||||
quality: FALLBACK_JPEG_QUALITY,
|
||||
})
|
||||
source.pipe(transformer)
|
||||
const image = await resized
|
||||
|
||||
return new Response(new Uint8Array(image), {
|
||||
status: 200,
|
||||
|
|
|
|||
|
|
@ -356,7 +356,12 @@ test.describe('Frontend Álbum de Fotos (C233)', () => {
|
|||
`${BASE_URL}/fotos/${approvedFeira}/midia?tamanho=grade`,
|
||||
)
|
||||
expect(thumbnailResponse.status()).toBe(200)
|
||||
// The API request context sends `Accept: */*` — never an explicit
|
||||
// `image/avif` — so the JPEG fallback is the deterministic answer here
|
||||
// (C248); `Vary: Accept` is the negotiation contract pinned.
|
||||
expect(thumbnailResponse.headers()['content-type']).toBe('image/jpeg')
|
||||
// Next merges its own RSC/router tokens into `Vary`; ours must be there.
|
||||
expect(thumbnailResponse.headers()['vary']).toContain('Accept')
|
||||
expect(thumbnailResponse.headers()['x-robots-tag']).toBe('noindex')
|
||||
expect(thumbnailResponse.headers()['cache-control']).toContain('no-store')
|
||||
|
||||
|
|
|
|||
|
|
@ -104,11 +104,22 @@ const statusOf = async (id: number): Promise<string> => {
|
|||
}
|
||||
|
||||
const approve = async (id: number): Promise<void> => {
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id,
|
||||
data: { publicationStatus: 'approved' },
|
||||
overrideAccess: true,
|
||||
// The approval guard reads the shared global; set the channel and approve
|
||||
// under the global's exclusive lease, so a parallel spec's teardown cannot
|
||||
// flip the channel between the two writes (the flake the C248 spec exposed
|
||||
// when the album specs grew).
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, async () => {
|
||||
await payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: true, removalChannelUrl: CHANNEL },
|
||||
overrideAccess: true,
|
||||
})
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id,
|
||||
data: { publicationStatus: 'approved' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +147,10 @@ describe('archive photo integrity (C246)', () => {
|
|||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
await setChannel(null)
|
||||
// The channel is left configured on purpose (the face specs' pattern):
|
||||
// tearing it down here would race the approvals of every sibling album
|
||||
// spec sharing the global; the specs that exercise the absent-channel
|
||||
// state restore it themselves.
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,16 @@ vi.mock('next/cache', () => ({
|
|||
unstable_cache: (fn: unknown) => fn,
|
||||
}))
|
||||
|
||||
vi.mock('next/server', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('next/server')>()
|
||||
// C248 — the route heals the grade with `after()`, which requires a request
|
||||
// scope; the generation itself is exercised in its own spec.
|
||||
return { ...actual, after: () => undefined }
|
||||
})
|
||||
|
||||
import { GET as getArchivePhotoMedia } from '@/app/(frontend)/fotos/[id]/midia/route'
|
||||
import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto'
|
||||
import { archivePhotoGradeFilename } from '@/lib/archivePhotoThumbnail'
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
getApprovedArchivePhotoById,
|
||||
|
|
@ -23,6 +31,7 @@ import {
|
|||
hasPublishedArchivePhotos,
|
||||
} from '@/utilities/archivePhotos/archivePhotoReads'
|
||||
import { ingestArchivePhoto } from '@/utilities/flickr/archivePhotoIngest'
|
||||
import { resolvePrivateMediaStaticDir } from '@/utilities/privateMedia/privateMediaResponse'
|
||||
|
||||
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
|
@ -129,14 +138,33 @@ describe('public photo album reads (C233)', () => {
|
|||
})
|
||||
|
||||
afterAll(async () => {
|
||||
const staticDir = resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG)
|
||||
for (const flickrId of createdFlickrIds) {
|
||||
// The approval hook warms the C248 grade as a sibling object; the delete
|
||||
// below removes the original, so the derived file is cleaned here.
|
||||
const photo = await payload.find({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { flickrId: { equals: flickrId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const filename = photo.docs[0]?.filename
|
||||
if (filename) {
|
||||
await rm(join(staticDir, archivePhotoGradeFilename(filename)), { force: true }).catch(
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
await payload.delete({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { flickrId: { equals: flickrId } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
await closeAlbumAndClearChannel()
|
||||
// The channel is left configured on purpose (the face specs' pattern):
|
||||
// tearing it down here would race the approvals of every sibling album
|
||||
// spec sharing the global; the tests that exercise the closed/absent
|
||||
// states restore the open state themselves.
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
|
|
@ -302,6 +330,7 @@ describe('public photo album reads (C233)', () => {
|
|||
expect(thumbnail.status).toBe(200)
|
||||
expect(thumbnail.headers.get('Content-Type')).toBe('image/jpeg')
|
||||
expect(thumbnail.headers.get('X-Robots-Tag')).toBe('noindex')
|
||||
expect(thumbnail.headers.get('Vary')).toBe('Accept')
|
||||
|
||||
const download = await getArchivePhotoMedia(
|
||||
new Request(`http://localhost/fotos/${approved.id}/midia?download=1`),
|
||||
|
|
|
|||
278
tests/int/archivePhotoThumbnails.int.spec.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { afterMock } = vi.hoisted(() => ({ afterMock: vi.fn() }))
|
||||
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: () => undefined,
|
||||
revalidateTag: vi.fn(),
|
||||
unstable_cache: (fn: unknown) => fn,
|
||||
}))
|
||||
|
||||
vi.mock('next/server', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('next/server')>()
|
||||
// The route heals the grade with `after()`, which requires a request scope;
|
||||
// the generation itself is exercised directly below. The call is captured
|
||||
// here so the wiring (and its absence on a hit) is pinned.
|
||||
return { ...actual, after: afterMock }
|
||||
})
|
||||
|
||||
import { GET as getArchivePhotoMedia } from '@/app/(frontend)/fotos/[id]/midia/route'
|
||||
import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto'
|
||||
import { archivePhotoGradeFilename } from '@/lib/archivePhotoThumbnail'
|
||||
import config from '@/payload.config'
|
||||
import {
|
||||
ensureArchivePhotoGrade,
|
||||
generateArchivePhotoGrade,
|
||||
} from '@/utilities/archivePhotos/archivePhotoThumbnails'
|
||||
import { ingestArchivePhoto } from '@/utilities/flickr/archivePhotoIngest'
|
||||
import { resolvePrivateMediaStaticDir } from '@/utilities/privateMedia/privateMediaResponse'
|
||||
|
||||
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import { PHOTO_ALBUM_LEASE_KEY, withExclusiveTestDatabaseLease } from '../helpers/testDatabaseLease'
|
||||
|
||||
// C248 — the stored grade thumbnail over the real Payload `teqo_test` and the
|
||||
// real local storage: generation/idempotence/failure classification, the
|
||||
// sibling-key contract and the route matrix (stored AVIF on `Accept`, JPEG
|
||||
// fallback otherwise, miss heals in the background).
|
||||
|
||||
let payload: Payload
|
||||
const createdFlickrIds = new Set<string>()
|
||||
const tempDirs: string[] = []
|
||||
|
||||
installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const CHANNEL = 'https://jorgessolla.example.org/remocao'
|
||||
|
||||
const record = (
|
||||
flickrId: string,
|
||||
overrides: Partial<ArchivePhotoImport> = {},
|
||||
): ArchivePhotoImport => ({
|
||||
flickrId,
|
||||
sourceUrl: `https://www.flickr.com/photos/depjorgesolla/${flickrId}/`,
|
||||
owner: '12345678@N00',
|
||||
license: '0',
|
||||
title: `Foto ${flickrId}`,
|
||||
description: null,
|
||||
tags: [],
|
||||
takenAt: '2025-03-14 10:20:30',
|
||||
postedAt: '2025-03-15T10:00:00.000Z',
|
||||
albums: [],
|
||||
geo: null,
|
||||
exif: [],
|
||||
originalUrl: `https://live.staticflickr.com/65535/${flickrId}_abcdef_o.jpg`,
|
||||
originalKind: 'original',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createPhoto = async (): Promise<{ id: number; flickrId: string; filename: string }> => {
|
||||
const flickrId = `c248-${randomUUID().slice(0, 8)}`
|
||||
createdFlickrIds.add(flickrId)
|
||||
const dir = await mkdtemp(join(tmpdir(), 'archive-photo-thumb-'))
|
||||
tempDirs.push(dir)
|
||||
const filePath = join(dir, `flickr-${flickrId}.jpg`)
|
||||
await writeFile(filePath, ARCHIVE_PHOTO_JPEG_BYTES)
|
||||
const created = await ingestArchivePhoto(payload, record(flickrId), { filePath })
|
||||
if (created.status !== 'created' || !created.filename) {
|
||||
throw new Error(`fixture not created: ${created.status}`)
|
||||
}
|
||||
return { id: created.id, flickrId, filename: created.filename }
|
||||
}
|
||||
|
||||
const setChannel = async (removalChannelUrl: string): Promise<void> => {
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: true, removalChannelUrl },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const approve = async (id: number): Promise<void> => {
|
||||
// The approval guard reads the shared global; set the channel and approve
|
||||
// under the global's exclusive lease, so a parallel spec's teardown cannot
|
||||
// flip the channel between the two writes.
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, async () => {
|
||||
await payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: true, removalChannelUrl: CHANNEL },
|
||||
overrideAccess: true,
|
||||
})
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id,
|
||||
data: { publicationStatus: 'approved' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const staticDir = (): string => resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG)
|
||||
|
||||
const gradePathOf = (filename: string): string =>
|
||||
join(staticDir(), archivePhotoGradeFilename(filename))
|
||||
|
||||
const exists = async (path: string): Promise<boolean> =>
|
||||
stat(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
|
||||
// The approval hook warms the grade fire-and-forget; awaiting `ensure` lands on
|
||||
// the same in-flight generation, so the file is settled when it resolves.
|
||||
const settleGrade = async (filename: string): Promise<void> => {
|
||||
await ensureArchivePhotoGrade(payload, { filename })
|
||||
}
|
||||
|
||||
describe('archive photo grade thumbnails (C248)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
await setChannel(CHANNEL)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
afterMock.mockClear()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const flickrId of createdFlickrIds) {
|
||||
const photo = await payload.find({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { flickrId: { equals: flickrId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
const filename = photo.docs[0]?.filename
|
||||
if (filename) {
|
||||
await rm(gradePathOf(filename), { force: true }).catch(() => undefined)
|
||||
}
|
||||
await payload.delete({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { flickrId: { equals: flickrId } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
// The channel is left open on purpose: tearing it down here would race the
|
||||
// approvals of the sibling album specs (the shared global).
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
it('generates the sibling AVIF once and converges on re-run', async () => {
|
||||
const photo = await createPhoto()
|
||||
const gradePath = gradePathOf(photo.filename)
|
||||
await rm(gradePath, { force: true })
|
||||
|
||||
const generated = await generateArchivePhotoGrade(payload, { filename: photo.filename })
|
||||
expect(generated.status).toBe('generated')
|
||||
if (generated.status === 'generated') {
|
||||
expect(generated.filename).toBe(archivePhotoGradeFilename(photo.filename))
|
||||
expect(generated.bytes).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
// ISO-BMFF container with the AVIF brand: a real AVIF came out of sharp.
|
||||
const bytes = await stat(gradePath)
|
||||
expect(bytes.size).toBeGreaterThan(0)
|
||||
const file = await readFile(gradePath)
|
||||
expect(file.subarray(4, 8).toString('latin1')).toBe('ftyp')
|
||||
expect(file.subarray(8, 12).toString('latin1')).toContain('avi')
|
||||
|
||||
// The existing grade is never regenerated; the ensure probe converges.
|
||||
expect(await ensureArchivePhotoGrade(payload, { filename: photo.filename })).toMatchObject({
|
||||
status: 'skipped',
|
||||
reason: 'already-present',
|
||||
})
|
||||
})
|
||||
|
||||
it('warms the grade when the photo enters approval', async () => {
|
||||
const photo = await createPhoto()
|
||||
await rm(gradePathOf(photo.filename), { force: true })
|
||||
|
||||
await approve(photo.id)
|
||||
await settleGrade(photo.filename)
|
||||
|
||||
expect(await exists(gradePathOf(photo.filename))).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies a missing original as an honest skip', async () => {
|
||||
const photo = await createPhoto()
|
||||
await rm(join(staticDir(), photo.filename), { force: true })
|
||||
|
||||
expect(await generateArchivePhotoGrade(payload, { filename: photo.filename })).toMatchObject({
|
||||
status: 'skipped',
|
||||
reason: 'missing-origin',
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies an undecodable original as an honest skip', async () => {
|
||||
const photo = await createPhoto()
|
||||
await writeFile(join(staticDir(), photo.filename), Buffer.from('not an image'))
|
||||
|
||||
expect(await generateArchivePhotoGrade(payload, { filename: photo.filename })).toMatchObject({
|
||||
status: 'skipped',
|
||||
reason: 'corrupt-origin',
|
||||
})
|
||||
})
|
||||
|
||||
it('serves the stored AVIF to clients that accept it and the JPEG fallback otherwise', async () => {
|
||||
const photo = await createPhoto()
|
||||
await approve(photo.id)
|
||||
await settleGrade(photo.filename)
|
||||
|
||||
const avif = await getArchivePhotoMedia(
|
||||
new Request(`http://localhost/fotos/${photo.id}/midia?tamanho=grade`, {
|
||||
headers: { accept: 'image/avif,image/webp,*/*' },
|
||||
}),
|
||||
{ params: Promise.resolve({ id: String(photo.id) }) },
|
||||
)
|
||||
expect(avif.status).toBe(200)
|
||||
expect(avif.headers.get('Content-Type')).toBe('image/avif')
|
||||
expect(avif.headers.get('Vary')).toBe('Accept')
|
||||
expect(avif.headers.get('Cache-Control')).toContain('no-store')
|
||||
expect(avif.headers.get('X-Robots-Tag')).toBe('noindex')
|
||||
expect(avif.headers.get('Content-Disposition')).toContain('inline')
|
||||
// A stored hit never schedules the heal.
|
||||
expect(afterMock).not.toHaveBeenCalled()
|
||||
|
||||
const jpeg = await getArchivePhotoMedia(
|
||||
new Request(`http://localhost/fotos/${photo.id}/midia?tamanho=grade`),
|
||||
{ params: Promise.resolve({ id: String(photo.id) }) },
|
||||
)
|
||||
expect(jpeg.status).toBe(200)
|
||||
expect(jpeg.headers.get('Content-Type')).toBe('image/jpeg')
|
||||
expect(jpeg.headers.get('Vary')).toBe('Accept')
|
||||
})
|
||||
|
||||
it('falls back to the on-the-fly JPEG when the grade is missing', async () => {
|
||||
const photo = await createPhoto()
|
||||
await approve(photo.id)
|
||||
await settleGrade(photo.filename)
|
||||
await rm(gradePathOf(photo.filename), { force: true })
|
||||
|
||||
const response = await getArchivePhotoMedia(
|
||||
new Request(`http://localhost/fotos/${photo.id}/midia?tamanho=grade`, {
|
||||
headers: { accept: 'image/avif' },
|
||||
}),
|
||||
{ params: Promise.resolve({ id: String(photo.id) }) },
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toBe('image/jpeg')
|
||||
expect(response.headers.get('Vary')).toBe('Accept')
|
||||
// The miss schedules the lazy heal for the next request.
|
||||
expect(afterMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
200
tests/int/archivePublish.int.spec.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: () => undefined,
|
||||
revalidateTag: vi.fn(),
|
||||
unstable_cache: (fn: unknown) => fn,
|
||||
}))
|
||||
|
||||
import { ARCHIVE_PHOTO_SLUG, type ArchivePhotoImport } from '@/lib/archivePhoto'
|
||||
import config from '@/payload.config'
|
||||
import { ingestArchivePhoto } from '@/utilities/flickr/archivePhotoIngest'
|
||||
import {
|
||||
inspectPrivateMediaObject,
|
||||
resolvePrivateMediaStaticDir,
|
||||
} from '@/utilities/privateMedia/privateMediaResponse'
|
||||
|
||||
import {
|
||||
formatArchivePublishReport,
|
||||
summarizeArchivePublishResults,
|
||||
} from '../../scripts/lib/archivePublishPlan.mjs'
|
||||
import { runArchivePublishBatch } from '../../scripts/publish-archive-photos.mjs'
|
||||
|
||||
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
|
||||
import { PHOTO_ALBUM_LEASE_KEY, withExclusiveTestDatabaseLease } from '../helpers/testDatabaseLease'
|
||||
|
||||
// C249 — the publish preflight over the real Payload `teqo_test`: a clean draft
|
||||
// is approved, a draft whose object is corrupt/removed stays `draft` and is
|
||||
// named in the receipt (stage + reason), and the plan mode classifies without
|
||||
// writing. The batch seam receives its own queue, so no other spec's drafts are
|
||||
// ever approved by this run.
|
||||
|
||||
let payload: Payload
|
||||
const createdFlickrIds = new Set<string>()
|
||||
const tempDirs: string[] = []
|
||||
|
||||
const CHANNEL = 'https://jorgesolla.example.org/remocao'
|
||||
|
||||
const record = (
|
||||
flickrId: string,
|
||||
overrides: Partial<ArchivePhotoImport> = {},
|
||||
): ArchivePhotoImport => ({
|
||||
flickrId,
|
||||
sourceUrl: `https://www.flickr.com/photos/depjorgesolla/${flickrId}/`,
|
||||
owner: '12345678@N00',
|
||||
license: '0',
|
||||
title: `Foto ${flickrId}`,
|
||||
description: 'Registro do mandato',
|
||||
tags: [],
|
||||
takenAt: '2025-03-14 10:20:30',
|
||||
postedAt: '2025-03-15T10:00:00.000Z',
|
||||
albums: [],
|
||||
geo: null,
|
||||
exif: [],
|
||||
originalUrl: `https://live.staticflickr.com/65535/${flickrId}_abcdef_o.jpg`,
|
||||
originalKind: 'original',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const tempDir = async (prefix: string): Promise<string> => {
|
||||
const dir = await mkdtemp(join(tmpdir(), prefix))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
const createPhoto = async (): Promise<{ id: number; flickrId: string; filename: string }> => {
|
||||
const flickrId = `c249-${randomUUID().slice(0, 8)}`
|
||||
createdFlickrIds.add(flickrId)
|
||||
const dir = await tempDir('archive-publish-')
|
||||
const filePath = join(dir, `flickr-${flickrId}.jpg`)
|
||||
await writeFile(filePath, ARCHIVE_PHOTO_JPEG_BYTES)
|
||||
const created = await ingestArchivePhoto(payload, record(flickrId), { filePath })
|
||||
if (created.status !== 'created' || !created.filename) {
|
||||
throw new Error(`fixture not created: ${created.status}`)
|
||||
}
|
||||
return { id: created.id, flickrId, filename: created.filename }
|
||||
}
|
||||
|
||||
const staticDir = (): string => resolvePrivateMediaStaticDir(payload, ARCHIVE_PHOTO_SLUG)
|
||||
const storedPath = (filename: string): string => join(staticDir(), filename)
|
||||
|
||||
const runBatch = (queue: Array<{ id: number; filename?: string | null }>, apply: boolean) =>
|
||||
runArchivePublishBatch({
|
||||
payload,
|
||||
queue,
|
||||
inspect: inspectPrivateMediaObject,
|
||||
staticDir: staticDir(),
|
||||
apply,
|
||||
log: () => undefined,
|
||||
})
|
||||
|
||||
const statusOf = async (id: number): Promise<string> => {
|
||||
const doc = await payload.findByID({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
return doc.publicationStatus
|
||||
}
|
||||
|
||||
const setChannel = async (removalChannelUrl: string | null): Promise<void> => {
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: removalChannelUrl !== null, removalChannelUrl },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('archive publish preflight (C249)', () => {
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
await setChannel(CHANNEL)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const flickrId of createdFlickrIds) {
|
||||
await payload.delete({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { flickrId: { equals: flickrId } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
await setChannel(null)
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
it('approves a draft whose object inspects clean', async () => {
|
||||
const photo = await createPhoto()
|
||||
|
||||
const results = await runBatch([photo], true)
|
||||
|
||||
expect(results).toEqual([{ photoId: photo.id, status: 'approved' }])
|
||||
expect(await statusOf(photo.id)).toBe('approved')
|
||||
})
|
||||
|
||||
it('keeps a corrupt object out of the batch and names the decode stage in the receipt', async () => {
|
||||
const photo = await createPhoto()
|
||||
await writeFile(storedPath(photo.filename), Buffer.from('isto não é uma imagem'))
|
||||
|
||||
const results = await runBatch([photo], true)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
const result = results[0]
|
||||
if (!result) throw new Error('batch result missing')
|
||||
expect(result).toMatchObject({
|
||||
photoId: photo.id,
|
||||
status: 'skippedBroken',
|
||||
stage: 'decode',
|
||||
})
|
||||
expect(typeof result.reason).toBe('string')
|
||||
if (typeof result.reason === 'string') {
|
||||
expect(result.reason.length).toBeGreaterThan(0)
|
||||
}
|
||||
expect(await statusOf(photo.id)).toBe('draft')
|
||||
|
||||
const summary = summarizeArchivePublishResults(results)
|
||||
expect(summary.approved).toBe(0)
|
||||
expect(summary.failed).toBe(0)
|
||||
expect(summary.skippedBrokenCount).toBe(1)
|
||||
const receipt = formatArchivePublishReport({
|
||||
mode: 'apply',
|
||||
target: 'test',
|
||||
queue: { totalDrafts: 1, items: 1 },
|
||||
summary,
|
||||
}).join('\n')
|
||||
expect(receipt).toContain(`! foto ${photo.id} fora do lote (decode):`)
|
||||
expect(receipt).toContain('puladas (quebradas): 1')
|
||||
})
|
||||
|
||||
it('keeps a draft with a removed object out of the batch as missing', async () => {
|
||||
const photo = await createPhoto()
|
||||
await rm(storedPath(photo.filename), { force: true })
|
||||
|
||||
const results = await runBatch([photo], true)
|
||||
|
||||
expect(results).toEqual([
|
||||
{ photoId: photo.id, status: 'skippedBroken', stage: 'missing', reason: 'objeto ausente' },
|
||||
])
|
||||
expect(await statusOf(photo.id)).toBe('draft')
|
||||
})
|
||||
|
||||
it('classifies a clean draft without writing in plan mode', async () => {
|
||||
const photo = await createPhoto()
|
||||
|
||||
const results = await runBatch([photo], false)
|
||||
|
||||
expect(results).toEqual([{ photoId: photo.id, status: 'eligible' }])
|
||||
expect(await statusOf(photo.id)).toBe('draft')
|
||||
})
|
||||
})
|
||||
|
|
@ -8,12 +8,12 @@ import {
|
|||
archiveIntegrityReportStamp,
|
||||
formatArchiveIntegrityRepairReport,
|
||||
formatArchiveIntegrityScanReport,
|
||||
mapWithConcurrency,
|
||||
parseArchiveIntegrityCliArgs,
|
||||
parseArchiveIntegrityOnly,
|
||||
summarizeArchiveIntegrityRepair,
|
||||
summarizeArchiveIntegrityScan,
|
||||
} from '../../scripts/lib/archiveIntegrityPlan.mjs'
|
||||
import { mapWithConcurrency } from '../../scripts/lib/cli.mjs'
|
||||
|
||||
// C246 — the pure parser/summary of `pnpm archive:integrity`: the read-only
|
||||
// default, the bounded-concurrency map and the honest accounting of the sweep
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import { tmpdir } from 'node:os'
|
|||
import { basename, join } from 'node:path'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { repairArchivePhotoFromSource } from '../../scripts/lib/archiveIntegrityRepair.mjs'
|
||||
import {
|
||||
ARCHIVE_INTEGRITY_DOWNLOAD_TIMEOUT_MS,
|
||||
repairArchivePhotoFromSource,
|
||||
} from '../../scripts/lib/archiveIntegrityRepair.mjs'
|
||||
import { FlickrApiError } from '../../scripts/lib/flickrApi.mjs'
|
||||
|
||||
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
|
||||
|
|
@ -112,6 +115,10 @@ describe('repairArchivePhotoFromSource (C246)', () => {
|
|||
expect(result.sha256).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(callOrder).toEqual(['source', 'download', 'repair', 'inspect'])
|
||||
expect(basename(vi.mocked(deps.repair).mock.calls[0][1].filePath)).toBe('flickr-123.jpg')
|
||||
// A >50MB Flickr original does not fit the old 2 min ceiling on a slow link.
|
||||
expect(vi.mocked(deps.download).mock.calls[0][0].timeoutMs).toBe(
|
||||
ARCHIVE_INTEGRITY_DOWNLOAD_TIMEOUT_MS,
|
||||
)
|
||||
})
|
||||
|
||||
it('withdraws when Flickr refuses the source (photo gone)', async () => {
|
||||
|
|
|
|||
41
tests/unit/archivePhotoThumbnail.unit.spec.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ARCHIVE_PHOTO_GRADE_EXTENSION,
|
||||
ARCHIVE_PHOTO_GRADE_MIME_TYPE,
|
||||
ARCHIVE_PHOTO_GRADE_QUALITY,
|
||||
archivePhotoGradeFilename,
|
||||
} from '@/lib/archivePhotoThumbnail'
|
||||
|
||||
// C248 — the pure naming rules of the stored grade: the deterministic sibling
|
||||
// key the route, the generation utility and the backfill CLI share. The
|
||||
// extension swap is the whole contract; a collision with the original key or a
|
||||
// nondeterministic name would silently break the discovery by exact key.
|
||||
|
||||
describe('archive photo grade naming (C248)', () => {
|
||||
it('derives the sibling key by replacing the original extension', () => {
|
||||
expect(archivePhotoGradeFilename('flickr-123.jpg')).toBe('flickr-123-grade.avif')
|
||||
expect(archivePhotoGradeFilename('flickr-123.jpeg')).toBe('flickr-123-grade.avif')
|
||||
expect(archivePhotoGradeFilename('flickr-123.png')).toBe('flickr-123-grade.avif')
|
||||
expect(archivePhotoGradeFilename('flickr-abc_def-9.JPG')).toBe('flickr-abc_def-9-grade.avif')
|
||||
})
|
||||
|
||||
it('keeps the whole name as the base when there is no usable extension', () => {
|
||||
expect(archivePhotoGradeFilename('flickr-123')).toBe('flickr-123-grade.avif')
|
||||
expect(archivePhotoGradeFilename('.hidden')).toBe('.hidden-grade.avif')
|
||||
expect(archivePhotoGradeFilename('flickr-1.2.jpg')).toBe('flickr-1.2-grade.avif')
|
||||
})
|
||||
|
||||
it('never derives the original key itself', () => {
|
||||
const original = 'flickr-999.jpg'
|
||||
expect(archivePhotoGradeFilename(original)).not.toBe(original)
|
||||
})
|
||||
|
||||
it('pins the AVIF container and the repo quality ruler', () => {
|
||||
expect(ARCHIVE_PHOTO_GRADE_MIME_TYPE).toBe('image/avif')
|
||||
expect(ARCHIVE_PHOTO_GRADE_EXTENSION).toBe('.avif')
|
||||
expect(ARCHIVE_PHOTO_GRADE_QUALITY).toBe(60)
|
||||
})
|
||||
})
|
||||
|
|
@ -50,6 +50,39 @@ describe('summarizeArchivePublishResults', () => {
|
|||
expect(summary.failed).toBe(1)
|
||||
expect(summary.failures).toEqual([{ photoId: 3, error: 'canal ausente' }])
|
||||
})
|
||||
|
||||
it('keeps a preflight skip out of the failures and names its stage', () => {
|
||||
const summary = summarizeArchivePublishResults([
|
||||
{ photoId: 1, status: 'approved' },
|
||||
{ photoId: 4, status: 'skippedBroken', stage: 'missing', reason: 'objeto ausente' },
|
||||
{ photoId: 5, status: 'skippedBroken', stage: 'decode', reason: 'Input buffer contains' },
|
||||
{ photoId: 6, status: 'failed', error: 'canal ausente' },
|
||||
])
|
||||
|
||||
expect(summary.approved).toBe(1)
|
||||
expect(summary.failed).toBe(1)
|
||||
expect(summary.failures).toEqual([{ photoId: 6, error: 'canal ausente' }])
|
||||
expect(summary.skippedBroken).toEqual([
|
||||
{ photoId: 4, stage: 'missing', reason: 'objeto ausente' },
|
||||
{ photoId: 5, stage: 'decode', reason: 'Input buffer contains' },
|
||||
])
|
||||
expect(summary.skippedBrokenCount).toBe(2)
|
||||
})
|
||||
|
||||
it('treats the plan-mode eligible census and the download stage honestly', () => {
|
||||
const summary = summarizeArchivePublishResults([
|
||||
{ photoId: 1, status: 'eligible' },
|
||||
{ photoId: 2, status: 'skippedBroken', stage: 'download', reason: 'checksum divergente' },
|
||||
])
|
||||
|
||||
expect(summary.approved).toBe(0)
|
||||
expect(summary.failed).toBe(0)
|
||||
expect(summary.failures).toEqual([])
|
||||
expect(summary.skippedBroken).toEqual([
|
||||
{ photoId: 2, stage: 'download', reason: 'checksum divergente' },
|
||||
])
|
||||
expect(summary.skippedBrokenCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatting', () => {
|
||||
|
|
@ -63,7 +96,7 @@ describe('formatting', () => {
|
|||
summary: { approved: 0, failed: 0, failures: [] },
|
||||
}).join('\n')
|
||||
expect(plan).toContain('modo: plan')
|
||||
expect(plan).toContain('drafts elegíveis: 6492')
|
||||
expect(plan).toContain('fila: 6492 de 6492 draft(s)')
|
||||
|
||||
const apply = formatArchivePublishReport({
|
||||
mode: 'apply',
|
||||
|
|
@ -74,4 +107,38 @@ describe('formatting', () => {
|
|||
expect(apply).toContain('aprovadas: 2 | falharam: 1')
|
||||
expect(apply).toContain('✗ foto 7: sem arquivo')
|
||||
})
|
||||
|
||||
it('names every skipped broken draft in the receipt lines (C249)', () => {
|
||||
const apply = formatArchivePublishReport({
|
||||
mode: 'apply',
|
||||
target: 'host/db',
|
||||
queue: { totalDrafts: 10, items: 4 },
|
||||
summary: {
|
||||
approved: 2,
|
||||
failed: 0,
|
||||
failures: [],
|
||||
skippedBroken: [
|
||||
{ photoId: 8, stage: 'missing', reason: 'objeto ausente' },
|
||||
{ photoId: 9, stage: 'decode', reason: 'Input buffer contains unsupported image format' },
|
||||
],
|
||||
skippedBrokenCount: 2,
|
||||
},
|
||||
}).join('\n')
|
||||
expect(apply).toContain('aprovadas: 2 | falharam: 0 | puladas (quebradas): 2')
|
||||
expect(apply).toContain('! foto 8 fora do lote (missing): objeto ausente')
|
||||
expect(apply).toContain('! foto 9 fora do lote (decode): Input buffer contains')
|
||||
|
||||
const plan = formatArchivePublishReport({
|
||||
mode: 'plan',
|
||||
target: 'host/db',
|
||||
queue: { totalDrafts: 10, items: 4 },
|
||||
summary: {
|
||||
skippedBroken: [{ photoId: 8, stage: 'missing', reason: 'objeto ausente' }],
|
||||
skippedBrokenCount: 1,
|
||||
},
|
||||
}).join('\n')
|
||||
expect(plan).toContain('drafts quebrados (fora do lote): 1')
|
||||
expect(plan).toContain('! foto 8 fora do lote (missing): objeto ausente')
|
||||
expect(plan).not.toContain('aprovadas:')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
130
tests/unit/archiveThumbnailPlan.unit.spec.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ARCHIVE_THUMBNAIL_DEFAULT_CONCURRENCY,
|
||||
ARCHIVE_THUMBNAIL_DEFAULT_OUT_DIR,
|
||||
archiveThumbnailEligibleRows,
|
||||
parseArchiveThumbnailCliArgs,
|
||||
parseArchiveThumbnailOnly,
|
||||
summarizeArchiveThumbnailProbe,
|
||||
summarizeArchiveThumbnailRun,
|
||||
} from '../../scripts/lib/archiveThumbnailPlan.mjs'
|
||||
|
||||
// C248 — the pure parser/accounting of `pnpm archive:thumbnails`: the
|
||||
// read-only default, the approved-only queue and the honest summary of the
|
||||
// generation run (already present is a skip, not work; only real failures
|
||||
// deserve a retry).
|
||||
|
||||
describe('parseArchiveThumbnailCliArgs', () => {
|
||||
it('defaults to the read-only plan and parses the options', () => {
|
||||
expect(parseArchiveThumbnailCliArgs([])).toEqual({
|
||||
apply: false,
|
||||
verify: false,
|
||||
only: null,
|
||||
limit: null,
|
||||
concurrency: ARCHIVE_THUMBNAIL_DEFAULT_CONCURRENCY,
|
||||
out: ARCHIVE_THUMBNAIL_DEFAULT_OUT_DIR,
|
||||
help: false,
|
||||
})
|
||||
|
||||
const apply = parseArchiveThumbnailCliArgs([
|
||||
'--apply',
|
||||
'--only',
|
||||
'80,83,80',
|
||||
'--limit',
|
||||
'5',
|
||||
'--concurrency',
|
||||
'2',
|
||||
'--out',
|
||||
'x',
|
||||
])
|
||||
expect(apply).toEqual({
|
||||
apply: true,
|
||||
verify: false,
|
||||
only: [80, 83],
|
||||
limit: 5,
|
||||
concurrency: 2,
|
||||
out: 'x',
|
||||
help: false,
|
||||
})
|
||||
|
||||
expect(parseArchiveThumbnailCliArgs(['--verify']).verify).toBe(true)
|
||||
expect(parseArchiveThumbnailCliArgs(['--help']).help).toBe(true)
|
||||
expect(parseArchiveThumbnailCliArgs(['-h']).help).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed on bad values and unknown args', () => {
|
||||
expect(() => parseArchiveThumbnailCliArgs(['--only'])).toThrow(/faltou valor/)
|
||||
expect(() => parseArchiveThumbnailCliArgs(['--only', ''])).toThrow(/--only/)
|
||||
expect(() => parseArchiveThumbnailCliArgs(['--only', '80,abc'])).toThrow(/--only/)
|
||||
expect(() => parseArchiveThumbnailCliArgs(['--only', '0'])).toThrow(/--only/)
|
||||
expect(() => parseArchiveThumbnailCliArgs(['--limit', '0'])).toThrow(/--limit/)
|
||||
expect(() => parseArchiveThumbnailCliArgs(['--limit', 'x'])).toThrow(/--limit/)
|
||||
expect(() => parseArchiveThumbnailCliArgs(['--concurrency', '0'])).toThrow(/--concurrency/)
|
||||
expect(() => parseArchiveThumbnailCliArgs(['--out', '../etc'])).toThrow(/--out/)
|
||||
expect(() => parseArchiveThumbnailCliArgs(['--apply=1'])).toThrow(/argumento desconhecido/)
|
||||
})
|
||||
|
||||
it('refuses the contradictory modes together', () => {
|
||||
expect(() => parseArchiveThumbnailCliArgs(['--apply', '--verify'])).toThrow(
|
||||
/mutuamente exclusivos/,
|
||||
)
|
||||
})
|
||||
|
||||
it('parses --only without duplicates and refuses an empty selection', () => {
|
||||
expect(parseArchiveThumbnailOnly('80, 83,80')).toEqual([80, 83])
|
||||
expect(() => parseArchiveThumbnailOnly(' ')).toThrow(/--only/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('archiveThumbnailEligibleRows', () => {
|
||||
it('keeps only approved rows with a stored filename', () => {
|
||||
const rows = [
|
||||
{ id: 1, flickrId: '1', filename: 'a.jpg', publicationStatus: 'approved' },
|
||||
{ id: 2, flickrId: '2', filename: 'b.jpg', publicationStatus: 'draft' },
|
||||
{ id: 3, flickrId: '3', filename: 'c.jpg', publicationStatus: 'removed' },
|
||||
{ id: 4, flickrId: '4', filename: null, publicationStatus: 'approved' },
|
||||
{ id: 5, flickrId: '5', filename: null, publicationStatus: 'draft' },
|
||||
]
|
||||
|
||||
expect(archiveThumbnailEligibleRows(rows)).toEqual([
|
||||
{ id: 1, flickrId: '1', filename: 'a.jpg' },
|
||||
])
|
||||
expect(archiveThumbnailEligibleRows([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeArchiveThumbnailProbe', () => {
|
||||
it('counts present, missing and names the missing ids', () => {
|
||||
const summary = summarizeArchiveThumbnailProbe([
|
||||
{ id: 1, flickrId: '1', present: true },
|
||||
{ id: 2, flickrId: '2', present: false },
|
||||
{ id: 3, flickrId: '3', present: false },
|
||||
])
|
||||
expect(summary).toEqual({ eligible: 3, present: 1, missing: 2, missingIds: [2, 3] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeArchiveThumbnailRun', () => {
|
||||
it('separates generated, skipped-by-reason and failures', () => {
|
||||
const summary = summarizeArchiveThumbnailRun([
|
||||
{ id: 1, outcome: { status: 'generated', bytes: 100 } },
|
||||
{ id: 2, outcome: { status: 'generated', bytes: 50 } },
|
||||
{ id: 3, outcome: { status: 'skipped', reason: 'already-present' } },
|
||||
{ id: 4, outcome: { status: 'skipped', reason: 'missing-origin' } },
|
||||
{ id: 5, outcome: { status: 'skipped', reason: 'corrupt-origin' } },
|
||||
{ id: 6, outcome: { status: 'failed', reason: 'upload: timeout' } },
|
||||
])
|
||||
|
||||
expect(summary).toEqual({
|
||||
eligible: 6,
|
||||
generated: 2,
|
||||
skipped: 3,
|
||||
failed: 1,
|
||||
bytes: 150,
|
||||
skippedByReason: { 'already-present': 1, 'missing-origin': 1, 'corrupt-origin': 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -44,6 +44,19 @@ describe('CampaignHero', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('serve masters WebP — AVIF fica fora do otimizador (libheif)', () => {
|
||||
const { container } = render(<CampaignHero />)
|
||||
|
||||
const sources = Array.from(container.querySelectorAll('img')).map(
|
||||
(image) => image.getAttribute('src') ?? '',
|
||||
)
|
||||
expect(sources.length).toBeGreaterThan(0)
|
||||
|
||||
for (const source of sources) {
|
||||
expect(source.endsWith('.webp'), source).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('expõe as provas e as fotos com nomes acessíveis', () => {
|
||||
render(<CampaignHero />)
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ describe('archive:publish write guards (C242)', () => {
|
|||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('ARCHIVE_PUBLISH_CONFIRM')
|
||||
expect(result.stdout).toContain('--limit')
|
||||
expect(result.stdout).toContain('preflight de integridade')
|
||||
})
|
||||
|
||||
it('refuses --apply on a production target without the intent flag', () => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ const MAX_HEIGHT = 2400
|
|||
|
||||
const LOCAL_IMAGE_REF = /src[=:]\s*['"](\/[^'"`]+\.(?:avif|webp|png|jpe?g))['"]/g
|
||||
|
||||
// `image:` covers the home card data (`CampaignCarouselItem.image`), which is
|
||||
// also rendered through next/image — the dimension budget above only walks
|
||||
// `src` literals on purpose (the card masters predate the budget), but the
|
||||
// AVIF ban below must catch both spellings.
|
||||
const LOCAL_IMAGE_LITERAL = /(?:src|image)[=:]\s*['"](\/[^'"`]+\.(?:avif|webp|png|jpe?g))['"]/g
|
||||
|
||||
const publicFile = (src: string) =>
|
||||
fileURLToPath(new URL(`../../public${decodeURIComponent(src)}`, import.meta.url))
|
||||
|
||||
|
|
@ -24,14 +30,14 @@ const localHeroAssets = [
|
|||
...new Set([...campaignHeroSource.matchAll(LOCAL_IMAGE_REF)].map(([, src]) => src)),
|
||||
]
|
||||
|
||||
const collectSourceImageRefs = () => {
|
||||
const collectSourceImageRefs = (pattern: RegExp) => {
|
||||
const refs = new Set<string>()
|
||||
const srcDir = fileURLToPath(new URL('../../src', import.meta.url))
|
||||
|
||||
for (const entry of readdirSync(srcDir, { recursive: true, withFileTypes: true })) {
|
||||
if (!entry.isFile() || !/\.(?:ts|tsx)$/.test(entry.name)) continue
|
||||
const code = readFileSync(join(entry.parentPath, entry.name), 'utf8')
|
||||
for (const [, src] of code.matchAll(LOCAL_IMAGE_REF)) refs.add(src)
|
||||
for (const [, src] of code.matchAll(pattern)) refs.add(src)
|
||||
}
|
||||
|
||||
return refs
|
||||
|
|
@ -49,7 +55,9 @@ describe('entrega das imagens do hero (perf do /_next/image)', () => {
|
|||
})
|
||||
|
||||
it('mantém todo asset local entregue via next/image dentro do orçamento', async () => {
|
||||
const refs = [...collectSourceImageRefs()].filter((src) => existsSync(publicFile(src)))
|
||||
const refs = [...collectSourceImageRefs(LOCAL_IMAGE_REF)].filter((src) =>
|
||||
existsSync(publicFile(src)),
|
||||
)
|
||||
expect(refs.length).toBeGreaterThan(0)
|
||||
|
||||
for (const src of refs) {
|
||||
|
|
@ -59,6 +67,24 @@ describe('entrega das imagens do hero (perf do /_next/image)', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('não entrega AVIF pelo otimizador (libheif: GHSA-2xp9-vwfh-vxw4 e travamento por chave)', () => {
|
||||
const refs = [...collectSourceImageRefs(LOCAL_IMAGE_LITERAL)]
|
||||
expect(refs.length).toBeGreaterThan(0)
|
||||
|
||||
for (const src of refs) {
|
||||
expect(src.endsWith('.avif'), src).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('não mantém AVIF em public/ — o otimizador decodifica qualquer arquivo local por URL', () => {
|
||||
const publicDir = fileURLToPath(new URL('../../public', import.meta.url))
|
||||
const avifFiles = readdirSync(publicDir, { recursive: true, withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.avif'))
|
||||
.map((entry) => entry.name)
|
||||
|
||||
expect(avifFiles).toEqual([])
|
||||
})
|
||||
|
||||
it('configura TTL longo para os derivados do otimizador', () => {
|
||||
const nextConfig = readFileSync(
|
||||
fileURLToPath(new URL('../../next.config.mjs', import.meta.url)),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
acceptsAvif,
|
||||
isPrivateMediaMissingError,
|
||||
PRIVATE_MEDIA_FALLBACK_MIME_TYPE,
|
||||
privateMediaContentDisposition,
|
||||
|
|
@ -120,6 +121,28 @@ describe('private media response rules (C193/C199)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('private media AVIF negotiation (C248)', () => {
|
||||
it('accepts only an explicit image/avif token with a positive quality', () => {
|
||||
expect(acceptsAvif('image/avif')).toBe(true)
|
||||
expect(acceptsAvif('IMAGE/AVIF')).toBe(true)
|
||||
expect(acceptsAvif('text/html, image/avif')).toBe(true)
|
||||
expect(acceptsAvif('image/avif;q=0.5')).toBe(true)
|
||||
expect(acceptsAvif('image/avif; q=0.5, image/webp')).toBe(true)
|
||||
expect(acceptsAvif('image/avif;q=0')).toBe(false)
|
||||
expect(acceptsAvif('image/avif;q=0.000')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the JPEG fallback for headers that never opted in', () => {
|
||||
expect(acceptsAvif(null)).toBe(false)
|
||||
expect(acceptsAvif(undefined)).toBe(false)
|
||||
expect(acceptsAvif('')).toBe(false)
|
||||
expect(acceptsAvif('*/*')).toBe(false)
|
||||
expect(acceptsAvif('image/*')).toBe(false)
|
||||
expect(acceptsAvif('image/jpeg, image/webp')).toBe(false)
|
||||
expect(acceptsAvif('image/avifjunk')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('private media missing-object classification (C246)', () => {
|
||||
it('recognizes the S3 and local-disk absence shapes', () => {
|
||||
expect(isPrivateMediaMissingError({ name: 'NoSuchKey' })).toBe(true)
|
||||
|
|
|
|||