Salta ai contenuti

Modifica dello schema live

L’editor dello schema nel pannello di amministrazione riscrive il codice sorgente delle tue collection. Questo funziona sulla tua macchina e da nessun’altra parte: i file di un server distribuito vengono ricostruiti dal repository a ogni deploy, quindi una modifica effettuata lì verrebbe scartata a quello successivo.

La modifica dello schema live è la risposta a questo problema. Essa esegue il commit della modifica nel tuo repository, quindi applica il DDL — così la modifica sopravvive al deploy successivo, poiché quest’ultimo viene generato a partire da essa.

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 richiedono lo scope schema:read, e apply richiede schema:write, come qualsiasi altra interfaccia di /api/admin che indica uno scope. Un amministratore li possiede entrambi. L’applicazione richiede inoltre un elemento in più oltre allo scope — consulta Chi può applicare.

/plan non ha effetti collaterali. Invia tramite POST la modifica, e ti indicherà cosa comporta. Una modifica a una collection esistente è una patch — ciò che è cambiato, come operazioni su percorsi di chiavi — mentre una nuova collection è l’intero collection:

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

Solo le chiavi che una patch nomina vengono scritte nel file della collection. Tutto il resto resta com’è — import, commenti, formattazione, l’onClick di un’azione sull’entità, una proprietà condivisa da un altro modulo, un enum importato altrove. Una patch che si estende dentro qualcosa definito nel codice (status: statusProperty, enum: LOCALE_ENUM, uno ...spread) viene rifiutata con l’espressione su cui si è imbattuta, in modo che la modifica venga fatta dove risiede quel codice. Un intero collection inviato per una collection già esistente viene trasformato nella patch di ciò che differisce da essa, e una chiave il cui valore è codice non viene mai rimossa in questo modo. Il pannello di amministrazione invia patch. Sulla 0.23 /plan e /apply accettano solo l’intero collection come dovrebbe risultare alla fine.

$ADMIN_TOKEN è un token di accesso — l’accessToken restituito da un accesso — per un account che possiede schema:read: un amministratore, o un ruolo che lo dichiara. Nulla sulla macchina lo imposta per te.

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 è ogni file che l’apply include nel commit: il sorgente della collezione, quando il backend può scriverlo, poi lo schema generato.

Non si tratta di una comodità superflua. Due dei tre verdetti sono rifiuti, e uno di essi è un rifiuto che altrimenti scopriresti solo premendo il pulsante su un database attivo.

Verdetto Significato
safe Il percorso di ensure all’avvio è in grado di esprimerlo e il risultato corrisponde alla tua configurazione. Applicato.
diverges Si applica in parte, lasciando un database che non corrisponde alla tua configurazione — e nulla lo segnala. Rifiutato.
needs-migration Il percorso di ensure non è affatto in grado di esprimerlo. Rifiutato.

diverges è quello che vale la pena comprendere a fondo, perché queste modifiche sembrano essere andate a buon fine:

  • Una proprietà obbligatoria aggiunta a una tabella che contiene già righe arriva come nullable. NOT NULL viene verificato a fronte di ogni riga già presente, e le righe scritte prima che la proprietà esistesse non hanno alcun valore per essa. Su una tabella vuota non c’è nulla da verificare, quindi il vincolo viene applicato ed è considerato safe.
  • Rendere obbligatoria una proprietà esistente ha la stessa logica: SET NOT NULL scansiona la tabella, quindi è safe su una tabella vuota e diverges su una popolata finché non esegui un backfill.

Due modifiche che in passato risultavano diverges ora sono safe, poiché il percorso di ensure le esegue:

  • L’aggiunta di un valore a un enum esistente va a buon fine, tramite ALTER TYPE … ADD VALUE IF NOT EXISTS. In precedenza veniva ignorata insieme all’intero tipo, e la prima riga che utilizzava il nuovo valore veniva rifiutata da un tipo che non lo conosceva.
  • Rendere facoltativa una proprietà obbligatoria rimuove il vincolo NOT NULL. In precedenza veniva lasciato invariato, quindi le scritture che omettevano la proprietà continuavano a fallire.

