Zum Inhalt springen

Schreiben über REST

Die Verben finden Sie auf der Seite REST API. Hier geht es um die fünf Dinge, die ein Schreibvorgang über sein Verb hinaus anfordern kann, sowie um den Endpunkt, der sammlungsübergreifend in einem Schritt schreibt. Alle diese Optionen sind pro Anfrage freiwillig (Opt-in): Ein Schreibvorgang, der nichts davon anfordert, verhält sich exakt wie bisher.

Über die Verben hinaus akzeptieren die Schreib-Routen fünf Parameter, die das Verhalten eines Schreibvorgangs verändern. Alle fünf sind pro Anfrage optional (Opt-in), sodass sich für Anfragen, die diese nicht anfordern, nichts ändert. Die verschachtelten Routen (/api/data/authors/42/posts) laufen durch denselben Schreibcode wie die Root-Routen, daher gilt alles in diesem Abschnitt auch für sie, außer ?on_conflict=.

Der Body eines Schreibvorgangs muss ein JSON-Objekt sein. null, eine Zahl, ein String oder ein Array ergibt 400 BAD_REQUEST.

Ein Update kann den Schlüssel einer Zeile nicht ändern. Ein Body, der den Schlüssel mit einem anderen Wert benennt, ergibt 400 KEY_IMMUTABLE, bevor irgendein Hook läuft und ohne dass etwas geschrieben wird — unabhängig davon, auf welchem Weg das Update eintrifft: REST, der Realtime-Socket, MCP oder das prozessinterne rebase.data. Der Schlüssel, den die Zeile bereits hat, wird akzeptiert, so wie ihn ein Formular sendet, das die gesamte Zeile zurückschickt. Um eine Zeile zu einem neuen Schlüssel zu verschieben, erstellen Sie sie dort neu und löschen Sie die alte. Auf 0.23 antwortet ein solches Update mit 500, und ein prozessinternes hat die Zeile bereits verschoben, wenn es die Exception wirft.

Idempotency-Key: <uuid> bei einem Schreibvorgang bedeutet: „Wenn diese exakte Anfrage bereits beantwortet wurde, sende dieselbe Antwort erneut, anstatt den Vorgang zweimal auszuführen“.

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

Ein Client, der keine Antwort erhält, kann nicht wissen, ob der Schreibvorgang erfolgreich ausgeführt wurde. Daher wiederholt er ihn – und ohne einen Schlüssel kann der Server diesen Wiederholungsversuch nicht von einem zweiten echten Schreibvorgang unterscheiden. In einer Tabelle mit einer vom Server vergebenen ID führt dies zu einer doppelten Zeile, da die vom Client generierte ID nie verwendet wurde.

Ein Schlüssel identifiziert eine einzige Anfrage: Er speichert die Methode, den Pfad, den Query-String und den Body, für den er beansprucht wurde. Wird exakt dieselbe Anfrage erneut gesendet, wird deren Antwort wiedergegeben; wird eine andere Anfrage unter demselben Schlüssel gesendet, wird sie mit IDEMPOTENCY_KEY_REUSED (422) abgelehnt, anstatt das Ergebnis der ersten zurückzugeben. Ein Wiederholungsversuch, der eintrifft, während der erste noch verarbeitet wird, erhält IDEMPOTENCY_KEY_IN_PROGRESS (409) – senden Sie ihn erneut, sobald der erste abgeschlossen ist. Der Query-String zählt, weil er ändert, was eine Anfrage tut: DELETE /api/data/posts/5?hard=true unter dem Schlüssel eines früheren Soft-Deletes ist eine andere Anfrage, keine Wiederholung. Die Reihenfolge seiner Parameter spielt keine Rolle.

