Salta ai contenuti

Scrittura tramite REST

I verbi sono descritti nella pagina REST API. Questa pagina riguarda le cinque cose che una scrittura può richiedere oltre al proprio verbo, e l’endpoint che scrive su più collezioni contemporaneamente. Tutte queste opzioni sono attivabili per singola richiesta: una scrittura che non ne fa richiesta si comporta esattamente come ha sempre fatto.

Oltre ai verbi, le rotte di scrittura accettano cinque parametri che modificano il comportamento di una scrittura. Tutti e cinque sono facoltativi su base richiesta, quindi nulla di quanto descritto qui altera il comportamento di una richiesta che non ne fa uso. Le rotte annidate (/api/data/authors/42/posts) eseguono lo stesso codice di scrittura di quelle radice, quindi tutto ciò che è in questa sezione vale anche per loro, tranne ?on_conflict=.

Il corpo di una scrittura deve essere un oggetto JSON. null, un numero, una stringa o un array restituisce 400 BAD_REQUEST.

Un aggiornamento non può cambiare la chiave di una riga. Un corpo che nomina la chiave con un altro valore restituisce 400 KEY_IMMUTABLE, prima che venga eseguito qualsiasi hook e senza che nulla venga scritto, indipendentemente dal canale con cui arriva l’aggiornamento: REST, il socket realtime, MCP o rebase.data in-process. La chiave che la riga ha già viene accettata, come fa un form che rimanda l’intera riga. Per spostare una riga su una nuova chiave, creala in quella posizione ed elimina la vecchia. Sulla 0.23 un aggiornamento di questo tipo risponde 500, e uno in-process ha già spostato la riga quando viene sollevata l’eccezione.

Idempotency-Key: <uuid> su qualsiasi scrittura significa “se hai già risposto a questa identica richiesta, rispondi di nuovo invece di eseguirla due volte”.

curl -X POST /api/data/orders \
-H "Idempotency-Key: 1f0f…" \
-d '{"total": 40}'

Un client che non riceve risposta non può sapere se la scrittura sia stata confermata o meno, quindi ritenta — e senza una chiave il server non può distinguere quel tentativo da una seconda scrittura autentica. Su una tabella con ID assegnato dal server, ciò genera una riga duplicata, poiché l’ID generato dal client non è mai stato utilizzato.

Una chiave identifica una sola richiesta: registra il metodo, il percorso, la query string e il corpo per cui è stata dichiarata. Inviando nuovamente la stessa identica richiesta, la risposta viene riprodotta; inviando una richiesta diversa con la stessa chiave, questa verrà rifiutata con IDEMPOTENCY_KEY_REUSED (422) anziché rispondere con il risultato della prima. Un nuovo tentativo che arriva mentre la prima richiesta è ancora in corso riceve IDEMPOTENCY_KEY_IN_PROGRESS (409) — invialo di nuovo una volta completata la prima. La query string conta perché cambia ciò che fa una richiesta: DELETE /api/data/posts/5?hard=true con la chiave di una precedente eliminazione logica è una richiesta diversa, non una ripetizione. L’ordine dei suoi parametri non conta.

È supportata su POST, PATCH, DELETE, su tutte e tre le rotte /bulk e su /_batch. Le chiavi rimangono valide per 24 ore e sono associate al chiamante autenticato; una richiesta non autenticata non ha un’identità a cui essere associata, quindi in tal caso l’intestazione viene ignorata. Un backend che non può memorizzare le chiavi ignora l’intestazione anziché rifiutare la scrittura.

DELETE è il caso che merita particolare attenzione. Se ritentato senza una chiave, il secondo tentativo trova la riga già eliminata e risponde con 404 — cosa che un client in fase di retry interpreta come un errore permanente per un’eliminazione che in realtà è andata a buon fine. Con una chiave, viene invece restituito nuovamente il 204.

GET /api/data/:slug/:id restituisce un ETag, e lo stesso fa il GET annidato di una singola riga. Inviandolo nuovamente come If-Match in una successiva richiesta PATCH o DELETE, la scrittura viene rifiutata con 412 se la riga è stata modificata nel frattempo.

Da 0.25 Un PATCH riuscito risponde con l’ETag della versione che ha scritto, così la successiva scrittura condizionale non richiede una lettura preventiva. Il tag viaggia anche su un 204 risposto per Prefer: return=minimal, e una risposta riproposta per un Idempotency-Key porta lo stesso tag della prima. Una sottoscrizione live a una riga porta lo stesso tag su ogni frame (vedi Realtime).

