Pular para o conteúdo

Edição de schema ao vivo

O editor de schema no painel de administração reescreve o código-fonte da sua coleção. Isso funciona na sua máquina e em nenhum outro lugar: os arquivos de um servidor implantado são reconstruídos a partir do seu repositório a cada deploy, portanto uma edição feita lá seria descartada no próximo.

A edição de schema ao vivo é a resposta para isso. Ela comita a alteração no seu repositório e depois aplica o DDL — assim, a edição sobrevive ao próximo deploy, porque o deploy é construído a partir dela.

GET /api/admin/schema/status whether this backend can do it, and whether you may
POST /api/admin/schema/plan what would happen, without doing it
POST /api/admin/schema/apply commit, then apply

Status e plan requerem o escopo schema:read, e apply requer schema:write, como qualquer outra superfície de /api/admin que nomeia um escopo. Um administrador tem os dois. A aplicação também exige algo além do escopo — consulte Quem pode aplicar.

O /plan não tem efeitos colaterais. Envie a alteração, e ele informa o que ela significa. Uma alteração em uma coleção existente é um patch — o que mudou, como operações sobre caminhos de chaves — e uma nova coleção é a collection inteira:

{ "collectionId": "posts", "patch": [
{ "op": "set", "path": ["properties", "subtitle"], "value": { "name": "Subtitle", "type": "string" } },
{ "op": "remove", "path": ["admin", "group"] }
] }

Apenas as chaves que um patch nomeia são escritas no arquivo da coleção. Todo o resto permanece como está — imports, comentários, formatação, o onClick de uma ação de entidade, uma propriedade compartilhada de outro módulo, um enum importado de outro lugar. Um patch que alcança dentro de algo definido em código (status: statusProperty, enum: LOCALE_ENUM, um ...spread) é recusado com a expressão em que bateu, de modo que a alteração seja feita onde esse código vive. Uma collection inteira enviada para uma coleção que já existe é transformada no patch do que difere dela, e uma chave cujo valor é código nunca é removida dessa forma. O painel admin envia patches. Na 0.23 o /plan e o /apply recebem apenas a collection inteira como ela deve ficar.

$ADMIN_TOKEN é um token de acesso — o accessToken que um login retorna — de uma conta que tem schema:read: um administrador, ou uma role que o declara. Nada na máquina o define para você.

curl -X POST https://your-app/api/admin/schema/plan \
-H "authorization: Bearer $ADMIN_TOKEN" \
-H "content-type: application/json" \
-d '{"collectionId":"posts","collection":{}}'
{
"applicable": true,
"verdict": "safe",
"changes": [
{ "kind": "add-property", "verdict": "safe", "collection": "posts",
"property": "subtitle", "detail": "New optional property subtitle …" }
],
"statements": ["ALTER TABLE \"public\".\"posts\" ADD COLUMN IF NOT EXISTS \"subtitle\" TEXT;"],
"files": ["config/collections/posts.ts", "backend/src/schema.generated.ts"]
}

files é cada arquivo que o apply inclui no commit: o código-fonte da coleção, quando o backend pode gravá-lo, e depois o schema gerado.

Isso não é apenas uma conveniência. Dois dos três vereditos são recusas, e um deles é uma recusa que você só descobriria ao apertar o botão em um banco de dados ativo.

Veredito Significado
safe O caminho ensure no momento do boot o expressa e o resultado corresponde à sua configuração. Aplicado.
diverges Ele é aplicado em parte, deixando um banco de dados que não corresponde à sua configuração — e nada reporta isso. Recusado.
needs-migration O caminho ensure não consegue expressá-lo de forma alguma. Recusado.

diverges é o que vale a pena entender, porque essas alterações parecem ter funcionado:

  • Uma propriedade obrigatória adicionada a uma tabela que já contém linhas chega como anulável (nullable). O NOT NULL é verificado em cada linha já existente, e as linhas gravadas antes de a propriedade existir não possuem valor para ela. Em uma tabela vazia, não há nada a verificar, portanto a restrição é aplicada e isso é safe.
  • Tornar obrigatória uma propriedade existente segue a mesma lógica: SET NOT NULL varre a tabela, portanto é safe em uma tabela vazia e diverges em uma preenchida até que você faça o backfill.

