Guia Fix-Forward

Se você viu na visão geral do seu site o banner âmbar que diz "As migrações de banco de um deploy que falhou foram aplicadas, mas o novo código não entrou no ar", esta página é para você. Ela explica o que aconteceu, por que o PrimeForge não desfaz isso automaticamente e como escrever uma pequena migração de acompanhamento que devolve o seu banco a um estado limpo.

A técnica se chama fix-forward (corrigir para a frente): em vez de reverter o schema para trás, você escreve uma nova migração que leva o schema a um estado no qual tanto o código antigo quanto o novo conseguem rodar. No momento, é mais lento do que um rollback seria; em todas as vezes seguintes, é mais rápido — porque o banco permanece num estado que você realmente entende.

O que aconteceu

A sua esteira de deploy rodou php artisan migrate e então falhou antes de trocar o código novo para o lugar (a troca, ou swap, atômica não aconteceu). Em detalhe:

  • ✅ O seu banco tem o schema novo. As migrações rodaram com sucesso.
  • ❌ O seu código ainda é a versão anterior. A troca atômica não ocorreu.
  • ✅ O seu site continua no ar. Ele está rodando o código anterior contra o schema novo.

Para muitas migrações, isso é perfeitamente seguro. Adicionar uma coluna com valor padrão, adicionar um índice, criar uma tabela nova que o código anterior nem consulta — nada disso quebra o código antigo. Vira problema apenas quando a migração quebra o código anterior: remover uma coluna que o código antigo ainda consulta, renomear uma coluna sem manter um apelido de compatibilidade, ou adicionar uma coluna NOT NULL sem valor padrão e sem backfill.

Esteja o código anterior funcionando ou não, o banner âmbar permanece até o seu próximo deploy bem-sucedido. Ele é o lembrete de que o schema está à frente do código (o estado que o PrimeForge chama de schema-ahead).

Por que o PrimeForge é "forward-only" no schema

Não existe um método down() que o PrimeForge possa rodar para restaurar o banco com segurança. Migrações de "descida" são uma ficção educada quando o assunto é dado real em produção:

  • Uma migração que removeu uma coluna não tem registro dos dados que a coluna guardava. O down() recria a coluna vazia — a aplicação acha que a coluna voltou; os usuários veem campos em branco onde antes havia dados.
  • Uma migração que renomeou uma coluna já perdeu o nome antigo. O down() renomeia de volta, mas se qualquer outro deploy nesse intervalo consultou o nome novo, aquele dado se perdeu.
  • Uma migração que adicionou uma coluna NOT NULL com backfill pode até reverter, mas o backfill se perde. A próxima subida não o reproduz.
  • Uma migração que criou uma tabela nova pode ser derrubada — e qualquer dado que os usuários gravaram nela nos minutos em que o código novo esteve no ar some junto.

Além da perda de dados, reverter o schema pode quebrar processos em execução. O PrimeForge roda workers PHP-FPM que mantêm conexões abertas; reverter o schema em pleno voo lança erros em toda requisição em andamento. A falha visível ao usuário fica pior do que a falha que motivou o rollback.

O caminho honesto é deixar o schema onde está e escrever a menor migração que faz a ponte entre o código antigo e o novo. Isso é o fix-forward, e é por isso que o PrimeForge trata o schema como forward-only (só para a frente).

O que fazer

São três passos.

Passo 1 — Identifique o que quebrou

Abra os logs do deploy que falhou (o botão Ver deploy com falha, no banner âmbar, leva direto até eles). Leia de baixo para cima. As causas comuns são:

  • A nova migração removeu uma coluna que o código anterior ainda consulta.
  • A nova migração renomeou uma coluna sem um apelido de compatibilidade.
  • A nova migração adicionou uma coluna NOT NULL sem valor padrão, e os seeders/caminhos de escrita da aplicação não foram atualizados.
  • A nova migração criou uma tabela nova, mas o código que a consulta tem um bug em outro ponto — e o deploy falhou no estágio de build, não no de migração. (Nesse caso o schema não está "à frente" de forma significativa; o banner âmbar se limpa sozinho no próximo deploy bem-sucedido.)

Se os logs sozinhos não bastarem, entre no banco pela sub-página Bancos de Dados do servidor e inspecione a tabela migrations para ver exatamente quais migrações rodaram.

Passo 2 — Escreva uma migração fix-forward

Crie uma nova migração que coloque o schema num estado que tanto o código antigo quanto o novo conseguem rodar. Os exemplos estão logo abaixo. O padrão é sempre o mesmo: não reverta; faça a ponte (bridge).

Passo 3 — Faça push e deploy

Envie a migração fix-forward junto com qualquer mudança de código que corrija o problema de fundo. Faça o deploy. A esteira roda a migração fix-forward, a troca de release se completa e o banner âmbar se limpa automaticamente.

Exemplos

Exemplo 1 — Coluna renomeada

A migração que falhou renomeou users.username para users.handle. O código anterior ainda consulta users.username, e toda página que usa o código antigo lança "Unknown column 'username'".

