Skip to content

prd: cms sync db ↔ fs robustness pass #8

@leandronsp

Description

@leandronsp

Date: 2026-04-30
Status: Draft
Type: Refinamento (não-feature)

Problema

O ciclo editar → salvar → publicar no CMS DevTUI suja o git status do repo leandronsp.com com modificações que não correspondem ao que o usuário escreveu. Drafts viram .md no repo. Despublicar deixa .md órfão. Slug auto-gerado fica desconectado do título. Frontmatter slug: é ignorado. O usuário precisa de um fluxo onde o git diff do repo do blog reflete exatamente a intenção do autor, antes de começar a escrever o próximo artigo.

Invariantes do sistema (estado correto)

Estes são os contratos que o sistema deve respeitar pós-fix. Cada um vira um teste.

  1. Drafts vivem só no DB. posts/ nunca contém .md de artigo com status=draft no DB.
  2. .md no FS = conteúdo do editor, byte-a-byte. Nenhum campo de frontmatter é injetado, removido, reordenado ou normalizado pela camada de sync.
  3. Despublicar remove o .md do FS. Imediato, no db::unpublish.
  4. Renomear slug remove o .md antigo do FS. Vale para auto-slug e slug manual.
  5. Slug auto-gerado se reconcilia com título não-Untitled. Sempre que o save detecta is_auto_slug(slug) && title != "Untitled", renomeia. Não depende de title ter mudado nesta sessão.
  6. Slug é editável via frontmatter. Save lê slug: do frontmatter; se válido e disponível, faz update_slug + remove .md antigo.
  7. Sync é idempotente sem efeito colateral. Rodar sync_to_filesystem N vezes consecutivas com DB inalterado não modifica nenhum byte em posts/.
  8. Sync nunca toca artigos não-gerenciados. .md no posts/ sem linha correspondente no DB sobrevive a qualquer sync.

Mapa de bugs → arquivos → invariantes que falham

# Bug Local Quebra invariante
B1 sync_to_filesystem itera todos os status, escreve drafts src/editor/db.rs:407 I1
B2 inject_status reescreve frontmatter na hora do sync src/editor/db.rs:409 + db.rs:470 I2
B3 sync_to_filesystem nunca remove .md (não cumpre docstring) src/editor/db.rs:398-419 I3, I4
B4 db::unpublish muda status no DB mas não toca FS src/editor/db.rs:224 I3
B5 Auto-slug rename só dispara quando title != article.title src/editor/mod.rs:159-164 I5
B6 slug: no frontmatter é ignorado src/editor/mod.rs:159-164 I6
B7 Teste sync_writes_draft_with_status_in_frontmatter (db.rs:905) congela o bug src/editor/db.rs:905 I1 (teste contra invariante)

Itens fora do escopo deste PRD: preprocess do engine (confirmado in-memory pelo scout), imagens órfãs com prefix obsoleto, round-trip de assets. Não violam invariantes desta lista.

Sequência de fixes (TDD, ordem importa)

Cada item é um commit. Cada um adiciona um teste que prova a invariante antes de mudar produção. RED → GREEN → confirma a invariante.

Fix 1 — Drafts saem do FS (B1, B7)

  • Teste RED: sync_does_not_write_drafts_to_fs — cria draft, roda sync, asserta que posts/<slug>.md não existe.
  • Teste a reverter: sync_writes_draft_with_status_in_frontmatter (db.rs:905). O nome dele descreve o bug. Substitui por sync_removes_md_when_article_unpublished.
  • Mudança: sync_to_filesystem filtra status=published no loop principal. Para drafts (e ex-published que viraram draft), remove posts/<slug>.md se existir.

Fix 2 — Sync escreve conteúdo cru (B2)

  • Teste RED: sync_writes_content_byte_for_byte — cria artigo published com conteúdo X (sem status: no frontmatter), roda sync, asserta que posts/<slug>.md é exatamente X.
  • Mudança: remover chamada a inject_status. db.rs:409 passa a escrever article.content direto.
  • Cuidado: o engine hoje filtra drafts via status: draft no frontmatter. Como Fix 1 garante que drafts não chegam ao FS, o filtro do engine vira no-op para blogs gerenciados. Sem mudança no engine.