Duas alterações que costumavam ser diverges agora são safe, porque o caminho ensure as executa:

  • Um valor adicionado a um enum existente é aplicado via ALTER TYPE … ADD VALUE IF NOT EXISTS. Antes, ele era ignorado junto com todo o tipo, e a primeira linha usando o novo valor era rejeitada por um tipo que nunca tinha ouvido falar dele.
  • Tornar uma propriedade obrigatória opcional remove o NOT NULL. Antes, ele era mantido, de modo que gravações omitindo a propriedade continuavam falhando.

Uma alteração pode ser aplicável e ainda deixar algo solicitado pela sua configuração sem aplicação — uma propriedade obrigatória sobre uma tabela populada é o caso. Isso não é uma recusa, portanto não aparece em changes; aparece em withheldConstraints, com o obstáculo e o que o resolveria:

{
"withheldConstraints": [
{
"target": "public.posts.author",
"kind": "not-null",
"reason": "\"author\" is required, but \"public.posts\" already holds rows …",
"remedy": "Backfill the column, then apply this again."
}
]
}

O caminho ensure de boot relata a mesma coisa como um aviso. Até que isso existisse, uma restrição retida ficava retida em silêncio.

needs-migration cobre tudo o que o caminho ensure não pode fazer: remover uma coleção ou uma propriedade, alterar o tipo de uma coluna (alternar um inteiro, uma string se tornando um enum, o tipo de elemento de um array, a largura de um varchar), renomear uma coluna, alterar uma chave primária, remover um valor de enum, tornar única uma coluna já existente, e alterar uma relação — seu tipo (kind), seu destino, seu localKey, seu onDelete. Um hasMany ou hasOne cuja coluna de vínculo nada cria também é recusado. Cada recusa nomeia a alteração e o que fazer em vez disso.

O veredito é lido a partir do esquema que cada lado produz — o mesmo plano a partir do qual o schema.generated.ts e o db push são renderizados — então uma edição que altera o banco de dados não pode ser relatada como nenhuma alteração. Duas edições que parecem alterações e não são recusadas (; a 0.23 relata ambas como exigindo uma migração):

  • Renomear a chave de uma propriedade mantendo sua coluna (columnName definido como a coluna antiga) não move nenhum dado. É safe; os clientes da API leem o nome novo.
  • Definir, alterar ou remover um default vincula apenas gravações futuras. É safe, e aplicado com ALTER COLUMN … SET DEFAULT / DROP DEFAULT.

Uma alteração recusada ainda pode ser escrita na origem da sua coleção e commitada, deixando o banco de dados como está — remover uma propriedade que você não serve mais é o caso comum. Envie /apply com "sourceOnly": true. Nada é executado; a mensagem do commit nomeia o que o banco de dados mantém, por exemplo chore(schema): remove sku from products (source only — column products.sku kept), e cada alteração no plano carrega uma frase sourceOnly dizendo o que ela deixa para trás — incluindo quando uma coluna deixada para trás é NOT NULL sem default, o que faz toda inserção futura falhar até que ela seja removida ou se torne anulável (nullable). Uma alteração sem uma dessas (mover uma chave primária, uma relação cuja coluna de vínculo nada cria) não pode ser escrita apenas na origem.

Excluir uma coleção pelo painel admin segue o mesmo caminho: /apply com "remove": true e "sourceOnly": true exclui o arquivo da coleção e sua entrada em index.ts e commita os dois; a tabela e suas linhas permanecem. É recusado enquanto outra coleção importa o arquivo (uma relação a ele), nomeando o importador — excluí-lo impediria que todas as coleções carregassem.

Na 0.23 o /apply não recebe nem sourceOnly nem remove, e o “Editar apenas a origem” do painel escreve o arquivo sem um commit.

Não apenas o arquivo da coleção. O schema Drizzle é gerado a partir dele, e um desatualizado quebra o próximo deploy, então os dois vão no mesmo commit:

  • config/collections/<name>.ts — a própria coleção
  • backend/src/schema.generated.ts — o schema Drizzle