O fix-forward traz username de volta como uma coluna gerada e armazenada que espelha handle. A sintaxe GENERATED ALWAYS AS (...) STORED funciona nos dois motores relacionais do PrimeForge — PostgreSQL 12+ (o padrão) e MySQL 5.7+. No SQLite, colunas geradas precisam ser declaradas na criação da tabela, então um site SQLite adicionaria uma coluna username simples e a manteria em sincronia pelo model (ou via trigger):

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            // Traz o nome antigo de volta como coluna gerada e armazenada,
            // para que leituras pelo código antigo vejam dados vivos e
            // escritas pelo código novo o preencham automaticamente.
            DB::statement('ALTER TABLE users ADD COLUMN username VARCHAR(255) GENERATED ALWAYS AS (handle) STORED');
            $table->index('username');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropIndex(['username']);
            $table->dropColumn('username');
        });
    }
};

Depois que o deploy passa, o código anterior lê username, o código novo lê handle, e os dois veem o mesmo dado. Você pode remover a coluna-ponte num release futuro, quando confirmar que nada mais lê username.

Exemplo 2 — Coluna removida

A migração que falhou removeu posts.legacy_id. O código anterior inclui essa coluna em consultas SELECT * — ou, pior, em relações carregadas — e quebra.

O fix-forward re-adiciona legacy_id como anulável:

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->unsignedBigInteger('legacy_id')->nullable()->after('id');
            // Sem backfill — não temos os dados originais. O código antigo
            // tolera NULL porque a coluna existe.
        });
    }

    public function down(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropColumn('legacy_id');
        });
    }
};

O código anterior para de quebrar porque posts.legacy_id existe. Ele lê NULL em todo lugar, que é a resposta certa — o dado se foi —, mas a aplicação não lança mais erro. Você remove a coluna de vez quando verificar que nenhum caminho ainda a referencia.

Exemplo 3 — NOT NULL adicionado sem valor padrão

A migração que falhou adicionou a coluna tenant_id como NOT NULL. Os seeders do deploy não preenchem as linhas existentes; novas escritas do código anterior falham porque não fornecem tenant_id.

O fix-forward preenche as linhas existentes e afrouxa a restrição o suficiente para o código anterior continuar funcionando:

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        // Preenche linhas existentes com o tenant padrão da organização.
        DB::table('orders')
            ->whereNull('tenant_id')
            ->update(['tenant_id' => DB::raw('(SELECT id FROM tenants LIMIT 1)')]);

        // Afrouxa o NOT NULL enquanto o código anterior ainda escreve sem ele.
        // Quando o código novo estiver no ar em todo lugar, aperte de volta.
        Schema::table('orders', function (Blueprint $table) {
            $table->unsignedBigInteger('tenant_id')->nullable()->change();
        });
    }

    public function down(): void
    {
        // Intencionalmente um no-op. Não queremos reapertar no rollback;
        // o próximo deploy para a frente reaperta quando as escritas forem seguras.
    }
};

Num deploy seguinte — depois de confirmar que todo o código está na versão nova — escreva uma terceira migração que reaperta a restrição para NOT NULL. Migrações de schema são forward-only; o caminho por elas é uma sequência de passos pequenos e seguros, não um par de operações espelhadas.

Depois da correção

Assim que o próximo deploy passa de ponta a ponta, o banner âmbar se limpa automaticamente. O log de auditoria mostra a sequência completa:

  • release.failed_pre_swap — o deploy original que falhou e rodou a migração
  • schema_ahead.flagged — o sistema registrando a divergência
  • release.created — o diretório da release fix-forward
  • release.promoted — a troca concluída
  • schema_ahead.cleared — a divergência resolvida

O site volta ao seu estado estável. A sua migração fix-forward agora faz parte do histórico de migrações e vai rodar em todo banco novo a partir daqui.

O toggle "rodar migrações" nas configurações do site

Todo o cenário deste guia começa quando a esteira executa php artisan migrate --force no estágio de Script. Você controla isso pelo toggle de rodar migrações (run-migrations) em Site → Configurações → Configuração do Site.

Configurações do site, onde fica o toggle de rodar migrações

  • Ligado (padrão): a cada deploy, a esteira roda as migrações pendentes e faz o acompanhamento do estado schema-ahead — ou seja, é o comportamento que este guia descreve.
  • Desligado: a esteira não roda migrate --force e não faz o rastreamento de schema-ahead. Use quando você prefere aplicar migrações manualmente (por exemplo, em janelas de manutenção controladas) ou quando o banco é gerenciado por fora do PrimeForge.

Desligar o toggle não é uma forma de "evitar" o fix-forward: se você roda migrações à mão e uma delas avança o schema à frente do código, o mesmo raciocínio forward-only continua valendo. O toggle apenas decide quem dispara as migrações — a esteira, ou você.

Por que isso importa

Releases atômicas dão a você um rollback de código seguro. Rollback de banco não é seguro — pode perder dados, quebrar processos em execução ou deixar você num estado pior que a própria falha. Fix-forward é a disciplina de tratar o banco como forward-only e escrever cada migração sabendo que reverter não é uma opção.

Muitas decisões de projeto do PrimeForge descendem dessa: a fronteira de segurança antes/depois da troca, a persistência do banner âmbar mesmo após um rollback, a distinção do log de auditoria entre release.rolled_back e schema_ahead.cleared — tudo flui da escolha de ser honesto sobre o que dá e o que não dá para desfazer.

Na prática, a disciplina compensa. Depois de escrever duas ou três migrações fix-forward, você internaliza o padrão, e suas migrações passam a chegar menores, mais seguras e mais naturalmente reversíveis do que seriam de outro modo.

Próximos passos