Fix 3 — db::unpublish remove .md (B4)

  • Teste RED: unpublish_removes_md_from_fs — publica + sync (cria .md), unpublish, asserta .md não existe.
  • Mudança: db::unpublish recebe posts_dir: &Path, faz remove_file após o UPDATE. Defesa em profundidade, não espera próximo build.

Fix 4 — Auto-slug se reconcilia mesmo sem mudança de título (B5)

  • Teste RED: save_reconciles_stale_auto_slug — cria artigo com title="Real Title" e força slug="untitled" no DB; chama save com conteúdo igual ao DB exceto adicionar uma linha; asserta que slug virou real-title após save.
  • Mudança: em edit_article (mod.rs:159-164), separar duas decisões:
    • update_title: só quando title no frontmatter difere do DB (mantém)
    • rename_auto_slug: roda quando is_auto_slug(article.slug) && title != "Untitled", independente de title ter mudado

Fix 5 — slug: no frontmatter vira fonte de verdade (B6)

  • Teste RED: save_updates_slug_from_frontmatter — artigo com slug foo, edit insere slug: bar no frontmatter, save. Asserta DB slug=bar e posts/foo.md removido.
  • Teste RED: save_rejects_slug_collision — artigo A (slug bar) existe; artigo B tenta slug: bar. Asserta DB de B inalterado.
  • Mudança: edit_articleslug do frontmatter. Se presente e diferente do DB:
    • colisão → log + skip (mantém slug atual)
    • ok → update_slug + remove .md antigo
  • Ordem com Fix 4: se ambos disparariam, frontmatter ganha. Adicionar teste frontmatter_slug_wins_over_auto_rename.

Fix 6 — Sync idempotente confirmado (I7)

  • Teste: sync_is_byte_idempotent — roda sync 3x, asserta mtimes não mudam após a 1ª.
  • Mudança: o "skip when content unchanged" de db.rs:411-414 já cobre isso uma vez Fix 2 esteja em pé. Teste confirma.

Fix 7 — Não tocar não-gerenciados (I8, regression test)

  • Já existe sync_leaves_orphan_md_files_untouched. Conferir que ainda passa após Fix 1-6. Se quebrar, é regressão.

Out of scope

  • Back-fill de artigos existentes (id 77 e similares). O slug do 77 vai se reconciliar sozinho na próxima vez que o editor for aberto (Fix 4 cobre).
  • Limpeza de imagens órfãs com prefix obsoleto.
  • Round-trip de assets images/ uploads/ no build.
  • Mudanças no engine (build.rs, config.rs). O engine fica intocado.

Critérios de aceite

  1. Após cargo test, todos os testes acima passam.
  2. Após make blog.build.leandronsp.com em DB inalterado, git -C ../leandronsp.com status --short mostra zero arquivos modificados em articles/.
  3. Criar draft, salvar com título "Foo Bar": DB tem slug=foo-bar, posts/foo-bar.md não existe (draft), git status em ../leandronsp.com limpo.
  4. Publicar o draft acima: posts/foo-bar.md aparece com conteúdo idêntico ao que está no DB. Despublicar: posts/foo-bar.md desaparece.
  5. Em artigo já publicado, mudar slug: no frontmatter para custom-url e salvar: posts/foo-bar.md desaparece, posts/custom-url.md aparece com conteúdo novo.
  6. Artigo id 77: na próxima abertura/save, slug vira path-to-vibe-engineering no DB. Como segue draft, nada vai pro FS.

Estimativa

7 commits, todos pequenos (≤80 linhas cada incluindo teste). Sequência total: ~half-day de pair work, considerando que cada fix começa pelo RED.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1: highHigh priorityprdProduct Requirements Document

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions