Continuidade Local-Primary com Supabase

Especificação técnica do perfil em que Postgres local é a autoridade e Supabase fornece Auth, snapshots e transporte de comandos.

Este documento é a especificação profunda de um perfil concreto da arquitetura de dados agnóstica. Ele não transforma Supabase em dependência do domínio: Auth, projeção de continuidade e fila são capacidades separadas, que podem ganhar outros adapters no futuro. Turso começa por projeções de leitura e não substitui esta fila nesta fase.

Status: FUNDAÇÃO / SHADOW — o núcleo, os adapters do plano hospedado e a migration 046 existem em código. A migration ainda NÃO foi aplicada ao Supabase hospedado, e nenhuma rota de pedido/webhook/auth usa esta camada ainda. Este documento descreve a arquitetura alvo e marca explicitamente o que está implementado vs. planejado.

Premissa inalienável: a máquina local (homelab) é o banco primário e a autoridade canônica de dados. O Supabase hospedado permanece como Auth estável e assume apenas um plano de continuidade leve durante quedas do primário: snapshots compactos de leitura + fila idempotente de comandos essenciais. Não é uma topologia multi-master — há um único escritor (o primário local); o hospedado só armazena intenções.

Runbook operacional (maioria PLANEJADA): docs/runbooks/local-primary-failover.md.


0. Implementado agora vs. próximo passo