# read
curl -i /api/data/docs/d1
# → ETag: "9f2c…"
# write, conditionally
curl -X PATCH /api/data/docs/d1 \
-H 'If-Match: "9f2c…"' \
-d '{"title": "Second draft"}'
# → 412 PRECONDITION_FAILED if somebody else edited it first

Senza di esso, il pattern read-modify-write segue la logica last-writer-wins per tutto ciò che la seconda scrittura non ha inviato: due modifiche a un secondo di distanza hanno entrambe successo e la modifica della prima viene persa senza alcun messaggio di errore.

Il tag deriva da una proprietà date con autoValue: "on_update" quando la collezione ne dichiara una — quella colonna già è una versione — e da un hash stabile della riga in caso contrario. If-Match: * verifica solo che la riga esista. Se la precondizione fallisce, non viene scritto nulla.

Da 0.25 Il form dei record dell’admin salva con If-Match: un salvataggio fatto su una versione che qualcuno ha nel frattempo sostituito viene rifiutato, il form lo dice, e la modifica resta nel form, riapplicata sulla nuova versione con i campi in conflitto evidenziati.

Il tag viene confrontato all’interno della transazione della scrittura stessa, dopo che la riga è stata bloccata. Due scritture con lo stesso tag non possono passare entrambe: la seconda attende il commit della prima, legge ciò che ha scritto e viene rifiutata con 412.

Nel corpo di una richiesta PATCH, il valore di una proprietà può essere un’operazione sul valore memorizzato anziché un valore statico:

curl -X PATCH /api/data/posts/p1 -d '{
"views": { "$inc": 1 },
"tags": { "$push": "featured" },
"meta": { "$merge": { "seen": true } }
}'
Operatore Tipo di proprietà Diventa
$inc number SET col = COALESCE(col, 0) + n
$push array array_append(col, …), o una concatenazione jsonb
$pull array array_remove(col, …), o una riaggregazione jsonb
$merge map `col

Il vantaggio principale è la lettura che il chiamante non deve più effettuare. Esprimere views + 1 come valore statico comporta doverlo prima leggere, e due richieste che leggono ciascuna 4, incrementano di uno e scrivono 5 finiranno per impostare 5 — senza che nessuna delle due risposte segnali che un incremento è andato perso. Compilata direttamente nell’istruzione, l’operazione aritmetica avviene all’interno del lock di riga e non può fallire.

È consentito esattamente un solo operatore per campo. Un operatore applicato a un tipo di proprietà non supportato, un $operator sconosciuto o un operando con una struttura errata restituisce un 400 (INVALID_FIELD_OPERATION) che indica il campo: un errore di battitura non viene mai salvato nella colonna come documento JSON.

Una map è l’eccezione, perché il suo valore è un documento JSON e può avere chiavi $ proprie — $schema e $id di un JSON Schema, un export di MongoDB. Su una map, solo un oggetto con esattamente una chiave che sia uno degli operatori sopra è un’operazione; qualunque altro oggetto viene salvato così com’è inviato, chiavi $ comprese. Quindi { "meta": { "$merge": { … } } } fa un merge, e { "meta": { "$schema": "https://json-schema.org/draft/2020-12/schema" } } viene salvato. Un operatore scritto male su una map viene quindi salvato anch’esso; su ogni altro tipo viene ancora rifiutato. Il client offline non ha uno schema per distinguere una map da qualsiasi altra cosa, quindi da offline rifiuta un tale valore come operazione sul campo; invialo quando la connessione è tornata.

Le operazioni si applicano solo agli aggiornamenti: su una riga che non esiste ancora non c’è nulla su cui operare, pertanto vengono rifiutate su POST, sulle creazioni con /bulk e sugli upsert.

Un’operazione risponde alla validation della proprietà come un valore. Gli elementi aggiunti sono giudicati dalle regole dell’elemento, le chiavi unite dalle proprietà della mappa e il valore che un’operazione produce dai limiti dichiarati: min, max, moreThan, lessThan, positive e negative di un numero, il numero minimo e massimo di elementi (min, max) di un array. Quest’ultimo controllo è una condizione dello stesso UPDATE, non una lettura precedente. Su stock: { validation: { min: 0 } }, { "stock": { "$inc": -1 } } riesce finché lo stock è almeno 1 e viene rifiutato a 0 con lo stesso 400 (VALIDATION_CONSTRAINT) che riceve { "stock": -1 }, indicando il campo e il limite. Due decrementi concorrenti di uno stock pari a 1 non possono riuscire entrambi, perché il secondo è giudicato sulla riga confermata dal primo. Un numero non impostato vale 0 e un array non impostato è vuoto. Un’operazione rifiutata non scrive nulla, nemmeno i valori semplici che la accompagnano nel corpo. Poiché il controllo fa parte dell’istruzione, vale anche per le scritture interne al processo tramite rebase.data, che saltano la validazione a livello di richiesta.

POST /api/data/:slug?on_conflict=sku inserisce la riga, oppure aggiorna quella che contiene già quello SKU, invece di fallire su di essa. La rotta bulk accetta lo stesso target come onConflict insieme a upsert: true, e lo stesso vale per ogni operazione di upsert all’interno di un batch.

curl -X POST '/api/data/products?on_conflict=sku' \
-d '{"sku": "LAMP-01", "name": "Desk lamp", "price": 39}'
curl -X POST /api/data/products/bulk -d '{
"rows": [ … ],
"upsert": true,
"onConflict": ["tenant_id", "sku"]
}'

Il target deve disporre di un vincolo di unicità su cui il database possa effettuare la corrispondenza: la chiave primaria (il valore predefinito se non ne viene specificato alcuno), una proprietà con validation: { unique: true }, oppure le colonne di un indice unique: true. Qualsiasi altra indicazione restituisce un 400 (INVALID_CONFLICT_TARGET) che elenca i target effettivamente disponibili — in caso contrario, Postgres risponderebbe con there is no unique or exclusion constraint matching the ON CONFLICT specification dall’interno di una transazione che ha già eseguito delle operazioni.

Anche specificare un target senza upsert: true in una scrittura bulk genera un 400: ignorarlo silenziosamente trasformerebbe un’importazione rieseguibile in una che genera duplicati.

?on_conflict= viene rifiutato su una creazione annidata (INVALID_CONFLICT_TARGET). La riga che troverebbe potrebbe trovarsi sotto un altro parent, e l’upsert la sposterebbe sotto questo. Invia l’upsert alla rotta propria della collezione.

Un upsert che incontra una riga memorizzata è un aggiornamento di quella riga. Il server legge la riga indicata dalla chiave, nell’ambito del chiamante e all’interno della transazione della scrittura, ed esegue l’aggiornamento ordinario: i campi presenti nel corpo vengono scritti, quelli che omette mantengono i valori memorizzati anziché essere reimpostati al loro defaultValue, beforeSave e afterSave vedono status: "existing" con i valori precedenti, e la cronologia registra un aggiornamento. Una riga che la lettura non ha potuto vedere, o una inserita in modo concorrente, viene comunque intercettata da ON CONFLICT … DO UPDATE, che imposta soltanto ciò che hanno scritto il corpo e gli hook, più i timestamp on_update. Una chiave che indica una riga al di fuori dell’ambito beforeQuery del chiamante risponde 404. Una chiave che appartiene a una riga nel cestino risponde 409 ROW_IN_TRASH e non scrive nulla: ripristina prima la riga, oppure eliminala definitivamente.

Una riga già esistente mantiene il proprio timestamp on_create e il proprio creatore user_on_create. Un conflitto implica che la creazione della riga sia un evento passato, e una reimportazione notturna che reimpostasse createdAt su ogni record toccato comprometterebbe qualsiasi query basata su “nuovi di questa settimana”.

Since 0.25 La collection degli utenti (quella con auth attivo) rifiuta un upsert da ogni porta — ?on_conflict=, /bulk con upsert: true, un’operazione upsert di _batch, l’upsert() dell’SDK e il socket realtime — con un 400 (INVALID_CONFLICT_TARGET). Le sue righe sono gli utenti, e creare un utente e modificarlo seguono regole diverse: una creazione esegue l’hash della password, normalizza l’email, esegue onCreateUser e invia l’invito, e un aggiornamento non può declassare l’ultimo amministratore. Un upsert scopre quale dei due è solo dentro la scrittura, troppo tardi per applicare l’una o l’altra. Crea gli utenti con un semplice POST e modificali con PATCH.

Ogni scrittura risponde per impostazione predefinita con la riga completa, che include i valori determinati dal server — un ID seriale, un timestamp generato da autoValue o qualsiasi cosa riscritta da beforeSave. Invia Prefer: return=minimal quando questi dati non sono necessari:

curl -X POST /api/data/events \
-H "Prefer: return=minimal" \
-d '{"kind": "page_view"}'
# → 204 No Content, Preference-Applied: return=minimal

Una singola scrittura risponde con 204. Una scrittura /bulk o /_batch risponde con 200 restituendo gli id invece delle intere righe — in una creazione, l’ID è l’unico dato che il chiamante non può calcolare, quindi scartarlo significherebbe dover rileggere la tabella tramite una chiave naturale per scoprire cosa è stato appena scritto. Una chiave a colonna singola viene restituita come valore scalare; una chiave composita come oggetto contenente le relative colonne.

POST /api/data/_batch esegue scritture su più collezioni in un’unica transazione, con il ruolo del chiamante, applicando le stesse validazioni, callback e sicurezza a livello di riga (row-level security) previste per le singole rotte.

POST /api/data/_batch
{
"operations": [
{ "op": "create", "collection": "orders",
"values": { "total": 40 }, "ref": "order" },
{ "op": "create", "collection": "order_items",
"values": { "order_id": { "$ref": "order.id" }, "sku": "A-1" } },
{ "op": "update", "collection": "stock",
"id": "A-1", "values": { "count": { "$inc": -1 } } },
{ "op": "delete", "collection": "carts", "id": "c-9" }
]
}
{
"data": [ { "id": 31, "total": 40 }, { "id": 88, … }, { … }, null ],
"meta": { "operations": 4 }
}

op può essere create, update, upsert o delete. update e delete richiedono un id; create, update e upsert richiedono values; upsert può specificare un target onConflict alle stesse condizioni descritte sopra. L’array data corrisponde a operations — contenendo la riga scritta per create, update o upsert, e null per delete — di conseguenza, un indice nell’uno corrisponde allo stesso indice nell’altro.

$ref: fare riferimento a una riga creata nello stesso batch

Sezione intitolata “$ref: fare riferimento a una riga creata nello stesso batch”

Un’operazione può assegnarsi un identificativo con ref, e qualsiasi operazione successiva può inserire { "$ref": "<name>.<field>" } al posto di un valore — in values a qualsiasi livello di annidamento, o come id. Questo verrà risolto con il valore di quel campo nella riga scritta dall’operazione a cui fa riferimento.

Questo è il motivo principale per cui l’endpoint esiste invece di essere un semplice ciclo: la chiave esterna (foreign key) di un record figlio non è nota finché il genitore non è stato inserito; senza questo meccanismo, genitore e figli dovrebbero essere richieste separate — esattamente la sequenza che rischia di avere successo solo a metà. Vengono risolti solo i riferimenti all’indietro (backward); un riferimento in avanti viene rifiutato prima ancora che la transazione venga avviata.

Cosa viene verificato prima di scrivere qualsiasi dato

Sezione intitolata “Cosa viene verificato prima di scrivere qualsiasi dato”

Formato, collezioni sconosciute, campi sconosciuti, vincoli sui valori, operazioni sui campi, target di conflitto e raggiungibilità di $ref vengono tutti convalidati prima che la transazione abbia inizio. Un batch è “tutto o niente”, e trovare un errore di battitura all’operazione 40 comporterebbe altrimenti il rollback delle 39 scritture precedenti.

Il limite massimo di operazioni è lo stesso di una scrittura bulk (1000 per impostazione predefinita), poiché un batch mantiene attivi i propri lock per l’intera durata della transazione. I callback di ogni operazione vengono eseguiti al suo interno, uno dopo l’altro, quindi un hook lento si paga una volta per riga con la transazione aperta: 100 righe il cui beforeSave attende 50 ms tengono una connessione, e i lock di ogni riga scritta finora, per 5,3 s. Lo stesso vale per una scrittura /bulk. Vedi Un callback tiene una connessione. Un driver che non può rendere il batch atomico risponde con BATCH_UNSUPPORTED invece di ricorrere a un ciclo di scritture singole — il che non garantirebbe né l’atomicità né il singolo round-trip per cui si utilizza un batch.

Un’operazione di update o delete che fa riferimento a una riga inesistente fa fallire l’intero batch con un 404, per lo stesso motivo per cui una scrittura parziale viene rifiutata ovunque: uno stato applicato solo a metà non consente un ripristino agevole.