Una modifica può essere applicabile e lasciare comunque non applicato qualcosa richiesto dalla tua configurazione — come nel caso di una proprietà obbligatoria su una tabella popolata. Questo non costituisce un rifiuto, pertanto non compare in changes; appare invece in withheldConstraints, con l’ostacolo e ciò che lo risolverebbe:

{
"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."
}
]
}

Il percorso di ensure all’avvio segnala la stessa cosa come avviso. Prima dell’introduzione di questa funzionalità, un vincolo trattenuto veniva omesso in silenzio.

needs-migration copre tutto ciò che il percorso di ensure non può fare: eliminare una collection o una proprietà, modificare il tipo di una colonna (un toggle su un intero, una stringa che diventa un enum, il tipo di elemento di un array, la larghezza di un varchar), rinominare una colonna, cambiare una chiave primaria, rimuovere un valore di enum, rendere univoca una colonna esistente, e modificare una relazione — il suo kind, il suo target, il suo localKey, il suo onDelete. Viene rifiutata anche una hasMany o hasOne la cui colonna di collegamento non è creata da nulla. Ogni rifiuto specifica la modifica e cosa fare in alternativa.

Il verdetto viene letto dallo schema che ciascun lato produce — lo stesso piano a partire dal quale vengono generati schema.generated.ts e db push — quindi una modifica che cambia il database non può essere segnalata come nessuna modifica. Due modifiche che sembrano cambiamenti e non vengono rifiutate (; la 0.23 segnala entrambe come richiedenti una migrazione):

  • Rinominare la chiave di una proprietà mantenendo la sua colonna (columnName impostato sul vecchio nome della colonna) non sposta alcun dato. È safe; i client dell’API leggono il nuovo nome.
  • Impostare, modificare o eliminare un default vincola solo le scritture future. È safe, e viene applicato con ALTER COLUMN … SET DEFAULT / DROP DEFAULT.

Una modifica rifiutata può comunque essere scritta nella sorgente della tua collection e committata, lasciando il database com’è — rimuovere una proprietà che non servi più è il caso tipico. Invia /apply con "sourceOnly": true. Non viene eseguito nulla; il messaggio di commit specifica cosa mantiene il database, ad esempio chore(schema): remove sku from products (source only — column products.sku kept), e ogni modifica nel piano porta una frase sourceOnly che indica cosa lascia indietro — compreso il caso in cui una colonna lasciata indietro è NOT NULL senza default, il che fa fallire ogni inserimento successivo finché non viene eliminata o resa nullable. Una modifica senza una frase di questo tipo (spostare una chiave primaria, una relazione la cui colonna di collegamento non è creata da nulla) non può essere scritta nella sola sorgente.

L’eliminazione di una collection dal pannello di amministrazione segue lo stesso percorso: /apply con "remove": true e "sourceOnly": true elimina il file della collection e la sua voce in index.ts e committa entrambi; la tabella e le sue righe restano. Viene rifiutata mentre un’altra collection importa il file (una relazione verso di essa), nominando l’importatore — eliminarlo interromperebbe il caricamento di ogni collection.

Sulla 0.23 /apply non accetta né sourceOnly né remove, e “Edit source only” nel pannello scrive il file senza eseguire un commit.

Non solo il file della collection. Da esso viene generato lo schema Drizzle, e uno schema non aggiornato compromette il deploy successivo, quindi entrambi finiscono nello stesso commit:

  • config/collections/<name>.ts — la collection stessa
  • backend/src/schema.generated.ts — lo schema Drizzle

Questi percorsi sono relativi al tuo progetto, non al tuo repository. Quando i due coincidono — un progetto rebase init, che rappresenta il caso comune — non c’è nulla di cui preoccuparsi. Quando il progetto si trova in una sottodirectory di un repository più grande, i percorsi includono il relativo prefisso, individuato risalendo dalla directory delle collection fino al rebase.json più vicino. Un progetto senza rebase.json mantiene i percorsi semplici.

