# Plano 08 — Services Headless e Persistência

> **Milestone:** M2 · **Depende de:** 07 · **Habilita:** 09–15
> **Objetivo:** oferecer capacidades transversais **agnósticas, headless e sempre disponíveis**
> (sem acoplar módulos), com implementação padrão e componente de configuração/uso quando cabível;
> e uma base de dados sólida, segura e **desacoplada da UI**.

---

## 1. O que é um Service (ADR-015)

- **Agnóstico e headless**: não conhece módulos; sem rotas/telas próprias. Expõe **contrato** em
  `src/services/{Nome}/Contracts` e declara implementação + metadados no `ServiceProvider.php` da raiz
  (convenção ADR-015, absorvida pelo Core).
- **Sempre disponível**: registrado uma vez (singleton/provider) e usável por **qualquer módulo**,
  de forma independente — não é "sob demanda".
- **Implementação padrão** (uma forma canônica) + **guia** de uso, para não variar por módulo.
- **Componente de configuração/uso** (quando cabível): componente Blade reutilizável e acessível
  que o módulo inclui (ex.: SEO). **Não** é rota/tela do service.
- **Depende de `core`**; **nunca** de `modules`.
- Exemplos: Media, **Seo**, Search, Theme, Audit, Notifications, Files.

## 2. Serviços do V7 (núcleo mínimo)

| Service | Contrato (service) | Responsabilidade | Componente de config/uso |
|---|---|---|---|
| `Media` | `MediaStorage`, `ImageOptimizer` | upload seguro (**Flysystem, disco `s3`** — RustFS/AWS, ADR-030), variantes (AVIF/WebP), `alt_text` obrigatório | `<x-service-media::input>` |
| `RichText` | `RichTextRenderer` (`toHtml`/`toText`) | **AST JSON (TipTap) → HTML semântico / texto** (ADR-028); sem HTML livre | `<x-service-richtext::editor>` |
| `Seo` | `SeoMetadata`, `SeoRenderer` | title/meta/canonical, JSON-LD, `robots`/`sitemap` | `<x-service-seo::input>` |
| `Audit` | `AuditWriter` | trilha imutável, WORM opcional (implementação no Core) | — |
| ~~`Search`~~ | — | **backlog** (ADR-033/034 + simplicidade) | — |
| ~~`Theme`~~ | — | **backlog** — tema como dado entra na riqueza do layout (ADR-016/F3) | — |
| ~~`Notifications`~~ | — | **backlog** — Laravel Notifications nativo quando necessário | — |

> **Revisão (ADR-033/ADR-034 + diretriz de simplicidade):** o caminho crítico do F2 cobre apenas
> `Media`, `Seo`, `RichText` e `Audit`. `Search`, `Theme`, `Notifications`, `LandingSections`,
> `FeedRSS`, `ExcelImporter` e `DigitalSignature` vão para o **backlog** (não bloquear o núcleo).

Regra: o Service **não conhece** módulos; o módulo **não conhece** o Service concreto, apenas a
interface (resolvida pelo Core).

### 2.1. Exemplo canônico: SEO (referência `sucesu-cms`)

O `sucesu-cms/V2/Services/SEO/` é o modelo: service headless + provider + migrations + componente
de configuração que qualquer módulo inclui no formulário.

```
src/services/Seo/
├── ServiceProvider.php                  # raiz: Core\Contracts\Service (contracts()+config())
├── Contracts/SeoBinder.php              # contrato (interface) consumido pelos módulos
├── SEOService.php                       # implementa SeoBinder (estado, bind($model), addSchema())
├── Migrations/                          # tabelas + chaves de configuração
└── View/seo-input.blade.php             # componente de configuração/uso (a11y)
```

- O módulo grava SEO no próprio registro (`metadata['seo']`, JSONB) via
  `<x-service-seo::input :model="$noticia" />`.
- O portal renderiza via contrato `SeoMetadata`/`SeoRenderer` (o módulo não conhece a implementação).

## 3. Persistência e Migrations (ADR-007 + ADR-017)

### 3.1. Regras de dados (valem desde a primeira migration)

- **Só o necessário.** Cada coluna é dado de domínio; nada de decoração, duplicação ou
  valor derivável.
- **`jsonb` quando cabível.** Conteúdo rico como AST (`corpo`), metadados extensíveis
  (`metadados`) e apresentação (`apresentacao`) — a UI evolui sem migration.
- **Nunca HTML/CSS/estilo no banco.** O editor grava estrutura atômica; a view renderiza.
- **Apresentação fora do schema.** "destacado", "largura total", cor, ícone e ordem vivem
  no `services/Theme`/`SlotConfig` (JSONB) ou no Enum de preset — **não** como colunas.
- **Tipos/integridade (PG 18).** PK `uuid` (**`uuidv7()` nativo do banco**); `timestamptz`; `numeric`
  para dinheiro; `text` com limite na app/`CHECK` (não `varchar(n)`); `NOT NULL` por
  padrão; FK com `onDelete` explícito; `softDeletes` em entidades editoriais; índices
  nomeados `tabela_coluna_idx`.