Wird bei POST, PATCH, DELETE, allen drei /bulk-Routen sowie /_batch unterstützt. Schlüssel sind 24 Stunden lang gültig und an den angemeldeten Aufrufer gebunden; eine nicht authentifizierte Anfrage hat keinen Prinzipal, an den der Schlüssel gebunden werden könnte, daher wird der Header dort ignoriert. Ein Backend, das keine Schlüssel speichern kann, ignoriert den Header, anstatt den Schreibvorgang abzulehnen.

DELETE ist der Fall, der besondere Aufmerksamkeit verdient. Ohne Schlüssel wiederholt ausgeführt, stellt der zweite Versuch fest, dass die Zeile nicht mehr existiert, und antwortet mit 404 – was ein wiederholender Client als dauerhaften Fehler für einen Löschvorgang interpretiert, der in Wahrheit erfolgreich war. Mit einem Schlüssel wird stattdessen der 204 wiederholt.

GET /api/data/:slug/:id gibt ein ETag zurück, ebenso das verschachtelte GET einer einzelnen Zeile. Wird dieses bei einem späteren PATCH oder DELETE als If-Match zurückgesendet, wird der Schreibvorgang mit 412 abgelehnt, falls sich die Zeile in der Zwischenzeit geändert hat.

Seit 0.25 Ein erfolgreiches PATCH antwortet mit dem ETag der Version, die es geschrieben hat, sodass der nächste bedingte Schreibvorgang kein vorheriges Lesen braucht. Das Tag steht auch bei einem 204, das für Prefer: return=minimal zurückgegeben wird, und eine für einen Idempotency-Key wiedergegebene Antwort trägt dasselbe Tag wie die erste. Ein Live-Abonnement einer Zeile trägt dasselbe Tag in jedem Frame (siehe 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

Ohne dies gilt bei Read-Modify-Write das Prinzip „Last-Writer-Wins“ für alles, was der zweite Schreibvorgang nicht übermittelt hat: Zwei Bearbeiter, die im Abstand von einer Sekunde speichern, sind beide erfolgreich, und die Änderung des ersten geht ohne jegliche Fehlermeldung verloren.

Das Tag wird aus einer date-Eigenschaft mit autoValue: "on_update" abgeleitet, sofern die Collection eine solche deklariert – diese Spalte ist bereits eine Version – andernfalls aus einem stabilen Hash der Zeile. If-Match: * stellt lediglich sicher, dass die Zeile existiert. Es wird nichts geschrieben, wenn die Vorbedingung fehlschlägt.

Seit 0.25 Das Formular des Admin-Bereichs speichert mit If-Match: Ein Speichern gegen eine Version, die inzwischen jemand ersetzt hat, wird abgelehnt, das Formular sagt es, und die Bearbeitung bleibt im Formular, auf die neue Version aufgesetzt, mit markierten konfliktierenden Feldern.

Das Tag wird innerhalb der eigenen Transaktion des Schreibvorgangs verglichen, nachdem die Zeile gesperrt wurde. Zwei Schreibvorgänge mit demselben Tag können nicht beide durchkommen: Der zweite wartet, bis der erste committet hat, liest, was dieser geschrieben hat, und wird mit 412 abgelehnt.

Der Wert einer Eigenschaft im Body eines PATCH kann anstelle eines statischen Werts auch eine Operation auf dem gespeicherten Wert sein:

curl -X PATCH /api/data/posts/p1 -d '{
"views": { "$inc": 1 },
"tags": { "$push": "featured" },
"meta": { "$merge": { "seen": true } }
}'
Operator Eigenschaftstyp Wird zu
$inc number SET col = COALESCE(col, 0) + n
$push array array_append(col, …) oder eine jsonb-Verkettung
$pull array array_remove(col, …) oder eine jsonb-Re-Aggregation
$merge map `col

Der Vorteil liegt darin, dass der Aufrufer den Wert nicht mehr vorher lesen muss. Den Ausdruck views + 1 als konkreten Wert zu übermitteln bedeutet, ihn zuerst lesen zu müssen; zwei Anfragen, die jeweils 4 lesen, eins addieren und 5 schreiben, enden bei 5 – ohne dass eine der beiden Antworten darauf hinweist, dass ein Inkrement verloren ging. Direkt in das Statement kompiliert, findet die Arithmetik innerhalb der Zeilensperre (Row Lock) statt und kann nicht verloren gehen.

Genau ein Operator pro Feld. Ein Operator auf einem Eigenschaftstyp, für den er nicht definiert ist, ein unbekannter $operator oder ein Operand mit falscher Struktur führt zu einem 400 (INVALID_FIELD_OPERATION), der das Feld benennt – ein Tippfehler wird niemals als JSON-Dokument in die Spalte geschrieben.

Eine map ist die Ausnahme, weil ihr Wert selbst ein JSON-Dokument ist und eigene $-Schlüssel tragen kann – $schema und $id eines JSON Schemas, ein MongoDB-Export. Bei einer map ist nur ein Objekt mit genau einem Schlüssel, der einer der obigen Operatoren ist, eine Operation; jedes andere Objekt wird so gespeichert, wie es gesendet wurde, samt $-Schlüsseln. So führt { "meta": { "$merge": { … } } } zusammen, und { "meta": { "$schema": "https://json-schema.org/draft/2020-12/schema" } } wird gespeichert. Ein falsch geschriebener Operator bei einer map wird daher ebenfalls gespeichert; bei jedem anderen Typ wird er weiterhin abgelehnt. Der Offline-Client hat kein Schema, um eine map von allem anderen zu unterscheiden, und lehnt einen solchen Wert daher offline als Feldoperation ab; senden Sie ihn, sobald die Verbindung wieder steht.

Operationen gelten nur für Aktualisierungen: Bei einer Zeile, die noch nicht existiert, gibt es nichts zu manipulieren, weshalb sie bei POST, /bulk-Erstellungen und Upserts abgelehnt werden.

Eine Operation unterliegt der validation der Eigenschaft wie ein Wert. Hinzugefügte Elemente werden nach den Regeln der Elemente geprüft, zusammengeführte Schlüssel nach den Eigenschaften der Map und der Wert, den eine Operation erzeugt, nach den deklarierten Grenzen: bei einer Zahl min, max, moreThan, lessThan, positive und negative, bei einem Array die Mindest- und Höchstzahl an Elementen (min, max). Diese letzte Prüfung ist eine Bedingung desselben UPDATE, kein vorheriges Lesen. Bei stock: { validation: { min: 0 } } gelingt { "stock": { "$inc": -1 } }, solange der Bestand mindestens 1 beträgt, und wird bei 0 mit demselben 400 (VALIDATION_CONSTRAINT) abgelehnt, den { "stock": -1 } erhält – mit Feld und Grenze benannt. Zwei gleichzeitige Verringerungen eines Bestands von 1 können nicht beide gelingen, weil die zweite an der Zeile gemessen wird, die die erste festgeschrieben hat. Eine nicht gesetzte Zahl zählt als 0, ein nicht gesetztes Array als leer. Eine abgelehnte Operation schreibt nichts, auch nicht die einfachen Werte daneben im Body. Da die Prüfung Teil der Anweisung ist, gilt sie auch für prozessinterne Schreibvorgänge über rebase.data, die die Validierung auf Anfrageebene überspringen.

POST /api/data/:slug?on_conflict=sku fügt die Zeile ein oder aktualisiert diejenige, die diese SKU bereits enthält, statt daran zu scheitern. Die Bulk-Route akzeptiert dasselbe Ziel als onConflict neben upsert: true, ebenso wie jede upsert-Operation eines Batches.

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"]
}'

Das Ziel muss eine Eindeutigkeitsgarantie bieten, die die Datenbank abgleichen kann: der Primärschlüssel (der Standard, wenn keiner angegeben ist), eine Eigenschaft mit validation: { unique: true } oder die Spalten eines unique: true-Index. Alles andere führt zu einem 400 (INVALID_CONFLICT_TARGET), der die tatsächlich vorhandenen Ziele auflistet – Postgres würde andernfalls innerhalb einer Transaktion, die bereits Arbeit verrichtet hat, mit there is no unique or exclusion constraint matching the ON CONFLICT specification antworten.

Die Angabe eines Ziels ohne upsert: true bei einem Bulk-Schreibvorgang führt ebenfalls zu einem 400: Ein stillschweigendes Ignorieren würde einen wiederholbaren Import in einen duplizierenden Vorgang verwandeln.

?on_conflict= wird bei einem verschachtelten Create abgelehnt (INVALID_CONFLICT_TARGET). Die Zeile, die es trifft, könnte unter einem anderen Parent liegen, und das Upsert würde sie unter diesen verschieben. Senden Sie das Upsert an die eigene Route der Collection.

Ein Upsert, das auf eine gespeicherte Zeile trifft, ist ein Update dieser Zeile. Der Server liest die Zeile, die der Schlüssel benennt, im Scope des Aufrufers und innerhalb der Transaktion des Schreibvorgangs, und führt das gewöhnliche Update aus: Die Felder im Body werden geschrieben, die weggelassenen behalten ihre gespeicherten Werte, statt auf ihren defaultValue zurückgesetzt zu werden, beforeSave und afterSave sehen status: "existing" mit den vorherigen Werten, und die Historie verzeichnet ein Update. Eine Zeile, die das Lesen nicht sehen konnte, oder eine, die gleichzeitig eingefügt wurde, wird weiterhin von ON CONFLICT … DO UPDATE erfasst, das nur setzt, was der Body und die Hooks geschrieben haben, sowie die on_update-Stempel. Ein Schlüssel, der eine Zeile außerhalb des beforeQuery-Scopes des Aufrufers benennt, antwortet mit 404. Ein Schlüssel, der zu einer Zeile im Papierkorb gehört, antwortet mit 409 ROW_IN_TRASH und schreibt nichts: Stellen Sie die Zeile zuerst wieder her, oder löschen Sie sie endgültig.

Eine Zeile, die bereits existierte, behält ihren on_create-Zeitstempel und ihren user_on_create-Ersteller. Ein Konflikt bedeutet, dass die Erstellung der Zeile ein Fakt der Vergangenheit ist; ein nächtlicher Re-Import, der createdAt bei jedem berührten Datensatz zurücksetzte, würde jede „Neu diese Woche“-Abfrage verfälschen.

Since 0.25 Die Users-Collection (die mit aktiviertem auth) lehnt ein Upsert an jeder Tür ab — ?on_conflict=, /bulk mit upsert: true, eine upsert-Operation in _batch, das upsert() des SDK und den Realtime-Socket — mit einem 400 (INVALID_CONFLICT_TARGET). Ihre Zeilen sind die Benutzer, und das Anlegen und das Ändern eines Benutzers folgen unterschiedlichen Regeln: Ein Create hasht das Passwort, normalisiert die E-Mail, führt onCreateUser aus und verschickt die Einladung, und ein Update darf den letzten Administrator nicht herabstufen. Ein Upsert erfährt erst innerhalb des Schreibvorgangs, was es ist, zu spät, um eines davon anzuwenden. Legen Sie Benutzer mit einem einfachen POST an und ändern Sie sie mit PATCH.

Standardmäßig antwortet jeder Schreibvorgang mit der vollständigen Zeile, welche die vom Server festgelegten Werte enthält – eine serielle ID, einen autoValue-Stempel oder was auch immer beforeSave geändert hat. Senden Sie Prefer: return=minimal, wenn Sie nichts davon benötigen:

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

Ein einzelner Schreibvorgang antwortet mit 204. Ein /bulk- oder /_batch-Schreibvorgang antwortet mit 200 und liefert die IDs anstelle der gesamten Zeilen – bei einer Erstellung ist die ID das Einzige, was der Aufrufer nicht selbst berechnen kann. Sie zu verwerfen würde bedeuten, die Tabelle anhand eines natürlichen Schlüssels erneut lesen zu müssen, um zu erfahren, was gerade geschrieben wurde. Ein einspaltiger Schlüssel wird als Skalar zurückgegeben, ein zusammengesetzter Schlüssel als Objekt seiner Spalten.

POST /api/data/_batch schreibt sammlungsübergreifend in einer einzigen Transaktion unter der Rolle des Aufrufers und mit denselben Validierungen, Callbacks und Zeilenebenen-Sicherheitsregeln (Row-Level Security), die auch die Einzelzeilen-Route jeder Operation anwenden würde.

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 ist create, update, upsert oder delete. update und delete erfordern eine id; create, update und upsert erfordern values; upsert kann unter denselben Bedingungen wie oben ein onConflict-Ziel angeben. data ist an operations ausgerichtet – die geschriebene Zeile für ein Create, Update oder Upsert und null für ein Delete –, sodass ein Index im einen dem Index im anderen entspricht.

$ref: Verweis auf eine Zeile, die derselbe Batch erstellt hat

Abschnitt betitelt „$ref: Verweis auf eine Zeile, die derselbe Batch erstellt hat“

Eine Operation kann sich selbst über ref benennen, und jede spätere Operation kann { "$ref": "<name>.<field>" } überall dort einsetzen, wo ein Wert erwartet wird – in beliebiger Tiefe innerhalb von values oder als id. Dies wird zu dem entsprechenden Feld der Zeile aufgelöst, die von der benannten Operation geschrieben wurde.

Dies ist der Grund, warum der Endpunkt existiert, anstatt eine Schleife zu sein: Der Fremdschlüssel eines Kind-Elements ist erst bekannt, wenn das übergeordnete Element eingefügt wurde. Ohne diesen Mechanismus müssten Eltern- und Kind-Elemente in separaten Anfragen gesendet werden – genau die Abfolge, die teilweise fehlschlagen kann. Es werden nur Rückwärtsverweise aufgelöst; ein Vorwärtsverweis wird abgelehnt, bevor die Transaktion geöffnet wird.

Struktur, unbekannte Sammlungen, unbekannte Felder, Wertebeschränkungen, Feldoperationen, Konfliktziele und die Erreichbarkeit von $ref werden alle geprüft, bevor die Transaktion geöffnet wird. Ein Batch ist ein Alles-oder-Nichts-Vorgang, und das Auffinden eines Tippfehlers bei Operation 40 würde andernfalls das Rollback der 39 vorangegangenen Schreibvorgänge erfordern.

Begrenzt auf dieselbe Anzahl von Operationen wie ein Bulk-Schreibvorgang (standardmäßig 1000), da ein Batch seine Sperren für die gesamte Transaktion hält. Die Callbacks jeder Operation laufen darin, nacheinander, sodass ein langsamer Hook pro Zeile bei offener Transaktion bezahlt wird: 100 Zeilen, deren beforeSave 50 ms abwartet, halten eine Verbindung und die Sperren jeder bisher geschriebenen Zeile 5,3 s lang. Dasselbe gilt für einen /bulk-Schreibvorgang. Siehe Ein Callback hält eine Verbindung. Ein Treiber, der den Batch nicht atomar ausführen kann, antwortet mit BATCH_UNSUPPORTED, anstatt auf eine Schleife von Einzelschreibvorgängen zurückzugreifen – was weder die Atomizität noch den einzelnen Roundtrip bieten würde, für die ein Batch gedacht ist.

Ein update oder delete, das eine nicht existierende Zeile benennt, lässt den gesamten Batch mit einem 404 fehlschlagen – aus demselben Grund, aus dem partielle Schreibvorgänge überall sonst abgelehnt werden: Ein halb angewendeter Zustand bietet keine verlässliche Wiederherstellung.