ItemEstadoOnde
Migration 046_continuity_transport.sqlEscritadb/supabase/migrations/046_continuity_transport.sql (não aplicada)
Tabelas continuity_commands/snapshots/outbox/replication_stateEsquemaMigration 046
RPCs continuity_claim_commands / _ack_command / _fail_commandEsquemaMigration 046
Módulo src/lib/db/continuity/ (router + breaker + ports)Implementadocaminho canônico; src/lib/continuity/* reexporta por compatibilidade
Testes determinísticos da camadaImplementadonode tooling/test-continuity.mjs (in-memory, sem rede)
Aplicar migration 046 ao SupabasePlanejadooperador via dashboard/CLI
Cliente do primário + adapters hospedados (SnapshotStore, CommandQueue)Implementadosrc/lib/db/continuity/{clients,supabase-adapters,supabase}.ts
Adapter de negócio PrimaryStore (API HTTPS via Tunnel)Planejadodepende dos contratos de leitura/escrita das rotas
Circuit breaker em shadow (logs only)Planejadofase de rollout 2
Rotas de pedido/webhook usando o routerPlanejadofase de rollout 3-4
Worker de drenagem (consome continuity_commands)Planejadohomelab (fase 5)
Health check /internal/health no primárioPlanejadoprimário local
Admin API / página /admin/continuityPlanejadoborda
Lease/epoch global de fencing p/ RECOVERINGPré-requisito de produção (NÃO implementado)ver §6.4
Métricas, alertas Telegram, observabilidadePlanejadofase 5+
Testes de caos (game days)Planejadofase 6

O que isso significa na prática hoje: se o primário cair, o app continua se comportando como antes (erros 5xx / retentativas naturais). A camada de continuidade é software pronto para ser ligada, não um serviço ativo em produção.


1. Objetivos e não-objetivos

Objetivos

  • Autoridade única: o Postgres local é a fonte de verdade. O Supabase hospedado nunca decide estado transacional; apenas guarda intenções (comandos) e snapshots compactos de leitura.
  • Continuidade leve: durante queda do local, o app continua vendendo, recebendo webhooks de pagamento e mostrando estados conhecidos — sem exigir infra adicional paga.
  • RPO-alvo zero para escritas essenciais, condicional ao INSERT na nuvem ser confirmado (ver §8). Exactly-once é efeito da idempotência dos comandos + PrimaryStore.execute idempotente, não garantia de transporte.
  • Failover/failback determinísticos: máquina de estados com invariantes verificáveis (já implementada no CircuitBreaker).

Não-objetivos

  • Não é multi-master / active-active. Não há “promover Supabase a primário”.
  • Não há replicação síncrona do local para o Supabase em escala total — apenas snapshots compactos e a fila de comandos.
  • Não é tolerante a uma queda simultânea do local e do Supabase (ambos são SPOFs de papéis distintos); para esse caso há backups (§12), não continuidade online.
  • Não substitui o Supabase como Auth — a autenticação continua no Supabase hospedado em todos os modos.

2. Topologia

                          ┌──────────────────────────────┐
   Browser  ──────►  Cloudflare Pages (Astro)            │
                          │  ContinuityRouter            │
                          │  CircuitBreaker              │
                          │  PrimaryStore / SnapshotStore │
                          │  CommandQueue  (adapters)    │
                          └───────────┬───────┬──────────┘
                                      │       │
                 Cloudflare Tunnel    │       │  HTTPS (Auth + service-role)
                  (private network)   │       │
                                      ▼       ▼
                          ┌────────────────┐   ┌───────────────────────┐
                          │  LOCAL PRIMARY │   │  Supabase (hosted)    │
                          │  Postgres      │   │  — Auth (sempre)      │
                          │  (homelab)     │   │  — continuity_commands │
                          │  = AUTORIDADE  │   │  — continuity_snapshots│
                          │                │   │  — continuity_outbox   │
                          │                │   │  — continuity_replication_state │
                          └────────────────┘   └───────────────────────┘
  • Cloudflare Tunnel liga a borda ao homelab sem IP público: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
  • A borda não fala SQL direto com o local; consome HTTPS (API interna) exposta pelo cloudflared. O adapter PrimaryStore encapsula esse hop.
  • Os adapters Supabase (SnapshotStore, CommandQueue) falam com o Supabase hospedado via service-role (src/lib/db/continuity/supabase.ts). O cliente do primário não possui fallback implícito para a URL hospedada.
  • O Supabase é o ponto de convergência estável: é onde a borda enfileira comandos e de onde o primário drena a fila na volta.

Por que Supabase como “plano de continuidade” e não outro broker?

Porque já é infra estável no projeto (SUPABASE_URL, service-role em secrets), já é o Auth, já tem RLS e migrations versionadas em db/supabase/migrations/. Introduzir outro broker (SQS, RabbitMQ, Upstash) seria custo e superfície nova. Trade-off aceito: a continuidade é leve — só o essencial.


3. Classificação de dados: essenciais vs. não essenciais

A regra de ouro define o que pode ser enfileirado (DEGRADED) e o que é rejeitado. Dados essenciais são aqueles cuja perda implica perda de dinheiro, obrigação fiscal ou confiança do cliente.

Implementado agora: a gate é a allowlist EssentialCommandPolicy (createEssentialPolicy em src/lib/db/continuity/index.ts). O router rejeita alto (non_essential_rejected) qualquer comando não listado — não há fallback silencioso. A composição concreta da allowlist por rota é planejada (ainda não há rotas consumindo o router).

3.1 Essenciais — enfileirados em DEGRADED

Fluxocommand_type candidatoOrigemObservação
Confirmação de pagamento (webhook/poll)order.confirm_paymentC6/MP webhook, status-pollCrítico — ver 3.3.
Cancelamento de pedidoorder.cancel/api/admin/orders/*Mesmo agregado order.
Mudança de statusorder.change_status/api/admin/orders/*A ser definido na allowlist.
Criação de pedido (checkout)order.create/api/orders/*Hoje já persiste em Supabase; viraria comando no primário.
Redenção de cupomvoucher.redeemredeem_voucherRevalidar na aplicação (estoque/expiração).
Endereço do clienteuser_address.upsert/api/user/addressesIdempotente por id.
Solicitação de cotaçãoquote_request.createquote_requestsSem efeito colateral; seguro de enfileirar.
Emissão fiscal (NF-e)order.request_fiscalrequestFiscalEmission (src/lib/store/fiscal/emission.ts)Apenas o pedido de emissão; o emissor roda no local.

command_type, aggregate_type e aggregate_id são TEXT na migration 046 (sem enum rígido) para permitir crescimento sem DDL. A allowlist vive no código (router), não no banco.

3.2 Não essenciais — rejeitados em DEGRADED

FluxoMotivo para rejeitar
Chat de IA / geração de imagemCusto, dependência de provedores externos, payload grande.
Galeria / user_assetsBinários grandes não cabem em snapshot/fila leve.
Uploads grandes (personalizados, personalized_products)Mesmo motivo; o primário orquestra storage.
Background removal (fal/imgly)Pesado; só faz sentido com o local online.
Telemetria / analyticsBuffer no cliente (IndexedDB) e reenviar na volta.
Comentários / avaliaçõesToleram atraso; não há dinheiro envolvido.

Conteúdo estático (blog, edu, loja, art-styles) é servido pela borda porque é build-time via Content Collections — continua disponível em qualquer modo sem tocar o primário.

3.3 Pagamentos e webhooks são críticos

Estado atual: webhooks de C6/Mercado Pago ainda não roteiam pela camada de continuidade. O comportamento descrito abaixo é o alvo da fase de rollout 4.

Alvo quando ligado:

  1. O webhook valida assinatura (já implementado: MERCADOPAGO_WEBHOOK_SECRET, C6_WEBHOOK_TOKEN) antes de qualquer coisa.
  2. Em DEGRADED, o webhook enfileira order.confirm_payment (com idempotency_key derivado de txid/transaction_id) e responde 202 Accepted ao PSP.
  3. Em PRIMARY, o webhook tenta o primário; confirmOrderPaid (src/lib/store/payments/confirm.ts) executa e dispara notificações.
  4. A notificação (e-mail/Telegram) só dispara depois do primário gravar — evita “cliente recebeu confirmação, pedido sumiu”. Enquanto só enfileirado, a notificação fica pendurada no comando e dispara na aplicação.
  5. O polling ativo (status.ts) gera o mesmo tipo de comando; nunca substitui o webhook.

4. Máquina de estados do circuit breaker

Implementado em src/lib/db/continuity/circuit-breaker.ts. Sem timers — transições lazy ao observar estado (Cloudflare-safe). Defaults em DEFAULT_BREAKER_CONFIG: failureThreshold=3, openWindowMs=30000, recoveryProbes=2.

Três estados only — PRIMARY, DEGRADED, RECOVERING.

   PRIMARY ──(failures ≥ failureThreshold)──► DEGRADED
      ▲                                          │
      │                                          │ (openWindowMs elapses)
      │ probes ≥ recoveryProbes OK               ▼
      └──────────── ◄──── RECOVERING ◄───────────┘
                        (single probe at a time)

4.1 Estado PRIMARY (closed)

  • shouldAttemptPrimary() === true sempre.
  • Leitura e escrita vão ao primário (via PrimaryStore).
  • Sucessos zeram o contador de falhas (onSuccess).

4.2 Estado DEGRADED (open)

  • shouldAttemptPrimary() === false — o primário não é chamado.
  • Leituras caem para SnapshotStore (marcadas stale: true, mode: 'degraded').
  • Escritas: somente command_type na allowlist são enfileiradas (applied: 'queued', pendingSync: true); demais rejeitadas.
  • Permanece aberto por openWindowMs; depois transiciona a RECOVERING.

4.3 Estado RECOVERING (half-open)

  • Uma única probe in-flight por vez (probeInFlight gating).
  • Sucesso incrementa probeSuccesses; ao atingir recoveryProbes, fecha em PRIMARY. Falha reabre em DEGRADED por uma janela fresca.
  • Sucesso tardio de chamada feita antes de abrir é ignorado (recuperação só por probes controlados).

Limite da fundação atual: o breaker em memória fecha após os probes; ele ainda não consulta o cursor da fila. Antes de ativar rotas reais de escrita, o rollout deve adicionar o fencing global descrito em §6.4 e exigir que o high-water mark capturado no failover esteja drenado. Sem essa etapa, a fundação permanece apenas em shadow/logs.

Mesmo abaixo do threshold de abertura, uma leitura que cai no snapshot já reporta mode='degraded' (não primary) — serviço degradado é declarado explicitamente, nunca mascarado (ver fallbackMode() em router.ts).

Referência de padrão: https://martinfowler.com/bliki/CircuitBreaker.html


5. Idempotência e ordenação

Implementado na migration 046 e no contrato PrimaryStore.execute (src/lib/db/continuity/types.ts).

5.1 Idempotência (real)

  • continuity_commands tem dois UNIQUE: idempotency_key e command_id. Reenfileirar o mesmo comando = deduplicated: true (sem nova linha), vindo de CommandQueue.enqueue.
  • PrimaryStore.execute deve ser idempotente em idempotencyKey/ commandId: um timeout do router NÃO cancela a promise em voo (router.tswithTimeout apenas rejecta o lado do chamador). O commit local pode atrasar e landar depois de o comando já estar na fila. Na drenagem, o replay de um comando já aplicado deve ser no-op — é assim que se obtém efeito exactly-once.
  • continuity_outbox tem UNIQUE em event_id (espelho deduplicável).

5.2 Ordenação

  • A fila é drenada por queue_sequence ASC, uma sequência observada pelo banco no momento do INSERT. occurred_at é apenas metadado vindo do relógio da borda e não controla o claim.
  • queue_sequence fornece um cursor operacional, não uma ordem causal distribuída. O worker planejado preserva essa ordem por agregado e cada handler ainda valida a transição de estado; agregados distintos podem ser processados em paralelo.
  • Conflito de intenção (ex.: dois status mutuamente exclusivos) é resolvido pela semântica de PrimaryStore.execute (pré-condição/estado-alvo); a fila não decide — apenas entrega em ordem.

5.3 Status real dos comandos

pendingclaimedacked (sucesso) | dead (após max_attempts=8). Não há stale/rejected/duplicate no banco; o efeito equivalente:

  • “já aplicado” → acked com PrimaryStore.execute no-op.
  • “semanticamente inválido” → continuity_fail_command registra last_error e, esgotadas as tentativas, vira dead (mantido para auditoria, nunca deletado).

6. Prevenção de split-brain

Split-brain = dois escritores achando ser autoridade ao mesmo tempo. Dois riscos concretos; mitigações atuais e futuras.

6.1 Autoridade única (invariante base) — implementado

A borda nunca aplica estado de negócio no Supabase. Toda escrita é uma intenção (continuity_commands); só o primário local transforma intenção em estado. O Supabase hospedado é somente fila + snapshot, por construção.

6.2 Allowlist de comandos essenciais — implementado

EssentialCommandPolicy (src/lib/db/continuity/index.ts) é uma allowlist explícita. O router rejeita alto (non_essential_rejected) qualquer comando fora dela — não há fallback silencioso que pudesse enfileirar algo imprevisto e competir depois.

6.3 Lease de claim por comando — implementado

continuity_claim_commands usa FOR UPDATE SKIP LOCKED + claimed_until (lease). Dois workers concorrentes nunca recebem o mesmo comando; se um worker morrer, o lease expira e o comando é reclaimável sem intervenção (claimed_at sozinho nunca wedga a fila). Após max_attempts, vira dead.

6.4 Fencing global para RECOVERING — BLOQUEADOR DE PRODUÇÃO (NÃO implementado)

Para o cenário de flap do tunnel (o primário “volta” por segundos, a borda reabre o circuito, mas ainda há comandos pendentes não drenados): um lease/epoch global de fencing (continuity_replication_state pode ser estendido para isso) deve impedir escrita direta enquanto o primário drena a fila. A API local só aceita o epoch ativo; a troca para PRIMARY ocorre por RPC atômica depois do cursor/high-water mark e dos probes de saúde convergirem. Hoje isso não existe. Cada CircuitBreaker é in-process (Workers) e independente — não há coordenação cross-instance.

Decisão registrada: não ligar rotas reais de escrita apenas com o breaker por instância. Idempotência cobre duplicidade e late commit, mas não substitui ordenação de cutover. Fencing global é gate obrigatório da fase 4.

6.5 Invariante formal

Para cada idempotency_key, exatamente um efeito de estado acontece no primário — seja no commit original, seja no replay da fila, jamais em ambos (garantido pelo contrato idempotente de PrimaryStore.execute).


7. Esquema real (migration 046)

Não duplicar SQL aqui. Fonte canônica: db/supabase/migrations/046_continuity_transport.sql. Resumo abaixo para navegação rápida; confira o arquivo para detalhes.

7.1 Tabelas

TabelaPapelColunas-chave
continuity_commandsFila de comandos essenciais (inbox)queue_sequence (ordem observada no DB), command_id (UNIQUE), idempotency_key (UNIQUE), aggregate_type/id, command_type, actor_id, occurred_at, schema_version, payload (JSONB ≤64KB), status, attempts/max_attempts=8, available_at, claimed_by/at/until, acked_at, last_error
continuity_snapshotsSnapshots compactos p/ leitura degradadasnapshot_key (UNIQUE, UPSERT), aggregate_type/id, schema_version, payload (JSONB ≤256KB), captured_at, source_node
continuity_outboxEspelho de eventos primário → hospedadoevent_id (UNIQUE), topic, aggregate_type/id, payload (≤64KB), status, claimed_by/at/until, delivered_at, last_error
continuity_replication_stateHeartbeat / cursores por nónode_id (PK), role (primary/fallback), mode (primary/degraded/recovering), last_command_seq, last_snapshot_at, last_drain_at, last_seen_at, details (≤16KB)

Status reais:

  • continuity_commands.status: pending · claimed · acked · dead
  • continuity_outbox.status: pending · claimed · delivered · dead
  • continuity_replication_state.mode: primary · degraded · recovering

7.2 RPCs (service-role only)

RPCAssinaturaComportamento
continuity_claim_commands(p_worker_id TEXT, p_limit INT=20, p_lease_seconds INT=60)Marca expirados esgotados como dead, então claim pending/expirados por queue_sequence, com FOR UPDATE SKIP LOCKED; define claimed_by/at/until e incrementa attempts.
continuity_ack_command(p_id UUID, p_worker_id TEXT) → BOOLEANSó o holder de um lease ainda válido acks; move a acked.
continuity_fail_command(p_id UUID, p_worker_id TEXT, p_error TEXT) → BOOLEANSó aceita lease válido; aplica backoff exponencial (LEAST(3600, 15*2^min(attempts,8))s), dead após max_attempts.

RLS: habilitada com ZERO policies + REVOKE de anon/authenticated → deny-all para clientes. service_role bypassa. EXECUTE nas RPCs concedido somente a service_role. Logo: leitura degradada de continuity_snapshots é servida por adapter server-side (SnapshotStore), não por SELECT direto do navegador.

Documentação de referência RLS: https://supabase.com/docs/guides/database/postgres/row-level-security


8. RTO / RPO (metas)

Sempre alvo — RPO/RTO não são garantias do transporte; são observados em jogo de caos (§14).

CenárioRPO-alvoRTO-alvo
Queda do primário (Supabase online)0 para escritas essenciais, condicional ao INSERT cloud confirmado; ≤ snapshot_interval para leituraContinuidade: até failureThreshold × timeout + enqueue; normalização: openWindowMs + probes + drenagem
Queda do Supabase (primário online)0 (primário continua)Indisponibilidade de Auth/novas filas; serviço read-only quanto a Auth; escritor continua no local
Queda simultânea≤ intervalo do backup localRestauração do backup (horas) — não há continuidade online

Precisões importantes:

  • RPO=0 é condicional: só vale quando o INSERT em continuity_commands foi confirmado pela nuvem (201 do Supabase). Se a borda cai junto com o primário, ou a confirmação se perdeu, o comando pode não estar na fila.
  • Exactly-once é efeito, não transporte: vem da idempotência (idempotency_key UNIQUE + PrimaryStore.execute idempotente), não de uma garantia de entrega do broker.
  • Parâmetros do breaker (defaults, ajustáveis via CircuitBreakerConfig): failureThreshold=3, openWindowMs=30000, recoveryProbes=2.
  • Timeouts do router (defaults): read 1500ms, write 3000ms (DEFAULT_ROUTER_CONFIG em types.ts).

9. Outbox, snapshots e replication_state

9.1 continuity_snapshots (leitura degradada — implementado como esquema)

  • UPSERT por snapshot_key; compacto (≤256 KiB por chave), só campos essenciais (status, totais, nomes, datas). Nunca binários nem textos longos — blob vai em Storage.
  • SnapshotStore.read retorna null em miss → router responde snapshot_miss.

9.2 continuity_outbox (espelho primário → hospedado — implementado como esquema)

  • Eventos publicados pelo primário (ex.: “pedido X mudou de status”) espelhados no hospedado para alimentar snapshots e assinantes.
  • Mesmo padrão de claim/lease de continuity_commands, status pending/claimed/delivered/dead.
  • RPCs de claim do outbox ainda não existem na migration 046 (só as 3 dos commands). Worker de publicação é planejado.

9.3 continuity_replication_state (heartbeat/cursor — implementado como esquema)

  • Uma linha por node_id; role, mode, cursores (last_command_seq, last_snapshot_at, last_drain_at), last_seen_at.
  • Base para o futuro watchdog de nodes e eventual fencing lease (§6.4).

10. Health checks (PLANEJADO)

Nada disso existe em código hoje. Marcas como alvo da fase de rollout.

  • Borda → Local (probe ativo): GET /internal/health no primário (via Tunnel) retornando { ok, lease_token, db_lag_ms }. Janela do breaker já determina abertura (3 falhas).
  • Local → Supabase (watchdog): heartbeat em continuity_replication_state renovando last_seen_at + mode.
  • Borda → estado observável: um bloco em /api/_health (hoje em src/pages/api/debug) expondo { circuit_state, pending_count, last_snapshot_age }a criar.

11. Failover e failback (PLANEJADO — visão de alto nível)

Nenhum comando operacional existe hoje. Passos executáveis detalhados (quando existirem) ficam em docs/runbooks/local-primary-failover.md. Aqui, apenas o esqueleto canônico planejado.

11.1 Failover (PRIMARY → DEGRADED)

Automático pelo CircuitBreaker após failureThreshold falhas consecutivas. Router passa a ler de SnapshotStore e enfileirar comandos essenciais.

11.2 Recovery (DEGRADED → RECOVERING → PRIMARY)

  1. openWindowMs transcorre → RECOVERING.
  2. O fencing global mantém escritas diretas bloqueadas; comandos essenciais continuam entrando na fila.
  3. Worker de drenagem (planejado) consome continuity_commands via continuity_claim_commands, aplica no primário (idempotente), acks.
  4. RPC de cutover confirma cursor/high-water mark + probes, incrementa o epoch e libera PRIMARY de forma coordenada.
  5. Republica snapshots/outbox e continua consumindo qualquer enqueue tardio de um isolate antigo de forma idempotente.

O passo 4 é arquitetura alvo. O breaker implementado hoje fecha somente por probes e, por isso, permanece restrito a shadow até o fencing existir.

11.3 Failback manual forçado

A definir — requer admin API e/ou endpoint de health que ainda não existem.


12. Backups

Realidade do plano Free do Supabase: sem PITR / backups gerenciados automáticos. Não alegar PITR atual.

12.1 Primário local (autoridade)

  • pg_dump base diário (lógico), retenção 14 dias.
  • WAL archiving + PITR via pgBackRest ou wal-g (contínuo, retenção 7d de WAL) — a configurar no homelab. PITR cobre o “buraco” entre dumps.
  • Restore-test mensal em ambiente isolado.
  • Documentação: https://www.postgresql.org/docs/current/backup.html

12.2 Supabase hospedado (continuidade)

  • Plano Free atual: dump/export manual agendado das tabelas de continuidade (continuity_commands, continuity_snapshots, continuity_outbox, continuity_replication_state). Sugestão: cron no homelab que roda pg_dump com a URL/senha de conexão do Postgres (a chave service-role da API não autentica pg_dump) e guarda em disco local + storage privado.
  • Upgrade de plano habilita PITR gerenciado e backups automáticos (documentação: https://supabase.com/docs/guides/platform/backups) — decisão de custo x risco com o operador. Item pendente em db/supabase/ROADMAP.md.

12.3 Snapshots de reconciliação

  • Snapshot publicado via continuity_snapshots (UPSERT idempotente) + manifesto a definir; retenção 90 dias + último de cada mês.

12.4 Segredos

  • Secrets de runtime (C6, MP, service-role) nunca em backup versionado; seguem wrangler secret / .dev.vars (prática já do projeto).

13. Observabilidade (PLANEJADO)

Nada implementado. A camada atual loga via ContinuityError (src/lib/db/continuity/types.ts) com códigos: primary_timeout, primary_failure, primary_unavailable, snapshot_miss, snapshot_failure, queue_failure, non_essential_rejected, invalid_command, payload_too_large.

13.1 Métricas (alvo)

  • continuity_circuit_state{} (gauge: 0=PRIMARY, 1=DEGRADED, 2=RECOVERING).
  • continuity_queue_depth{status} (pending/claimed/acked/dead).
  • continuity_queue_age_seconds (p95/p99 de occurred_at dos pending).
  • continuity_replay_lag_seconds, continuity_snapshot_freshness_seconds.
  • continuity_drain_throughput (comandos acked/min).

13.2 Alertas (alvo)

  • Telegram (sendTelegramMessage em src/lib/notify/telegram.ts): transição para DEGRADED, fila > limiar, lease expirado, dead acumulando.
  • E-mail (sendEmail): resumo diário.
  • Admin (/admin/...): painel com estado, fila, idade dos snapshots.

13.3 Tracing

  • Cada comando carrega commandId (UUID); propagar como header x-command-id em todo hop (webhook → enqueue → claim → apply → ack) para correlação ponta-a-ponta.

14. Testes de caos (PLANEJADO)

Implementado hoje: apenas testes determinísticos unitários em node tooling/test-continuity.mjs (8 grupos: primary success, circuit opening, degraded reads, essential queue, non-essential rejection, primary idempotency/late-commit, recovery, idempotency/dedupe). Esses cobrem o contrato da camada in-memory, sem rede.

Game days a ensaiar quando a camada estiver ligada a rotas reais:

  1. Kill do cloudflared — circuito abre em ≤ openWindowMs + failureThreshold.
  2. Kill do Postgres local — circuito abre; fila acumula; reinício drene sem perda.
  3. Partição Supabase — primário continua primary localmente; fila offline sinalizada.
  4. Clock skew (5 min) — provar que o claim por queue_sequence e a idempotência não dependem de occurred_at.
  5. Comando duplicado (mesmo idempotency_key) — deduplicated: true, uma linha só.
  6. Late commit + replay (já coberto no teste unitário) — estender para rede real.
  7. Webhook PSP reenvia confirmação após cancelamento — PrimaryStore.execute no-op/ack; sem duplo efeito.
  8. Flapping do tunnel — validar que o fencing global (§6.4) impede cutover antes da drenagem e rejeita epochs antigos.
  9. Fila em flooding (1000 cmds/min) — backoff exponencial + SKIP LOCKED paralelo.
  10. Recuperação após queda simultânea — restaurar de backup; reconciliar.
  11. Worker morre mid-claim — lease expira, comando reclaimável (já coberto pela RPC, estender em E2E).
  12. dead após max_attempts — alerta fired; revisão manual.

15. Segurança e RLS

15.1 Princípios (implementado)

  • Borda nunca aplica estado de negócio direto no Supabase — apenas enfileira intents; estado só no primário.
  • Todas as tabelas de continuidade são service-role only: RLS habilitada, zero policies, REVOKE de anon/authenticated. service_role bypassa. EXECUTE nas RPCs só para service_role.
  • Cliente não enxerga a fila nem snapshots diretamente — leitura degradada é mediada por adapter server-side com service-role.

15.2 RPCs hardening (implementado)

  • SECURITY DEFINER com search_path = public, pg_temp (pinned) — evita search_path hijack.
  • EXECUTE revogado de PUBLIC/anon/authenticated, concedido só a service_role.
  • p_worker_id obrigatório (não-vazio) em continuity_claim_commands.

15.3 Autenticação

  • Auth continua 100% no Supabase (@supabase/ssr já integrado), todos os modos. Sessões expiradas já tratadas pela UX documentada em src/lib/auth/*.
  • Em DEGRADED, nenhuma degradação de Auth — login/logout/refresh seguem funcionando porque o Supabase está online.

15.4 Webhooks (alvo)

  • Validação HMAC (já existe: MERCADOPAGO_WEBHOOK_SECRET, C6_WEBHOOK_TOKEN) antes do enqueue.
  • idempotency_key derivado de txid/transaction_id (já usado em confirmOrderPaid).

15.5 Dados sensíveis

  • payload em continuity_commands pode conter PII. Critério: incluir o mínimo necessário; aggregate_id deve identificar o agregado, evitando CPF completo. Limite de 64 KiB (commands/outbox) e 256 KiB (snapshots) por CHECK no banco — defesa em profundidade contra payloads aberrantes.

16. Rollout realista

Fases incrementais e reversíveis. Cada fase tem métrica de sucesso explícita antes de avançar. Feature flag sugerida: LOCAL_CONTINUITY_PHASE (runtime secret).

Fase 1 — Schema

  • Aplicar 046_continuity_transport.sql ao Supabase.
  • Verificar tabelas/RPCs/RLS deny-all.
  • Métrica: migration verde; RPCs executáveis por service-role.

Fase 2 — Adapters Supabase + shadow

  • SnapshotStore, CommandQueue e os clientes server-only já estão em src/lib/db/continuity/supabase.ts; implementar o adapter de negócio PrimaryStore via Tunnel.
  • Router ligado em shadow: apenas observa, não roteia tráfego real.
  • Métrica: node tooling/test-continuity.mjs verde + shadow sem erros por 7 dias.

Fase 3 — Circuit breaker real (logs only)

  • Health probe ativo; transições só logadas, sem mudar comportamento.
  • Métrica: 0 falsos positivos de DEGRADED em 7 dias.

Fase 4 — Rotas de pedido/webhook

  • Implementar e validar o fencing/epoch global de §6.4 antes do primeiro write path real.
  • Webhooks de pagamento enfileiram order.confirm_payment quando DEGRADED.
  • Checkout/admin usam o router para order.*.
  • Métrica: game day de leitura/escrita degradada sem incidente.

Fase 5 — Worker de drenagem + snapshots/outbox

  • Worker no homelab consome continuity_commands via continuity_claim_commands, aplica idempotentemente, acks.
  • Republica snapshots/outbox.
  • Métrica: RPO=0 confirmado em game day; fila drena sem erro.

Fase 6 — Chaos + observabilidade

  • Game days do §14; métricas + alertas do §13.
  • Métrica: 3 game days seguidos com RTO ≤ meta.

17. Rollback

Rollback de fase (comportamental)

  • Setar LOCAL_CONTINUITY_PHASE=<número anterior> via wrangler pages secret put. Sem schema change.
  • Fila existente é preservada (não descartada); drenagem manual continua possível.

Rollback de schema

  • As tabelas são aditivas. No rollback, desative a feature flag e preserve schema e fila para auditoria; não faça DROP durante um incidente.
  • Uma remoção futura exige migration revisada, backup e confirmação de que não há pending/claimed. Ela não faz parte deste runbook.

Rollback de emergência (split-brain suspeito) — a definir

  • Requer admin API / página /admin/continuity que ainda não existem.
  • Por ora, rollback = desligar a feature flag e tratar a fila offline.

18. Decisões explícitas (registro)

  • D-1 — Não multi-master: custo/risco de reconciliação bidirecional não se pagam nesta escala. Local é autoridade; Supabase é continuidade.
  • D-2 — Supabase como fila, não broker externo: menor superfície, volume compacto e custo marginal esperado; quotas continuam sendo monitoradas.
  • D-3 — Snapshots compactos: binários/textos longos não entram; ≤256 KiB.
  • D-4 — Allowlist explícita com rejeição alta: sem fallback silencioso.
  • D-5 — Idempotência dupla: idempotency_key UNIQUE no banco e PrimaryStore.execute idempotente (cobre late commit + replay).
  • D-6 — IA/galeria/uploads desabilitados em DEGRADED: custo + payload grande + não-essencial; cliente recebe 503/non_essential_rejected.
  • D-7 — Lease de claim por comando + fencing global: o primeiro já protege cada item na drenagem; o segundo é obrigatório antes de ligar write paths, pois coordena o cutover entre isolates.
  • D-8 — Snapshot store service-role only: leitura degradada é server-side, nunca SELECT direto do cliente (RLS deny-all).

19. Referências


20. Pontos abertos (a decidir com o operador)

  • Formato final da RPC e do epoch de fencing (§6.4).
  • Onde roda o worker de drenagem no homelab? (systemd? container? cron?)
  • Frequência real de publicação de snapshots/outbox.
  • Retenção final de continuity_commands após acked (política de purga).
  • Política de max_attempts/backoff: defaults atuais (8/15·2^n cap 1h) são adequados para a volumetria esperada?
  • UX de notificação ao usuário cujo pedido foi criado em modo degradado.
  • Upgrade de plano Supabase para PITR, ou dump agendado (§12.2)?

21. Leitura relacionada