Esses caminhos são relativos ao seu projeto, não ao seu repositório. Quando os dois são a mesma coisa — um projeto rebase init, que é o caso comum —, não há nada com o que se preocupar. Quando o seu projeto fica em um subdiretório de um repositório maior, os caminhos recebem o prefixo dele, localizado ao subir a partir do diretório de coleções até o rebase.json mais próximo. Um projeto sem rebase.json mantém os caminhos simples.

Nenhum SQL entra no commit. rebase db push e rebase db generate gravam o próprio SQL a partir das coleções a cada execução, em .rebase/sql/, que fica no gitignore. Na 0.23 o commit também carrega drizzle/schema.sql, drizzle/policies.sql e drizzle/search.sql, escritos na raiz do projeto.

A mensagem de commit descreve a alteração em vez de simplesmente anunciar uma, e é atribuída à pessoa que a realizou, pelo nome de exibição e pelo e-mail da conta (um usuário sem nenhum dos dois é registrado como <uid>@users.noreply.rebase.pro). Desde 0.25 Na 0.24, os commits da autenticação integrada levam o uid como nome.

Uma alteração de schema com autor e diff no histórico do seu projeto é algo que nem o Firebase nem o Supabase oferecem — as edições de tabela deles são invisíveis para o seu repositório.

Ter schema:read é suficiente para planejar (plan). O planejamento não tem efeitos colaterais, e uma tarefa de CI verificando se uma alteração de coleção proposta é aplicável é um bom uso para ele.

Aplicar é um segundo privilégio, porque aplicar grava um commit e um commit traz um autor:

Chamador Plan Apply
Uma pessoa autenticada que tem schema:write sim sim
Uma chave de API que tem schema:read / schema:write sim não
A chave de serviço do servidor sim não

Uma credencial não é um autor. api-key:7c3f… no seu ambiente de CI não é alguém, e permitir que ela escreva no seu repositório produz exatamente o histórico sem autoria que este recurso existe para substituir.

Na 0.23 a linha é a role admin: um admin planeja e aplica, e qualquer chave de API pode planejar.

Se você deseja uma alteração automatizada de schema — um pipeline de migração, por exemplo —, ative isso deliberadamente:

initializeRebaseBackend({
// …the rest of your config
liveSchema: { allowMachineApply: true }
})

ou REBASE_LIVE_SCHEMA_ALLOW_MACHINE_APPLY=true. O commit é então atribuído à credencial pelo nome — Rebase API key (7c3f) — para que ler o git log um mês depois ainda mostre quais alterações foram feitas por uma pessoa.

GET /api/admin/schema/status informa o que você pode fazer, não apenas o que o servidor suporta, para que um painel possa desativar o controle e dizer o motivo em vez de recusar depois que você já tiver decidido:

{
"enabled": true,
"canPlan": true,
"canApply": false,
"applyRefusedCode": "SCHEMA_EDIT_REQUIRES_A_PERSON",
"applyRefusedBecause": "This request is authenticated with an API key …"
}

Aplicar aqui não cria uma migração, e nem poderia: uma migração usa o formato do Atlas com um arquivo de integridade, gerado por um binário externo contra um banco de dados temporário, e um servidor em execução não possui nenhum dos dois.

O que ele comita é a coleção, e é a partir dela que o rebase db generate grava a migração. Portanto, a migração está a apenas um comando de distância:

rebase db generate

O plano e o resultado avisam sobre isso quando o seu projeto possui migrações, porque, caso contrário, a falha seria silenciosa: seu banco de dados teria a alteração e seu repositório a descreveria, mas o próximo ambiente construído reproduzindo as migrações não a teria, sem que nada avisasse.

Um projeto provisionado pelo boot-ensure — o runtime gerenciado e qualquer self-host deixando REBASE_MIGRATE_ON_BOOT no padrão — não precisa de migração alguma. Suas coleções são o próprio schema, e a próxima inicialização faz a reconciliação.

A ordem importa e não é arbitrária.

