Scrittura dei dati
Le operazioni di lettura si trovano in Interrogazione dei dati. Questa pagina rappresenta l’altra metà: tutto ciò che modifica una riga.
Ogni metodo qui descritto attraversa la stessa pipeline di una scrittura proveniente da qualsiasi altra parte — callback, relazioni e sicurezza a livello di riga continuano ad applicarsi. Niente di tutto questo è una scorciatoia per aggirare le tue regole; ciò che queste opzioni ti fanno guadagnare è un round trip, una transazione o una race condition che non devi più rischiare di perdere.
Scritture a riga singola
Sezione intitolata “Scritture a riga singola”const newProduct = await client.data.products.create({ name: "New Product", price: 29.99, active: true});
// With a specific IDconst newProduct = await client.data.products.create( { name: "Custom ID Product" }, "my-custom-id");Inserisce la riga, oppure aggiorna quella che ne occupa già la chiave:
await client.data.products.upsert( { sku: "LAMP-01", name: "Desk lamp", price: 39 }, { onConflict: ["sku"] });Il server decide quale delle due all’interno di un’unica transazione, e ripiega su
INSERT … ON CONFLICT DO UPDATE, quindi, a differenza di un
findById seguito da create o update, non può perdere la race condition tra le
due operazioni e, a differenza di create, non fallisce se la riga esiste già.
Quando la riga esiste, l’upsert è un aggiornamento di quella riga. I campi inviati vengono
scritti e quelli omessi mantengono i valori memorizzati — non vengono reimpostati
al loro defaultValue. beforeSave e afterSave vedono status: "existing"
con i valori precedenti, e la cronologia registra un aggiornamento. Una chiave che indica una riga
al di fuori dell’ambito beforeQuery del chiamante restituisce un 404.
onConflict è impostato di default sulla chiave primaria, che è il target errato per la maggior parte
delle scritture per cui si sceglie un upsert: basato su un ID seriale diventa un semplice
inserimento, poiché il chiamante non conosce l’ID — di conseguenza, un’importazione rieseguibile
duplicherebbe ogni riga alla seconda esecuzione. Specifica invece la chiave naturale. Essa deve
garantire l’unicità su cui il database può verificare la corrispondenza — validation: { unique: true } sulla proprietà, oppure le colonne di un indice
unique: true — qualsiasi altra cosa restituirà un errore 400 con l’elenco dei target
effettivamente esistenti, anziché un errore generato dall’interno di una transazione.
Il timestamp on_create e il creatore user_on_create di una riga già esistente non vengono
toccati: un conflitto indica che la sua creazione appartiene al passato.
Non sulla collection degli utenti
Sezione intitolata “Non sulla collection degli utenti”Since 0.25 upsert() sulla collection degli utenti (quella con auth attivo) viene rifiutato con un 400 (INVALID_CONFLICT_TARGET), come anche createMany con upsert su di essa. Creare un utente e modificarlo seguono regole diverse — una creazione esegue l’hash della password e invia l’invito, un aggiornamento non può declassare l’ultimo amministratore — e un upsert scopre quale dei due è solo dentro la scrittura. Chiama invece create o update.
const updated = await client.data.products.update(42, { name: "Updated Name", price: 39.99});Operazioni sui campi
Sezione intitolata “Operazioni sui campi”Un valore può invece essere un’operazione sul valore memorizzato:
await client.data.posts.update(postId, { views: { $inc: 1 }, tags: { $push: "featured" }, meta: { $merge: { lastSeen: Date.now() } }});| Operatore | Tipo di proprietà | Significato |
|---|---|---|
$inc |
number |
aggiunge (valore negativo per sottrarre) |
$push |
array |
accoda un valore, o ciascun elemento di un array di valori |
$pull |
array |
rimuove ogni occorrenza di un valore |
$merge |
map |
unisce un oggetto tramite shallow merge |
Il motivo per cui utilizzarle è la lettura che non si deve più effettuare e la race condition
che tale lettura aprirebbe. views = current + 1 implica il dover recuperare prima current, e due
richieste che leggono entrambe 4 scriveranno entrambe 5 — un incremento va perduto e nessuna delle due
risposte lo segnala. Compilata all’interno dell’istruzione, l’operazione aritmetica avviene all’interno
del lock sulla riga.
È consentito esattamente un operatore per campo, e solo su un update: su una riga che non
esiste ancora non c’è nulla su cui operare, quindi un’operazione in un create,
createMany o upsert restituisce un errore 400. Un operatore applicato al tipo di proprietà errato, o un
$operator con errore di digitazione, restituisce un 400 che specifica il campo — mai un documento JSON
scritto all’interno della colonna.
Un’operazione risponde alla validation della proprietà come un valore, e questo include il valore che produce. { stock: { $inc: -1 } } su uno stock con validation: { min: 0 } riesce finché lo stock è almeno 1 e viene rifiutato a 0 con lo stesso 400 (VALIDATION_CONSTRAINT) che riceve { stock: -1 }. Lo stesso vale per max, moreThan, lessThan, positive e negative, e per il numero minimo e massimo di elementi (min, max) di un array dopo un $push o un $pull. Il controllo fa parte dell’istruzione di aggiornamento, quindi due decrementi concorrenti di uno stock pari a 1 non possono riuscire entrambi, e un aggiornamento rifiutato non scrive nulla.
In modalità offline vengono rifiutate anziché accodate: un’operazione viene valutata rispetto a un valore memorizzato di cui il dispositivo non possiede una copia aggiornata, e una riga ottimistica potrebbe mostrare solo il marcatore stesso finché la coda non viene smaltita.
await client.data.products.delete(42);Su una collezione con soft delete questo contrassegna la
riga invece di rimuoverla. client.data.products.trash elenca, conta, ripristina
ed elimina definitivamente le righe contrassegnate.
Scritture condizionali
Sezione intitolata “Scritture condizionali”update e delete accettano un parametro ifMatch, in modo che la scrittura venga rifiutata se la riga è
cambiata rispetto a quando è stata letta:
import { etagOf } from "@rebasepro/client";
const post = await client.data.posts.get(1);await client.data.posts.update(1, { title: "New" }, { ifMatch: etagOf(post) });// → RebaseApiError, status 412, if somebody edited it in betweenSenza di esso, il pattern read-modify-write segue la logica per cui l’ultima scrittura sovrascrive tutto ciò che non è stato inviato: due modifiche a distanza di un secondo hanno entrambe successo, e la modifica della prima va perduta senza che venga segnalato alcun errore.
etagOf(row) legge la versione da una riga restituita da findById/get. Essa
risiede su una chiave non enumerabile, quindi non entra mai nel tipo Row generato, in un
JSON.stringify o in uno spread all’interno del corpo dell’update successivo. È undefined per una
riga proveniente da find(), dalla cache offline o da un server che non invia alcun
ETag — e passare undefined non invia alcuna precondizione, degradando la chiamata precedente
a un normale update invece di generare un errore.
Da 0.25 Una riga restituita da update() porta la versione che quella scrittura ha prodotto,
così le modifiche condizionali si concatenano senza una lettura nel mezzo. Una riga che riceve una callback di listenById()
porta la versione che quel frame mostra:
import { etagOf } from "@rebasepro/client";
let shown: Record<string, unknown> | undefined;client.data.posts.listenById(1, (post) => { shown = post; // etagOf(shown) is the version this frame shows});
// On save: refused with 412 if the row has moved on since that frameawait client.data.posts.update(1, { title: "New" }, { ifMatch: etagOf(shown) });Da un browser su un’altra origine — un frontend Vite su una propria porta, un
host app. che chiama un host api. — l’ETag è leggibile solo perché il
server lo nomina in Access-Control-Expose-Headers. Il runtime di Rebase lo
fa, insieme a Retry-After, X-Request-ID, gli header X-RateLimit-* e
Preference-Applied; sulla 0.23 non ne espone nessuno, quindi lì etagOf è
undefined fra origini diverse. Un backend che collega il proprio cors() deve esporre
lo stesso elenco, altrimenti etagOf lì è sempre undefined e ogni scrittura
condizionale parte senza condizione.
Omettere la risposta
Sezione intitolata “Omettere la risposta”Ogni scrittura si risolve restituendo la riga che ha scritto. Passa { returning: false } quando
non ne hai bisogno:
await client.data.events.create({ kind: "page_view" }, undefined, { returning: false });Questo invia l’header Prefer: return=minimal; il server risponde con 204 per una singola scrittura
e solo con gli ID per un batch. Il metodo si risolve quindi in undefined (o []),
evitando di utilizzare accidentalmente una riga che il server non ha mai inviato. È molto utile nelle importazioni
e nelle scritture fire-and-forget — il comportamento predefinito restituisce la riga perché riporta ciò che il
server ha determinato.
Scritture batch
Sezione intitolata “Scritture batch”Tre operazioni permettono di scrivere più righe in una singola richiesta e una singola transazione. Ogni riga attraversa comunque la normale pipeline — callback, relazioni, sicurezza a livello di riga — quindi un batch non è una scorciatoia per aggirare le tue regole; il vantaggio consiste in un solo round trip e una sola transazione invece di N per ciascuno.
Tutte e tre sono di tipo tutto o niente (all-or-nothing). Se una qualsiasi riga viene rifiutata, nessuna di esse viene applicata e l’errore indica l’indice incriminato.
// Createawait client.data.products.createMany([ { name: "Widget", price: 9.99 }, { name: "Gadget", price: 19.99 }]);
// Update — each entry names its row and the fields to changeawait client.data.orders.updateMany([ { id: "o-1", data: { status: "shipped" } }, { id: "o-2", data: { status: "shipped" } }]);
// Delete — by idawait client.data.sessions.deleteMany(["s-1", "s-2"]);Perché { id, data } anziché righe piatte
Sezione intitolata “Perché { id, data } anziché righe piatte”createMany accetta righe piatte perché una riga in fase di creazione è l’insieme delle sue colonne.
updateMany indica l’indirizzo separatamente, poiché in una tabella con una chiave diversa
da id — uno sku, una chiave composita — una riga piatta non può stabilire se una
colonna rappresenti l’indirizzo o un valore da scrivere. Questo rispecchia esattamente
la firma a riga singola update(id, data).
Perché deleteMany accetta ID e non un filtro
Sezione intitolata “Perché deleteMany accetta ID e non un filtro”Un’eliminazione massiva basata su un filtro è un’operazione diversa e molto più pericolosa: il rischio d’errore tipico è una condizione omessa o digitata male che svuota un’intera tabella, e non può essere verificata a livello di chiamata nel modo in cui lo permette un elenco esplicito. Esegui prima una lettura, poi passa gli ID desiderati:
const stale = await client.data.sessions.findAll({ where: { expiresAt: ["<", cutoff] }});await client.data.sessions.deleteMany(stale.map(s => s.id as string));Tentativi di ripetizione e duplicati
Sezione intitolata “Tentativi di ripetizione e duplicati”Un client che non riceve la risposta non può sapere se il batch sia stato confermato (committed), quindi tenta nuovamente — e senza una chiave il server non può distinguere quel tentativo da un secondo batch effettivo. Passa una chiave di idempotenza per qualsiasi richiesta che potrebbe essere reinviata:
const attemptKey = crypto.randomUUID();await client.data.products.createMany(rows, { idempotencyKey: attemptKey });Una chiave identifica una singola richiesta, non un job: viene registrata insieme al metodo, al percorso
e al corpo con cui è stata inviata. Reinviare esattamente quella richiesta ne riproduce la risposta;
la stessa chiave su una richiesta diversa viene rifiutata con IDEMPOTENCY_KEY_REUSED
(422). Generane quindi una per ogni chiamata anziché riutilizzare un ID di business — un importId
condiviso tra il createMany e il deleteMany di una stessa importazione lascerebbe
l’eliminazione silenziosamente non eseguita.
Un tentativo di ripetizione che arriva mentre il primo tentativo è ancora in fase di elaborazione riceve
IDEMPOTENCY_KEY_IN_PROGRESS (409): invialo di nuovo e riceverà come risposta il risultato
del primo tentativo non appena completato. Le chiavi sono valide per 24 ore e
solo per un chiamante autenticato — altrimenti non esisterebbe alcun principal a cui associarle.
La coda offline imposta automaticamente una chiave a ogni ripetizione.
I batch hanno un limite massimo lato server (1000 righe per impostazione predefinita), poiché un batch trattiene
i propri lock per l’intera durata della transazione. Il superamento genera un errore BULK_TOO_LARGE che
indica sia il limite sia il numero di righe inviate; suddividi quindi le chiamate in blocchi:
for (const chunk of chunks(rows, 1000)) { await client.data.products.createMany(chunk, { upsert: true });}Una sorgente di dati che non è in grado di scrivere in modo atomico segnala BULK_UNSUPPORTED anziché
eseguire silenziosamente un ciclo di scritture singole — il che non garantirebbe né l’atomicità
né il singolo round trip per cui si è scelto di usare un batch.
Scrittura tra collezioni
Sezione intitolata “Scrittura tra collezioni”createMany e i metodi affini operano su una sola collezione alla volta. client.batch() è la variante
per più collezioni: una sola richiesta, una sola transazione, tutto o niente.
const result = await client.batch([ { op: "create", collection: "orders", values: { total: 40 }, ref: "order" }, { op: "create", collection: "orderItems", values: { orderId: { $ref: "order.id" }, sku: "A-1" } }, { op: "update", collection: "stock", id: "A-1", values: { count: { $inc: -1 } } }, { op: "delete", collection: "carts", id: "c-9" }]);
result.data; // [ order, item, stock, null ] — aligned to the operationsresult.meta; // { operations: 4 }op può essere create, update, upsert o delete, e collection restringe
values alla struttura tipizzata generata di Insert o Update di quella collezione — una colonna
che la collezione non ha è un errore di compilazione, e lo è anche una creazione a cui manca una
colonna obbligatoria. collection è l’accessor, il nome che prende
client.data.<accessor> (orderItems per lo slug order_items); il client invia lo slug,
tramite il dizionario collections con cui è stato creato. Sulla 0.23 un
batch tipizzato invia l’accessor come scritto, quindi una collezione in
snake_case non è raggiungibile attraverso di esso, e una colonna scritta in
modo errato non è un errore di compilazione. Ogni
operazione esegue la pipeline della sua equivalente a riga singola — la stessa
validazione, callback e sicurezza a livello di riga, con lo stesso utente.
Un’operazione può assegnarsi un nome con ref; un’operazione successiva può inserire
{ $ref: "<name>.<field>" } ovunque sia ammesso un valore, anche come id e a
qualsiasi livello di annidamento all’interno di values. Si risolve in quel campo della riga scritta
dall’operazione indicata.
È questo il motivo per cui il metodo esiste anziché ricorrere a un ciclo su createMany: la
chiave esterna dell’elemento figlio non esiste finché l’elemento genitore non viene inserito, quindi le due operazioni
dovrebbero essere richieste separate — e richieste separate possono riuscire a metà. Il ripristino
da tale situazione (rileggere, determinare quale metà sia stata applicata, annullarla) è codice che nessuno
vuole scrivere.
Vengono risolti soltanto i riferimenti all’indietro. Un riferimento in avanti viene rifiutato prima che la transazione si apra, insieme a collezioni sconosciute, campi sconosciuti, operazioni illegali sui campi e target di conflitto non validi — poiché rilevare un errore all’operazione 40 comporterebbe altrimenti il rollback delle 39 scritture precedenti.
Limiti e fallimenti
Sezione intitolata “Limiti e fallimenti”Si applica lo stesso limite di 1000 operazioni delle scritture bulk, per la medesima ragione: un batch
trattiene i propri lock per l’intera durata della transazione. Un update o delete che fa riferimento a una riga
inesistente fa fallire l’intero batch con un 404. Un backend il cui driver
non può garantire l’atomicità risponde con BATCH_UNSUPPORTED anziché iterare in loop.
idempotencyKey e returning funzionano esattamente come per qualsiasi altra operazione di scrittura.