Salta ai contenuti

Soft delete

Con softDelete attivo, un’eliminazione contrassegna una colonna invece di rimuovere la riga, e ogni operazione di lettura esclude le righe contrassegnate. Tutto il resto dell’operazione rimane invariato: è richiesta la stessa autorizzazione, beforeDelete può ancora porre il veto e afterDelete viene comunque eseguito. Dal punto di vista del chiamante la riga è stata eliminata; il modo in cui la tabella registra l’operazione dipende da questo flag.

import { defineCollection } from "@rebasepro/cms-types";
const invoices = defineCollection({
slug: "invoices",
name: "Invoices",
table: "invoices",
softDelete: true,
properties: {
reference: { name: "Reference", type: "string" },
deletedAt: { name: "Deleted at", type: "date", admin: { readOnly: true } }
}
});

true utilizza deletedAt (colonna deleted_at). La forma a oggetto consente di rinominarlo: softDelete: { field: "archivedAt" }.

Solo Postgres, come la ricerca e gli indici.

Le righe contrassegnate sono nascoste per impostazione predefinita da find, findById, count, dalle aggregazioni, dal refetch in tempo reale e da questa collection caricata tramite una relazione. Tale comportamento predefinito è fondamentale: il codice scritto prima dell’introduzione del flag continua a funzionare e nessuno deve ricordarsi di filtrare.

Due parametri di query consentono di accedervi:

Parametro Risultati
?deleted=include Righe attive e quelle contrassegnate
?deleted=only Solo le righe contrassegnate — la vista cestino

Qualsiasi altro valore restituisce un 400 invece di un fallback silenzioso. ?deleted=true che nasconde silenziosamente ogni riga eliminata sembrerebbe funzionare, ma risponderebbe alla domanda opposta.

Un ripristino è un normale aggiornamento che reimposta il campo su null. Non esiste un verbo speciale, perché non esiste uno stato speciale: la riga non è mai stata spostata altrove. Tramite REST è un PATCH — singolo, /bulk o _batch — il cui body imposta il campo su null:

curl -X PATCH /api/data/invoices/7 -d '{"deletedAt": null}'

Quell’aggiornamento è l’unico che raggiunge una riga contrassegnata. Qualsiasi altra modifica di essa restituisce 404, come fa la riga per ogni lettura predefinita; ripristinala prima.

L’operazione inversa non è un aggiornamento. Impostare il campo su un valore viene rifiutato con 400 FIELD_NOT_WRITABLE — elimina invece la riga — così il permesso delete, beforeDelete e afterDelete si applicano sempre. Un upsert può creare una riga già contrassegnata, ma non contrassegna mai una riga già memorizzata.

Né un upsert scrive in una riga nel cestino: quando la sua chiave appartiene a una riga contrassegnata, viene rifiutato con 409 ROW_IN_TRASH e non viene scritto nulla. Ripristina prima la riga, oppure elimina definitivamente con ?hard=true e ripeti l’upsert. Sulla 0.23 l’upsert scrive i suoi valori nella riga nascosta e risponde 201.

Un’eliminazione tramite un percorso many-to-many, come DELETE /api/data/posts/1/tags/5, rimuove il collegamento del post 1 al tag. Non esegue il soft delete del tag, che altri post utilizzano ancora.

Una vera DELETE corrisponde a ?hard=true nella chiamata di eliminazione. Richiede esattamente gli stessi permessi di un’eliminazione ordinaria: si tratta dello stesso verbo e gestirlo separatamente creerebbe una seconda superficie di controllo accessi per la medesima operazione. Ciò che cambia è la possibilità che la riga venga recuperata. Solo il valore letterale true o 1 significa sì; un errore di digitazione produce un 400, poiché un chiamante che ha richiesto di eliminare definitivamente e ha ottenuto un soft delete crederebbe erroneamente che i dati siano spariti.

Il client tipizzato raggiunge il cestino tramite trash su ogni collection. Invia le richieste sopra descritte: ?deleted=only sulle letture, l’aggiornamento a null per un ripristino, e ?hard=true per un’eliminazione definitiva.

const trashed = await client.data.invoices.trash.find({ limit: 20 });
const howMany = await client.data.invoices.trash.count();
await client.data.invoices.trash.restore(7); // PATCH { deletedAt: null }
await client.data.invoices.trash.restoreMany([8, 9]);
await client.data.invoices.trash.purge(7); // DELETE ?hard=true
await client.data.invoices.trash.purgeMany([8, 9]);

trash.find e trash.count accettano gli stessi parametri di find e count. Un ripristino imposta deletedAt a meno che tu non nomini il campo: una collection dichiarata con softDelete: { field: "archivedAt" } si ripristina con trash.restore(7, { field: "archivedAt" }). Il client non può leggere il nome del campo dal server.

Usa trash su una collection che dichiara softDelete. Su una che non lo fa, il server ignora ?deleted=, quindi trash.find restituisce le righe attive e trash.purge è un’eliminazione ordinaria.

Il codice server ha lo stesso trash: context.data.invoices.trash in una callback, e rebase.data.invoices.trash in una function o in un cron. Legge ed elimina definitivamente tramite il driver, con i privilegi del chiamante, come fa il resto di context.data.

Con offline abilitato, il cestino non viene mai messo in cache e nulla al suo interno viene accodato. Ogni chiamata richiede il server, e fallisce con l’errore OFFLINE senza di esso. Una riga ripristinata entra nel database locale, e una eliminata definitivamente ne esce.