- **Busca nativa em PG 18.** Coluna `busca tsvector` por **coluna gerada**
  (`GENERATED ALWAYS AS (...) STORED`) + índice GIN — o PG 18 suporta colunas geradas
  (trigger só quando a expressão não couber numa coluna).
- **Expand & contract.** Alteração de coluna em 3 passos (add nullable → backfill em
  lotes → `CHECK ... NOT VALID` + `VALIDATE` + `SET NOT NULL`); remoção em 2 releases (o
  segundo só renomeia para `backup_*`). No PG 18, `ADD COLUMN ... DEFAULT` (não volátil) não
  reescreve a tabela.
- **Operação.** `CREATE INDEX CONCURRENTLY` **fora** do helper transacional; `EXPLAIN
  (ANALYZE, BUFFERS)` para validar índices; `pg_stat_statements` ligado; papéis de banco
  com privilégio mínimo.
- **Auditoria imutável** via trigger + tabela particionada.
- **LGPD pragmática**: usar o **cast `encrypted` nativo** do Laravel para PII real
  (CPF/endereço/contato). Blind index (função **BLAKE3**, ADR-029) **só** se houver busca sobre o campo
  cifrado. **Sem cast/criptografia customizada.**
- **Dados de referência na migration/implementação**, de forma idempotente
  (`insertOrIgnore`/`upsert`/`firstOrCreate`). Seeders são **só demonstração** (ADR-041, vivem em
  `src/demonstration/{Module}/`) e nunca pré-requisito de funcionamento.

### 3.2. Template canônico de migration

Referência-base: `sucesu-cms/V2/Modules/Publicacoes/Noticias/Migrations/`
(transação + `lock_timeout`, log, `backup_*`, `metadata jsonb`, `search_vector` GIN,
chaves de importação). O V7 mantém esses padrões e corrige o que prendia dados à UI.

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;