Se o DDL rodasse primeiro e o commit falhasse, seu banco de dados teria uma coluna que o seu repositório não descreve. O caminho ensure nunca remove nada, portanto o próximo deploy não a removeria nem a mencionaria — uma coluna invisível, ausente das suas coleções, até que alguém fosse procurá-la.

Comitar primeiro falha no sentido oposto: o repositório descreve algo que o banco de dados ainda não tem. Esse é o estado normal de qualquer projeto entre uma edição e um deploy, e o boot o reconcilia na inicialização seguinte.

Portanto, uma aplicação que falha não é um erro. A resposta diz isso:

{
"applied": false,
"applyError": "connection refused",
"committed": { "sha": "1a2b3c4de", "branch": "main" },
"summary": "Committed 1a2b3c4de on main, but the database was not changed. The change will be applied on the next boot."
}

A edição ao vivo do schema reescreve o código-fonte das suas coleções com o mesmo editor que os salvamentos somente de código-fonte do painel usam (ele precisa do ts-morph instalado). De onde vem esse código-fonte decide onde ela roda:

  • Em disco (collectionsDir, o padrão): roda onde o editor somente de código-fonte roda. Esse editor fica desligado sob NODE_ENV=production, a menos que o servidor defina schemaEditor: true.
  • Em um repositório que você nomeia (liveSchema.repository, ou REBASE_LIVE_SCHEMA_REPOSITORY para um bundle): o código-fonte é lido do GitHub e a alteração é comitada de volta lá, então roda em qualquer lugar, produção inclusive. Esta é a adesão para um bundle compilado.

Tudo acima descreve o Postgres, onde uma alteração de schema significa DDL. No MongoDB não há tabela para alterar: adicionar uma propriedade não adiciona nada, remover uma não remove nada, e um documento gravado ontem ainda é válido amanhã.

Portanto, toda alteração é aplicável, nada é recusado e o plano não tem instruções (statements) — o commit é a alteração. O painel diz “Commit” em vez de “Commit and apply”, e não afirma que algo foi executado contra o banco de dados.

O único ponto que vale a pena ler com atenção é uma remoção. No Postgres, remover uma propriedade é recusado porque isso excluiria uma coluna. No MongoDB, o campo permanece em cada documento que o possui; sua API simplesmente para de servi-lo. A alteração expressa isso em vez de deixar você assumir a resposta relacional.

Implantação Funciona
rebase dev na sua máquina sim
Self-host com o projeto e sua árvore de trabalho do git montados sim, quando NODE_ENV não é production, ou com schemaEditor: true
Um bundle compilado: um self-host servindo uma build, ou a imagem publicada somente quando adere com REBASE_LIVE_SCHEMA_REPOSITORY — veja A partir de um bundle compilado
Qualquer servidor com liveSchema.repository configurado sim, NODE_ENV=production inclusive

Um bundle é uma saída compilada, portanto não há código-fonte de coleções nele, e o runtime que o serve desliga o editor somente de código-fonte. A menos que nomeie um repositório, /api/admin/schema/status responde enabled: false com o código SCHEMA_EDITOR_DISABLED: edite as coleções em desenvolvimento, faça o commit e o deploy.

Um bundle não tem código próprio para configurar, então adere por meio de variáveis de ambiente. Nada muda para um bundle que não as define.

REBASE_LIVE_SCHEMA_REPOSITORY=acme/storefront # turns it on
REBASE_LIVE_SCHEMA_GITHUB_TOKEN=github_pat_… # or the three _GITHUB_APP_* variables
# REBASE_LIVE_SCHEMA_BRANCH=main # default
# REBASE_LIVE_SCHEMA_COLLECTIONS_PATH=config/collections # default

A lista completa está em Configuração. Um repositório nomeado sem uma credencial utilizável (nenhuma, os dois tipos, metade de um GitHub App), ou um valor que não seja owner/name, se recusa a iniciar, em vez de servir um editor que falha quando alguém pressiona Apply.