Nel commit non finisce alcun SQL. rebase db push e rebase db generate scrivono il proprio a partire dalle collection a ogni esecuzione, in .rebase/sql/, che è in gitignore. Sulla 0.23 il commit porta anche drizzle/schema.sql, drizzle/policies.sql e drizzle/search.sql, scritti nella root del progetto.

Il messaggio di commit descrive la modifica piuttosto che limitarsi ad annunciarne una, ed è attribuito alla persona che l’ha effettuata, con il nome visualizzato e l’email dell’account (un utente senza nessuno dei due viene registrato come <uid>@users.noreply.rebase.pro). Da 0.25 Su 0.24 i commit dell’autenticazione integrata riportano l’uid come nome.

Una modifica dello schema con un autore e un diff nella cronologia del tuo progetto è qualcosa che né Firebase né Supabase offrono: le modifiche alle loro tabelle sono invisibili al tuo repository.

Possedere schema:read è sufficiente per eseguire plan. La pianificazione non ha effetti collaterali, e un job CI che verifichi se una modifica proposta per una collection sia applicabile ne rappresenta un ottimo caso d’uso.

L’applicazione è un privilegio distinto, poiché applicare scrive un commit e un commit reca con sé un autore:

Chiamante Plan Apply
Una persona autenticata che possiede schema:write sì sì
Una chiave API che possiede schema:read / schema:write sì no
La service key del server sì no

Una credenziale non è un autore. api-key:7c3f… nel tuo ambiente di CI non è una persona fisica, e consentirle di scrivere nel repository produrrebbe esattamente quella cronologia non attribuibile che questa funzionalità è stata concepita per rimpiazzare.

Sulla 0.23 la linea di confine è il ruolo admin: un admin pianifica e applica, e qualsiasi chiave API può pianificare.

Se ciò che desideri è una modifica automatizzata dello schema — ad esempio una pipeline di migrazione — attivala esplicitamente:

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

oppure REBASE_LIVE_SCHEMA_ALLOW_MACHINE_APPLY=true. Il commit viene quindi attribuito alla credenziale per nome — Rebase API key (7c3f) — in modo tale che, leggendo il git log a distanza di un mese, sia ancora possibile distinguere le modifiche apportate da una persona.

GET /api/admin/schema/status segnala ciò che tu sei autorizzato a fare, non soltanto ciò che il server supporta, consentendo così a un pannello di disabilitare il controllo spiegandone il motivo, invece di rifiutare l’operazione dopo che hai già deciso di procedere:

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

Se il tuo progetto gestisce migrazioni con controllo di versione

Sezione intitolata “Se il tuo progetto gestisce migrazioni con controllo di versione”

L’applicazione in questo contesto non scrive una migrazione, e non può farlo: una migrazione adotta il formato di Atlas con un file di integrità, generato da un binario esterno a fronte di un database usa e getta, e un server in esecuzione non dispone di nessuno dei due.

Ciò che invece committa è la collection, ed è a partire da essa che rebase db generate scrive la migrazione. Di conseguenza, la migrazione è a portata di un solo comando:

rebase db generate

Sia il plan sia il risultato lo segnalano esplicitamente quando il progetto prevede migrazioni, poiché in caso contrario il problema passerebbe inosservato: il database conterrebbe la modifica e il repository la descriverebbe, ma l’ambiente successivo configurato rieseguendo le migrazioni non la conterrebbe, senza alcun avviso.

Un progetto configurato tramite boot-ensure — il runtime gestito e qualsiasi self-host che mantenga REBASE_MIGRATE_ON_BOOT sul valore predefinito — non necessita di alcuna migrazione. Le sue collection costituiscono lo schema, e l’avvio successivo si occupa della riconciliazione.

L’ordine è importante e non è arbitrario.