/** @see wiki/specs/10-migracoes-e-dados.md */
return new class extends Migration
{
    private const TABLE = 'noticia';

    public function up(): void
    {
        $this->safely(function (): void {
            Schema::create(self::TABLE, function (Blueprint $table): void {
                $table->uuid('id')->primary();            // UUIDv7 gerado na app
                $table->text('titulo');                    // text + CHECK na app (não varchar(n))
                $table->text('slug')->unique();
                $table->text('resumo')->nullable();        // texto puro (lista/SEO)
                $table->jsonb('corpo')->nullable();        // AST estruturada (nunca HTML)
                $table->jsonb('metadados')->nullable();    // SEO + extensões
                $table->uuid('autor_id')->nullable();
                $table->uuid('categoria_id')->nullable();
                $table->text('situacao')->default('rascunho');
                $table->timestampTz('publicado_em')->nullable();
                $table->timestampsTz();
                $table->softDeletesTz();

                $table->index('situacao', 'noticia_situacao_idx');
                $table->index('publicado_em', 'noticia_publicado_em_idx');
                $table->index('autor_id', 'noticia_autor_id_idx');
            });

            // Full-text em PG 18: coluna gerada + índice GIN (sem trigger).
            DB::statement(<<<'SQL'
                ALTER TABLE noticia ADD COLUMN busca tsvector
                GENERATED ALWAYS AS (
                    setweight(to_tsvector('portuguese'::regconfig, coalesce(titulo,'')), 'A') ||
                    setweight(to_tsvector('portuguese'::regconfig, coalesce(resumo,'')), 'B')
                ) STORED
            SQL);
            DB::statement('CREATE INDEX noticia_busca_idx ON noticia USING GIN (busca)');
        }, 'criar tabela noticia');
    }

    public function down(): void
    {
        // down() NUNCA apaga: preserva como backup_<tabela>_<timestamp>.
        $this->safely(fn () => $this->renameToBackup(self::TABLE), 'preservar tabela noticia');
    }

    /** Transação + lock_timeout + log estruturado. */
    private function safely(callable $operation, string $action): void
    {
        DB::beginTransaction();
        DB::statement("SET LOCAL lock_timeout = '3s'");

        try {
            $operation();
            DB::commit();
            Log::info('migration.ok', ['module' => 'noticia', 'action' => $action]);
        } catch (\Throwable $e) {
            DB::rollBack();
            Log::error('migration.fail', ['module' => 'noticia', 'action' => $action, 'error' => $e->getMessage()]);
            throw $e;
        }
    }

    private function renameToBackup(string $table): void
    {
        if (! Schema::hasTable($table)) {
            return;
        }
        Schema::rename($table, sprintf('backup_%s_%s', $table, now()->format('Y_m_d_His')));
    }
};
```

### 3.3. O que herdamos e o que melhoramos

| Padrão do `sucesu-cms` | Decisão no V7 |
|---|---|
| Transação + `SET lock_timeout = '3s'` | **Mantido e obrigatório** |
| `down()` renomeia para `backup_*` | **Mantido e obrigatório** (nunca `DROP`) |
| `metadata jsonb` | **Mantido e ampliado** (`corpo`, `metadados`, `apresentacao`) |
| `content` como `text` guardando AST | **Melhorado**: `jsonb` de verdade |
| `is_highlighted` / `is_super_highlighted` (UI como coluna) | **Removido do schema**: vai para `apresentacao` JSONB / `SlotConfig` |
| Coluna gerada `search_vector` | **Preferida** no PG 18 (`GENERATED ... STORED`); trigger só quando necessário |
| `v2_news_import_keys` (idempotência) | **Mantido** como padrão de importação |
| Log de migração | **Mantido**, em JSON estruturado |

## 4. Atividades

- [ ] Publicar `wiki/specs/10-migracoes-e-dados.md` (template + checklist acima).
- [ ] Publicar `wiki/specs/12-postgres-18.md` (tipos, migrações seguras e tuning do PG 18).
- [ ] `tools/migration-check` (proíbe `DROP`/`dropColumn` no `down()`, flag em
      `*_html`/`*_css`/`*_style` e conteúdo rico fora de `jsonb`) + testes do gate.
- [ ] Contrato no service (`src/services/{Nome}/Contracts`) + `ServiceProvider.php` (raiz) com
      `contracts()`/`config()`, descoberto por convenção e absorvido pelo Core (ADR-015).
- [ ] `src/services/Media`: upload seguro, checksum, variantes AVIF/WebP e `alt_text` obrigatório —
      disco **`s3`** (RustFS local ↔ AWS S3 por `.env`, ADR-030).
- [ ] `src/services/Seo`: DTO `SeoData`, renderer, JSON-LD por tipo de conteúdo.
- [ ] `src/services/Search`: índice Postgres (trigger `tsvector` + GIN) + DTO de query.
- [ ] `src/services/RichText`: `ContentASTParser` + renderers concretos por nó/mark, `toHtml`/
      `toText` (ADR-028); `toMarkdown` quando houver demanda; sem trait/abstract/genérico.
- [ ] `src/services/Theme`: leitura/escrita de tokens e `apresentacao`; exporta CSS vars.
- [ ] `src/services/Audit`: `AuditWriter` + `core_audit_logs` + trigger de imutabilidade.
- [ ] PII: cast `encrypted` nativo; blind index (função) só se houver busca cifrada.
- [ ] Migrations base (identidade, roles/permissions, módulos/flags, auditoria) no template.
- [ ] Dados de referência criados idempotentemente nas migrations (roles, permissões,
      presets, categorias de sistema, flags).
- [ ] Demo seeder separado (`php artisan db:seed`), nunca invocado por `make up`/produção.

## 5. Testes e evidências

- [ ] Teste de migration: rodar `up()` e `down()` preserva os dados em `backup_*` (nada perdido).
- [ ] Teste: `migrate:fresh` sem seeder deixa o sistema utilizável (dados de referência vêm
      das migrations) e rodar 2× não duplica (idempotência).
- [ ] `tools/migration-check` falha em `dropColumn` no `down()` e em coluna `*_html`.
- [ ] Teste de PG 18: migrations válidas no PG 18; `uuidv7()` e coluna gerada de busca
      funcionam; busca usa o índice GIN.
- [ ] Teste: o conteúdo rico é `jsonb` e não contém HTML/estilo renderizado.
- [ ] Teste de contrato `RichText`: cada nó/mark do TipTap gera a tag semântica esperada no
      `toHtml()`; `toText()` remove formatação e gera resumo limpo.
- [ ] Teste de upload rejeita MIME falso, tamanho excessivo e ausência de `alt_text`.
- [ ] Teste de variantes gera AVIF/WebP e mantém o original.
- [ ] Teste de auditoria: `UPDATE`/`DELETE` na tabela de auditoria falham por trigger.
- [ ] Teste de PII: cast `encrypted` faz ida-e-volta; blind index (se usado) é determinístico.
- [ ] Teste de busca: indexa em A, encontra consultando de B (via Core), sem acoplamento.
- [ ] `make reset` idempotente (rodar 2× sem duplicar dados).

## 6. Definition of Done

- [ ] Serviços mínimos com contrato, fake e teste de contrato.
- [ ] Toda migration segue o ADR-017: transação+lock, `down()` sem perda, `jsonb` quando
      cabível, **zero** HTML/CSS/estilo e zero flag de UI no schema.
- [ ] Persistência com UUIDv7, auditoria imutável e LGPD onde há PII.
- [ ] Nenhum dado necessário à operação depende de seeder; seeds são exclusivamente demo.
- [ ] Um módulo usa um Service **sem** importar sua implementação.

## 7. Riscos

| Risco | Mitigação |
|---|---|
| Criptografia atrapalhar busca | Blind index só nos campos realmente sensíveis |
| Service virar "Deus" | Um contrato pequeno por capacidade; recusar escopo extra |
| Migração difícil | Expand & contract + dry-run (plano 15) + `tools/migration-check` |
| Reincidir no erro de guardar UI em coluna | ADR-017 + gate + revisão de PR |