O que permanece igual quando está ligado:

  • É uma superfície de /api/admin: nenhuma resposta sem schema:read, e /apply exige schema:write e uma pessoa (veja Quem pode aplicar).
  • O alvo da escrita é apenas o repositório que você nomeou. O editor somente de código-fonte, que grava arquivos ao lado do processo, permanece desligado.
  • Cada alteração é planejada, mostrada, comitada e então aplicada, exatamente como na sua máquina.

Uma coisa é diferente. A implantação em execução continua servindo as coleções com as quais foi construída até que o seu pipeline construa e faça o deploy do commit. O banco de dados recebe a alteração de imediato; o painel de administração e a API veem a nova definição da coleção depois desse deploy.

Por isso uma implantação faz uma alteração por deploy. Depois que um commit entra, o próximo apply é recusado com SCHEMA_CHANGE_FAILED, citando o commit, até que um processo executando esse commit substitua este. Uma segunda alteração planejada contra as coleções que esta implantação ainda executa comitaria arquivos de schema gerados que deixam a primeira alteração de fora. O planejamento continua disponível nesse meio tempo.

liveSchema.repository faz o servidor comitar cada alteração em um repositório do GitHub através da Git Data API, em vez de executar git commit em uma árvore de trabalho. Ele não depende de o editor somente de código-fonte estar ligado: com um repositório configurado, a edição ao vivo funciona sob NODE_ENV=production e com schemaEditor: false, porque nada nesta máquina é gravado. Um bundle o define pelas variáveis acima.

initializeRebaseBackend({
// …the rest of your config
liveSchema: {
repository: {
kind: "github",
owner: "acme",
repo: "storefront",
branch: "main",
// Where the collection source lives in that repository.
// Defaults to "config/collections".
collectionsPath: "config/collections",
auth: { kind: "token", token: process.env.GITHUB_TOKEN! }
}
}
})

O diretório de coleções é então lido do repositório, reescrito com o mesmo editor executado localmente, e o que mudou é comitado de volta através da Git Data API: um blob por arquivo, uma tree, um commit e uma atualização de ref. Nada é clonado e nada é deixado em disco. O diretório inteiro é lido, não apenas o arquivo editado, então uma relação pode apontar para outra coleção (o arquivo dela é importado) e excluir uma coleção funciona como localmente: recusado enquanto outra coleção a importa, e removida de index.ts no mesmo commit.

auth aceita um token ou uma instalação de GitHub App:

auth: {
kind: "app",
appId: "123456",
privateKey: process.env.GITHUB_APP_PRIVATE_KEY!,
installationId: "987654"
}

Use o token para um projeto único comitando em um repositório que você já possui — configurar um App para que seu próprio servidor possa comitar nele é muita burocracia para uma credencial de uma linha. Use o App para um plano de controle mantendo uma única chave para vários projetos, que é o que o Rebase Cloud faz: um App, uma instalação por projeto e nenhum segredo por cliente para rotacionar.

O token precisa da permissão contents: read and write nesse repositório, e nada mais.

Sem liveSchema.repository, o commit é um simples git commit na árvore de trabalho que contém collectionsDir: nada para autenticar, sem token, sem rede.

Duas coisas que tornam seguro executá-lo contra um repositório em que outra pessoa está trabalhando:

  • Ele adiciona à stage apenas os arquivos que gerou. Um commit de schema que incluísse trabalho pela metade seria um commit que ninguém conseguiria revisar, e ele se recusa expressamente se a árvore já tiver um de seus próprios arquivos modificado.
  • O caminho remoto nunca atualiza uma ref com force (force-update). Se algo entrou no repositório enquanto o commit estava sendo construído, a atualização é rejeitada — perder o commit de alguém silenciosamente é pior do que falhar.
  • Apenas alterações aditivas. Todo o resto é recusado com um motivo, porque o caminho ensure é a única coisa que altera um schema e ele só pode adicionar.
  • Nenhum arquivo de migração é gerado. Um projeto provisionado pelo boot-ensure não precisa de nenhum; um projeto provisionado por migrações deve executar rebase db generate, que cria um através do Atlas com o hash de integridade que o Atlas exige.
  • Apenas Postgres. A funcionalidade é detectada no driver, e outros mecanismos respondem SCHEMA_EDITING_UNSUPPORTED.