Se il DDL venisse eseguito per primo e il commit fallisse, il database avrebbe una colonna non descritta dal repository. Il percorso di ensure non elimina mai nulla, quindi il deploy successivo non la rimuoverebbe né la segnalerebbe — una colonna invisibile, assente dalle tue collection, finché qualcuno non andasse a cercarla.

Eseguire prima il commit fallisce nel modo opposto: il repository descrive qualcosa che il database non possiede ancora. Questo è lo stato ordinario di ogni progetto nell’intervallo tra una modifica e un deploy, e la fase di boot lo riconcilia all’avvio successivo.

Pertanto, un’applicazione fallita non è un errore. La risposta lo indica chiaramente:

{
"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."
}

La modifica live dello schema riscrive il sorgente delle tue collection con lo stesso editor usato dai salvataggi del pannello che toccano solo il sorgente (richiede che ts-morph sia installato). Da dove arriva quel sorgente decide dove funziona:

  • Su disco (collectionsDir, l’impostazione predefinita): funziona dove funziona l’editor che tocca solo il sorgente. Quell’editor è disattivato con NODE_ENV=production a meno che il server non imposti schemaEditor: true.
  • In un repository che indichi (liveSchema.repository, oppure REBASE_LIVE_SCHEMA_REPOSITORY per un bundle): il sorgente viene letto da GitHub e la modifica viene committata di nuovo lì, quindi funziona ovunque, produzione compresa. È l’opt-in per un bundle compilato.

Tutto quanto descritto sopra riguarda Postgres, dove una modifica dello schema si traduce in istruzioni DDL. Su MongoDB non ci sono tabelle da alterare: l’aggiunta di una proprietà non aggiunge nulla, la sua rimozione non rimuove nulla, e un documento scritto ieri rimane valido anche domani.

Di conseguenza, ogni modifica è applicabile, nulla viene mai rifiutato e il piano non contiene istruzioni — il commit è la modifica stessa. Il pannello mostra “Commit” anziché “Commit and apply”, e non asserisce che sia stato eseguito alcunché sul database.

L’unico aspetto che merita un’attenta lettura è la rimozione. Su Postgres la rimozione di una proprietà viene rifiutata perché comporterebbe l’eliminazione di una colonna. Su MongoDB il campo rimane in ogni documento che lo contiene; la tua API smette semplicemente di restituirlo. La modifica lo esplicita chiaramente, evitando di lasciare presumere il comportamento tipico dei database relazionali.

Distribuzione Funziona
rebase dev sulla tua macchina sì
Self-host con il progetto e il suo working tree git montati sì, quando NODE_ENV non è production, oppure con schemaEditor: true
Un bundle compilato: un self-host che serve una build, oppure l’immagine pubblicata solo se fa opt-in con REBASE_LIVE_SCHEMA_REPOSITORY — vedi Da un bundle compilato
Qualsiasi server con liveSchema.repository configurato sì, NODE_ENV=production compreso

Un bundle è un output compilato, quindi non contiene il sorgente delle collection, e il runtime che lo serve disattiva l’editor che tocca solo il sorgente. Se non indica un repository, /api/admin/schema/status risponde enabled: false con il codice SCHEMA_EDITOR_DISABLED: modifica le collection in sviluppo, fai il commit e distribuisci.

Un bundle non ha codice proprio da configurare, quindi fa opt-in con variabili d’ambiente. Nulla cambia per un bundle che non le imposta.

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

L’elenco completo è in Configurazione. Un repository indicato senza una credenziale utilizzabile (nessuna, entrambi i tipi, metà di una GitHub App), o un valore che non è owner/name, si rifiuta di avviarsi, anziché servire un editor che fallisce quando qualcuno preme Applica.

