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
046existem 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
| Item | Estado | Onde |
|---|---|---|
Migration 046_continuity_transport.sql | Escrita | db/supabase/migrations/046_continuity_transport.sql (não aplicada) |
Tabelas continuity_commands/snapshots/outbox/replication_state | Esquema | Migration 046 |
RPCs continuity_claim_commands / _ack_command / _fail_command | Esquema | Migration 046 |
Módulo src/lib/db/continuity/ (router + breaker + ports) | Implementado | caminho canônico; src/lib/continuity/* reexporta por compatibilidade |
| Testes determinísticos da camada | Implementado | node tooling/test-continuity.mjs (in-memory, sem rede) |
| Aplicar migration 046 ao Supabase | Planejado | operador via dashboard/CLI |
Cliente do primário + adapters hospedados (SnapshotStore, CommandQueue) | Implementado | src/lib/db/continuity/{clients,supabase-adapters,supabase}.ts |
Adapter de negócio PrimaryStore (API HTTPS via Tunnel) | Planejado | depende dos contratos de leitura/escrita das rotas |
| Circuit breaker em shadow (logs only) | Planejado | fase de rollout 2 |
| Rotas de pedido/webhook usando o router | Planejado | fase de rollout 3-4 |
Worker de drenagem (consome continuity_commands) | Planejado | homelab (fase 5) |
Health check /internal/health no primário | Planejado | primário local |
Admin API / página /admin/continuity | Planejado | borda |
| Lease/epoch global de fencing p/ RECOVERING | Pré-requisito de produção (NÃO implementado) | ver §6.4 |
| Métricas, alertas Telegram, observabilidade | Planejado | fase 5+ |
| Testes de caos (game days) | Planejado | fase 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
INSERTna nuvem ser confirmado (ver §8). Exactly-once é efeito da idempotência dos comandos +PrimaryStore.executeidempotente, 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 adapterPrimaryStoreencapsula 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(createEssentialPolicyemsrc/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
| Fluxo | command_type candidato | Origem | Observação |
|---|---|---|---|
| Confirmação de pagamento (webhook/poll) | order.confirm_payment | C6/MP webhook, status-poll | Crítico — ver 3.3. |
| Cancelamento de pedido | order.cancel | /api/admin/orders/* | Mesmo agregado order. |
| Mudança de status | order.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 cupom | voucher.redeem | redeem_voucher | Revalidar na aplicação (estoque/expiração). |
| Endereço do cliente | user_address.upsert | /api/user/addresses | Idempotente por id. |
| Solicitação de cotação | quote_request.create | quote_requests | Sem efeito colateral; seguro de enfileirar. |
| Emissão fiscal (NF-e) | order.request_fiscal | requestFiscalEmission (src/lib/store/fiscal/emission.ts) | Apenas o pedido de emissão; o emissor roda no local. |
command_type,aggregate_typeeaggregate_idsãoTEXTna 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
| Fluxo | Motivo para rejeitar |
|---|---|
| Chat de IA / geração de imagem | Custo, dependência de provedores externos, payload grande. |
Galeria / user_assets | Biná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 / analytics | Buffer no cliente (IndexedDB) e reenviar na volta. |
| Comentários / avaliações | Toleram 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:
- O webhook valida assinatura (já implementado:
MERCADOPAGO_WEBHOOK_SECRET,C6_WEBHOOK_TOKEN) antes de qualquer coisa. - Em DEGRADED, o webhook enfileira
order.confirm_payment(comidempotency_keyderivado detxid/transaction_id) e responde202 Acceptedao PSP. - Em PRIMARY, o webhook tenta o primário;
confirmOrderPaid(src/lib/store/payments/confirm.ts) executa e dispara notificações. - 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.
- 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 emDEFAULT_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() === truesempre.- 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(marcadasstale: true,mode: 'degraded'). - Escritas: somente
command_typena 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 (
probeInFlightgating). - Sucesso incrementa
probeSuccesses; ao atingirrecoveryProbes, 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ãoprimary) — serviço degradado é declarado explicitamente, nunca mascarado (verfallbackMode()emrouter.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_commandstem dois UNIQUE:idempotency_keyecommand_id. Reenfileirar o mesmo comando =deduplicated: true(sem nova linha), vindo deCommandQueue.enqueue.PrimaryStore.executedeve ser idempotente emidempotencyKey/commandId: um timeout do router NÃO cancela a promise em voo (router.ts—withTimeoutapenas 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_outboxtem UNIQUE emevent_id(espelho deduplicável).
5.2 Ordenação
- A fila é drenada por
queue_sequence ASC, uma sequência observada pelo banco no momento doINSERT.occurred_até apenas metadado vindo do relógio da borda e não controla o claim. queue_sequencefornece 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
pending → claimed → acked (sucesso) | dead (após max_attempts=8).
Não há stale/rejected/duplicate no banco; o efeito equivalente:
- “já aplicado” →
ackedcomPrimaryStore.executeno-op. - “semanticamente inválido” →
continuity_fail_commandregistralast_errore, esgotadas as tentativas, viradead(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 dePrimaryStore.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
| Tabela | Papel | Colunas-chave |
|---|---|---|
continuity_commands | Fila 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_snapshots | Snapshots compactos p/ leitura degradada | snapshot_key (UNIQUE, UPSERT), aggregate_type/id, schema_version, payload (JSONB ≤256KB), captured_at, source_node |
continuity_outbox | Espelho de eventos primário → hospedado | event_id (UNIQUE), topic, aggregate_type/id, payload (≤64KB), status, claimed_by/at/until, delivered_at, last_error |
continuity_replication_state | Heartbeat / 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·deadcontinuity_outbox.status:pending·claimed·delivered·deadcontinuity_replication_state.mode:primary·degraded·recovering
7.2 RPCs (service-role only)
| RPC | Assinatura | Comportamento |
|---|---|---|
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) → BOOLEAN | Só 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) → BOOLEAN | Só 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 +
REVOKEdeanon/authenticated→ deny-all para clientes.service_rolebypassa.EXECUTEnas RPCs concedido somente aservice_role. Logo: leitura degradada decontinuity_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ário | RPO-alvo | RTO-alvo |
|---|---|---|
| Queda do primário (Supabase online) | 0 para escritas essenciais, condicional ao INSERT cloud confirmado; ≤ snapshot_interval para leitura | Continuidade: 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 local | Restauração do backup (horas) — não há continuidade online |
Precisões importantes:
- RPO=0 é condicional: só vale quando o
INSERTemcontinuity_commandsfoi 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_keyUNIQUE +PrimaryStore.executeidempotente), 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_CONFIGemtypes.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.readretornanullem miss → router respondesnapshot_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, statuspending/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/healthno 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_staterenovandolast_seen_at+mode. - Borda → estado observável: um bloco em
/api/_health(hoje emsrc/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)
openWindowMstranscorre → RECOVERING.- O fencing global mantém escritas diretas bloqueadas; comandos essenciais continuam entrando na fila.
- Worker de drenagem (planejado) consome
continuity_commandsviacontinuity_claim_commands, aplica no primário (idempotente), acks. - RPC de cutover confirma cursor/high-water mark + probes, incrementa o epoch e libera PRIMARY de forma coordenada.
- 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
pgBackRestouwal-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 rodapg_dumpcom a URL/senha de conexão do Postgres (a chave service-role da API não autenticapg_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 deoccurred_atdos pending).continuity_replay_lag_seconds,continuity_snapshot_freshness_seconds.continuity_drain_throughput(comandos acked/min).
13.2 Alertas (alvo)
- Telegram (
sendTelegramMessageemsrc/lib/notify/telegram.ts): transição para DEGRADED, fila > limiar, lease expirado,deadacumulando. - 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 headerx-command-idem 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:
- Kill do
cloudflared— circuito abre em ≤openWindowMs + failureThreshold. - Kill do Postgres local — circuito abre; fila acumula; reinício drene sem perda.
- Partição Supabase — primário continua primary localmente; fila offline sinalizada.
- Clock skew (5 min) — provar que o claim por
queue_sequencee a idempotência não dependem deoccurred_at. - Comando duplicado (mesmo
idempotency_key) —deduplicated: true, uma linha só. - Late commit + replay (já coberto no teste unitário) — estender para rede real.
- Webhook PSP reenvia confirmação após cancelamento —
PrimaryStore.executeno-op/ack; sem duplo efeito. - Flapping do tunnel — validar que o fencing global (§6.4) impede cutover antes da drenagem e rejeita epochs antigos.
- Fila em flooding (1000 cmds/min) — backoff exponencial +
SKIP LOCKEDparalelo. - Recuperação após queda simultânea — restaurar de backup; reconciliar.
- Worker morre mid-claim — lease expira, comando reclaimável (já coberto pela RPC, estender em E2E).
deadapósmax_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,
REVOKEdeanon/authenticated.service_rolebypassa.EXECUTEnas RPCs só paraservice_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 DEFINERcomsearch_path = public, pg_temp(pinned) — evita search_path hijack.EXECUTErevogado dePUBLIC/anon/authenticated, concedido só aservice_role.p_worker_idobrigatório (não-vazio) emcontinuity_claim_commands.
15.3 Autenticação
- Auth continua 100% no Supabase (
@supabase/ssrjá integrado), todos os modos. Sessões expiradas já tratadas pela UX documentada emsrc/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_keyderivado detxid/transaction_id(já usado emconfirmOrderPaid).
15.5 Dados sensíveis
payloademcontinuity_commandspode conter PII. Critério: incluir o mínimo necessário;aggregate_iddeve 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.sqlao Supabase. - Verificar tabelas/RPCs/RLS deny-all.
- Métrica: migration verde; RPCs executáveis por service-role.
Fase 2 — Adapters Supabase + shadow
SnapshotStore,CommandQueuee os clientes server-only já estão emsrc/lib/db/continuity/supabase.ts; implementar o adapter de negócioPrimaryStorevia Tunnel.- Router ligado em shadow: apenas observa, não roteia tráfego real.
- Métrica:
node tooling/test-continuity.mjsverde + 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_paymentquando 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_commandsviacontinuity_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>viawrangler 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
DROPdurante 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/continuityque 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_keyUNIQUE no banco ePrimaryStore.executeidempotente (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
- Cloudflare Tunnel — https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
- Circuit Breaker (M. Fowler) — https://martinfowler.com/bliki/CircuitBreaker.html
- Transactional Outbox — https://microservices.io/patterns/data/transactional-outbox-pattern.html
- Stripe Idempotency-Key — https://stripe.com/docs/api/idempotent_requests
- Supabase RLS — https://supabase.com/docs/guides/database/postgres/row-level-security
- Supabase Backups (Free vs paid) — https://supabase.com/docs/guides/platform/backups
- PostgreSQL Backup/PITR — https://www.postgresql.org/docs/current/backup.html
FOR UPDATE SKIP LOCKED— https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE
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_commandsapósacked(política de purga). - Política de
max_attempts/backoff: defaults atuais (8/15·2^ncap 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)?