Cosa resta uguale quando è attivo:

  • È una superficie /api/admin: nessuna risposta senza schema:read, e /apply richiede schema:write e una persona (vedi Chi può applicare).
  • L’unica destinazione di scrittura è il repository che hai indicato. L’editor che tocca solo il sorgente, che scrive file accanto al processo, resta disattivato.
  • Ogni modifica viene pianificata, mostrata, committata e poi applicata, esattamente come sulla tua macchina.

Una cosa è diversa. Il deployment in esecuzione continua a servire le collection con cui è stato compilato finché la tua pipeline non compila e distribuisce il commit. Il database ha la modifica subito; il pannello di amministrazione e l’API vedono la nuova definizione della collection dopo quel deploy.

Quindi un deployment fa una sola modifica per deploy. Dopo che un commit è stato scritto, la successiva applicazione viene rifiutata con SCHEMA_CHANGE_FAILED, indicando il commit, finché un processo che esegue quel commit non sostituisce questo. Una seconda modifica pianificata sulle collection che questo deployment esegue ancora farebbe il commit di file di schema generati che escludono la prima. La pianificazione resta disponibile nel frattempo.

liveSchema.repository fa sì che il server faccia il commit di ogni modifica su un repository GitHub tramite le Git Data API, invece di eseguire git commit in un working tree. Non dipende dal fatto che l’editor che tocca solo il sorgente sia attivo: con un repository configurato, la modifica live funziona con NODE_ENV=production e con schemaEditor: false, perché nulla su questa macchina viene scritto. Un bundle lo imposta tramite le variabili sopra.

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! }
}
}
})

La directory delle collection viene quindi letta dal repository, riscritta con lo stesso editor utilizzato in locale e ciò che è cambiato viene committato nuovamente tramite le Git Data API: un blob per file, un tree, un commit e l’aggiornamento di un ref. Nulla viene clonato e nulla viene lasciato su disco. Viene letta l’intera directory, non solo il file modificato, quindi una relazione può avere come destinazione un’altra collection (il suo file viene importato) e l’eliminazione di una collection funziona come in locale: rifiutata finché un’altra collection la importa, e rimossa da index.ts nello stesso commit.

auth accetta un token o un’installazione di una GitHub App:

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

Utilizza il token per un singolo progetto che esegue commit su un repository già di tua proprietà — configurare un’App solo per permettere al tuo server di committare richiede troppa complessità burocratica per una credenziale su una sola riga. Utilizza invece l’App per un control plane che gestisce un’unica chiave condivisa tra più progetti, esattamente come fa Rebase Cloud: una sola App, un’installazione per ciascun progetto e nessun segreto specifico per singolo cliente da ruotare.

Il token richiede il permesso contents: read and write su quel repository, e nient’altro.

Senza liveSchema.repository, il commit è un semplice git commit nel working tree che contiene collectionsDir: nulla da autenticare, nessun token, nessuna rete.

Due aspetti rendono sicura l’esecuzione a fronte di un repository su cui sta lavorando qualcun altro:

  • Esegue lo stage dei soli file che ha generato. Un commit dello schema che inglobasse lavori a metà sarebbe impossibile da revisionare; pertanto, l’operazione viene categoricamente rifiutata se nell’albero risulta già modificato uno dei propri file.
  • Il percorso remoto non esegue mai un aggiornamento forzato di un ref. Se qualcosa è stato inviato mentre il commit veniva generato, l’aggiornamento viene rifiutato: perdere silenziosamente il commit di qualcuno è peggio di un fallimento dell’operazione.
  • Solo modifiche additive. Tutto il resto viene rifiutato con una motivazione, poiché il percorso di ensure è l’unico elemento a modificare uno schema e può eseguire solo aggiunte.
  • Non viene scritto alcun file di migrazione. Un progetto configurato tramite boot-ensure non ne ha bisogno; un progetto configurato con migrazioni dovrebbe eseguire rebase db generate, che ne genera uno tramite Atlas con l’hash di integrità richiesto da Atlas.
  • Solo Postgres. La funzionalità viene rilevata sul driver, e gli altri motori rispondono con SCHEMA_EDITING_UNSUPPORTED.