Écriture via REST
Les verbes sont détaillés sur la page de l’API REST. Celle-ci traite des cinq éléments qu’une écriture peut demander au-delà de son verbe, ainsi que du point de terminaison permettant d’écrire simultanément sur plusieurs collections. Chacun d’entre eux est optionnel par requête : une écriture qui ne demande rien de tout cela se comporte exactement comme d’habitude.
Écriture
Section intitulée « Écriture »Au-delà des verbes, les routes d’écriture acceptent cinq éléments qui modifient le comportement d’une écriture. Tous les cinq sont optionnels par requête, ainsi rien de ce qui suit ne modifie le fonctionnement d’une requête qui ne les sollicite pas. Les routes imbriquées (/api/data/authors/42/posts) passent par le même code d’écriture que les routes racines, donc tout ce qui suit dans cette section s’y applique aussi, sauf ?on_conflict=.
Le corps d’une écriture doit être un objet JSON. null, un nombre, une chaîne ou un tableau donne un 400 BAD_REQUEST.
Une mise à jour ne peut pas changer la clé d’une ligne. Un corps qui nomme la clé
avec une autre valeur donne un 400 KEY_IMMUTABLE, avant l’exécution de tout hook
et sans rien écrire, quel que soit le chemin par lequel la mise à jour arrive :
REST, le socket temps réel, MCP ou rebase.data en interne. La clé que la ligne
possède déjà est acceptée, comme l’envoie un formulaire qui renvoie la ligne
entière. Pour déplacer une ligne vers une nouvelle clé, créez-la à cet
emplacement puis supprimez l’ancienne. Sur la 0.23 une telle mise à jour répond 500,
et une mise à jour interne au processus a déjà déplacé la ligne au moment où elle échoue.
Idempotence
Section intitulée « Idempotence »Idempotency-Key: <uuid> sur n’importe quelle écriture signifie « si vous avez déjà répondu à cette requête exacte, renvoyez la même réponse plutôt que de l’exécuter deux fois ».
curl -X POST /api/data/orders \ -H "Idempotency-Key: 1f0f…" \ -d '{"total": 40}'Un client qui ne reçoit jamais de réponse ne peut pas savoir si l’écriture a été validée, il réessaie donc — et sans clé, le serveur ne peut distinguer cette nouvelle tentative d’une deuxième écriture légitime. Sur une table avec un identifiant attribué par le serveur, cela crée une ligne en double, car l’identifiant inventé par le client n’a jamais été utilisé.
Une clé désigne une seule requête : elle enregistre la méthode, le chemin, la chaîne de requête et le corps pour lesquels elle a été réclamée. Renvoyez cette requête exacte et sa réponse est rejouée ; envoyez-en une différente sous la même clé et elle est refusée avec IDEMPOTENCY_KEY_REUSED (422) au lieu de renvoyer le résultat de la première. Une tentative qui arrive alors que la première est toujours en cours reçoit IDEMPOTENCY_KEY_IN_PROGRESS (409) — renvoyez-la une fois que la première a abouti. La chaîne de requête compte parce qu’elle change ce que fait une requête : DELETE /api/data/posts/5?hard=true sous la clé d’une suppression logique antérieure est une requête différente, pas une répétition. L’ordre de ses paramètres n’a pas d’importance.
Pris en charge sur POST, PATCH, DELETE, sur les trois routes /bulk et sur /_batch. Les clés sont valables 24 heures et sont limitées à l’appelant authentifié ; une requête non authentifiée n’ayant pas d’entité principale à laquelle se rattacher, l’en-tête y est ignoré. Un backend incapable de stocker les clés ignore l’en-tête au lieu de refuser l’écriture.
Le cas de DELETE mérite une attention particulière. Rejouée sans clé, la seconde tentative constate que la ligne a disparu et renvoie 404 — ce qu’un client effectuant une nouvelle tentative interprète comme un échec définitif pour une suppression qui a pourtant réussi. Avec une clé, elle rejoue le code 204.
Concurrence optimiste : ETag et If-Match
Section intitulée « Concurrence optimiste : ETag et If-Match »GET /api/data/:slug/:id renvoie un ETag, tout comme le GET imbriqué d’une seule ligne. Renvoyez-le dans l’en-tête If-Match lors d’un PATCH ou d’un DELETE ultérieur et l’écriture sera refusée avec un code 412 si la ligne a été modifiée entre-temps.
Depuis 0.25 Un PATCH réussi répond avec l’ETag de la version qu’il a écrite, de sorte que l’écriture conditionnelle suivante n’a pas besoin de lecture préalable. Le jeton accompagne aussi un 204 répondu pour Prefer: return=minimal, et une réponse rejouée pour une Idempotency-Key porte le même jeton que la première. Un abonnement en direct à une ligne porte le même jeton sur chaque trame (voir Temps réel).
# readcurl -i /api/data/docs/d1# → ETag: "9f2c…"
# write, conditionallycurl -X PATCH /api/data/docs/d1 \ -H 'If-Match: "9f2c…"' \ -d '{"title": "Second draft"}'# → 412 PRECONDITION_FAILED if somebody else edited it firstSans cela, le mécanisme de lecture-modification-écriture applique la règle du « dernier arrivant gagne » sur tout ce que la seconde écriture n’a pas envoyé : deux modifications à une seconde d’intervalle réussissent toutes les deux, et le changement apporté par la première disparaît sans la moindre erreur.
Le jeton provient d’une propriété date avec autoValue: "on_update" lorsque la collection en déclare une — cette colonne constitue déjà une version — et d’un hachage stable de la ligne dans le cas contraire. If-Match: * affirme uniquement que la ligne existe. Rien n’est écrit lorsque la précondition échoue.
Depuis 0.25 Le formulaire d’enregistrement de l’admin enregistre avec If-Match : un enregistrement fait sur une version que quelqu’un a remplacée depuis est refusé, le formulaire le signale, et la modification reste dans le formulaire, rebasée sur la nouvelle version avec les champs en conflit marqués.
Le jeton est comparé au sein de la propre transaction de l’écriture, une fois la ligne verrouillée. Deux écritures portant le même jeton ne peuvent pas passer toutes les deux : la seconde attend le commit de la première, lit ce qu’elle a écrit et est refusée avec un code 412.
Opérations sur les champs
Section intitulée « Opérations sur les champs »Dans le corps d’un PATCH, la valeur d’une propriété peut être une opération sur la valeur stockée plutôt qu’une simple valeur :
curl -X PATCH /api/data/posts/p1 -d '{ "views": { "$inc": 1 }, "tags": { "$push": "featured" }, "meta": { "$merge": { "seen": true } }}'| Opérateur | Type de propriété | Devient |
|---|---|---|
$inc |
number |
SET col = COALESCE(col, 0) + n |
$push |
array |
array_append(col, …), ou une concaténation jsonb |
$pull |
array |
array_remove(col, …), ou une réagrégation jsonb |
$merge |
map |
`col |
L’intérêt réside dans la lecture que l’appelant n’a plus besoin d’effectuer. Exprimer views + 1 sous forme de valeur implique de la lire au préalable, et deux requêtes lisant chacune 4, ajoutant un et écrivant 5 aboutiront à 5 — sans que rien dans l’une ou l’autre réponse n’indique qu’une incrémentation a été perdue. Compilée dans l’instruction SQL, l’opération arithmétique a lieu au sein du verrou de ligne et ne peut pas échouer.
Un seul opérateur est autorisé par champ. Un opérateur sur un type de propriété pour lequel il n’est pas défini, un $operator inconnu, ou un opérande dont le format est incorrect renvoie un 400 (INVALID_FIELD_OPERATION) nommant le champ concerné — une faute de frappe n’est jamais écrite dans la colonne sous forme de document JSON.
Une map fait exception, car sa valeur est un document JSON et peut porter ses propres clés $ — les $schema et $id d’un JSON Schema, un export MongoDB. Sur une map, seul un objet avec exactement une clé qui est l’un des opérateurs ci-dessus est une opération ; tout autre objet est stocké tel qu’envoyé, clés $ comprises. Ainsi { "meta": { "$merge": { … } } } fusionne, et { "meta": { "$schema": "https://json-schema.org/draft/2020-12/schema" } } est stocké. Un opérateur mal orthographié sur une map est donc stocké lui aussi ; sur tout autre type, il reste refusé. Le client hors ligne n’a pas de schéma pour distinguer une map du reste : hors ligne, il refuse donc une telle valeur comme une opération de champ ; envoyez-la une fois la connexion rétablie.
Les opérations s’appliquent uniquement aux mises à jour : sur une ligne qui n’existe pas encore, il n’y a rien à modifier, elles sont donc refusées sur POST, sur les créations via /bulk et sur les opérations d’upsert.
Une opération obéit à la validation de la propriété comme une valeur. Les éléments ajoutés sont jugés par les règles de l’élément, les clés fusionnées par les propriétés de la map et la valeur qu’une opération produit par les bornes déclarées : min, max, moreThan, lessThan, positive et negative pour un nombre, nombre minimal et maximal d’éléments (min, max) pour un tableau. Cette dernière vérification est une condition du même UPDATE, pas une lecture préalable. Sur stock: { validation: { min: 0 } }, { "stock": { "$inc": -1 } } réussit tant que le stock vaut au moins 1 et est refusé à 0 avec le même 400 (VALIDATION_CONSTRAINT) que { "stock": -1 }, qui nomme le champ et la borne. Deux décréments concurrents d’un stock de 1 ne peuvent pas réussir tous les deux, car le second est jugé sur la ligne que le premier a validée. Un nombre non renseigné compte pour 0 et un tableau non renseigné pour vide. Une opération refusée n’écrit rien, pas même les valeurs simples qui l’accompagnent dans le corps. Comme la vérification fait partie de l’instruction, elle s’applique aussi aux écritures internes au processus via rebase.data, qui ne passent pas par la validation au niveau de la requête.
Upsert sur une clé naturelle
Section intitulée « Upsert sur une clé naturelle »POST /api/data/:slug?on_conflict=sku insère la ligne, ou met à jour celle qui détient déjà ce SKU, au lieu d’échouer sur elle. La route bulk prend la même cible sous le nom onConflict aux côtés de upsert: true, tout comme chaque opération upsert d’un lot (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"]}'La cible doit comporter une garantie d’unicité sur laquelle la base de données peut faire une correspondance : la clé primaire (valeur par défaut si aucune n’est précisée), une propriété avec validation: { unique: true }, ou les colonnes d’un index avec unique: true. Tout autre élément renvoie un 400 (INVALID_CONFLICT_TARGET) énumérant les cibles existantes — sans quoi Postgres répondrait there is no unique or exclusion constraint matching the ON CONFLICT specification depuis l’intérieur d’une transaction ayant déjà effectué du travail.
Indiquer une cible sans upsert: true lors d’une écriture en masse renvoie également un 400 : l’ignorer silencieusement transformerait un import rejouable en une opération générant des doublons.
?on_conflict= est refusé sur une création imbriquée (INVALID_CONFLICT_TARGET). La ligne qu’il trouverait pourrait se trouver sous un autre parent, et l’upsert la déplacerait sous celui-ci. Envoyez l’upsert à la route propre de la collection.
Un upsert qui rencontre une ligne stockée est une mise à jour de celle-ci. Le serveur lit la ligne désignée par la clé, dans la portée de l’appelant et au sein de la transaction de l’écriture, puis exécute la mise à jour ordinaire : les champs du corps sont écrits, ceux qu’il omet conservent leurs valeurs stockées au lieu d’être réinitialisés à leur defaultValue, beforeSave et afterSave voient status: "existing" avec les valeurs précédentes, et l’historique enregistre une mise à jour. Une ligne que la lecture n’a pas pu voir, ou une ligne insérée de manière concurrente, est tout de même interceptée par ON CONFLICT … DO UPDATE, qui ne définit que ce que le corps et les hooks ont écrit, ainsi que les horodatages on_update. Une clé qui désigne une ligne hors de la portée beforeQuery de l’appelant répond 404. Une clé qui appartient à une ligne dans la corbeille répond 409 ROW_IN_TRASH et n’écrit rien : restaurez d’abord la ligne, ou purgez-la.
Une ligne qui existait déjà conserve son horodatage on_create et son créateur user_on_create. Un conflit signifie que la création de la ligne est un fait passé, et un réimport nocturne réinitialisant createdAt sur chaque élément touché fausserait toutes les requêtes du type « nouveautés de la semaine ».
Pas sur la collection des utilisateurs
Section intitulée « Pas sur la collection des utilisateurs »Since 0.25 La collection des utilisateurs (celle où auth est activé) refuse un upsert par toutes les voies — ?on_conflict=, /bulk avec upsert: true, une opération upsert de _batch, le upsert() du SDK et le socket temps réel — avec un 400 (INVALID_CONFLICT_TARGET). Ses lignes sont les utilisateurs, et créer un utilisateur et le modifier suivent des règles différentes : une création hache le mot de passe, normalise l’email, exécute onCreateUser et envoie l’invitation, et une mise à jour ne peut pas rétrograder le dernier administrateur. Un upsert n’apprend lequel des deux il est qu’à l’intérieur de l’écriture, trop tard pour appliquer l’une ou l’autre. Créez les utilisateurs avec un simple POST et modifiez-les avec PATCH.
Prefer: return=minimal
Section intitulée « Prefer: return=minimal »Par défaut, chaque écriture renvoie la ligne complète, qui contient les éléments déterminés par le serveur — un identifiant auto-incrémenté, un horodatage autoValue, ou les modifications apportées par beforeSave. Envoyez Prefer: return=minimal lorsque vous n’en avez pas besoin :
curl -X POST /api/data/events \ -H "Prefer: return=minimal" \ -d '{"kind": "page_view"}'# → 204 No Content, Preference-Applied: return=minimalUne écriture simple renvoie 204. Une écriture via /bulk ou /_batch renvoie 200 contenant les identifiants plutôt que les lignes complètes — lors d’une création, l’identifiant est la seule chose que l’appelant ne peut pas calculer, l’omettre obligerait donc à relire la table via une clé naturelle pour savoir ce qui vient d’être écrit. Une clé à colonne unique est renvoyée sous forme scalaire ; une clé composite sous forme d’objet regroupant ses colonnes.
Lots multi-collections
Section intitulée « Lots multi-collections »POST /api/data/_batch écrit sur plusieurs collections au sein d’une même transaction, sous le rôle propre de l’appelant, avec les mêmes validations, rappels (callbacks) et sécurité au niveau de la ligne (RLS) que ceux appliqués par la route unitaire de chaque opération.
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 accepte create, update, upsert ou delete. update et delete nécessitent un id ; create, update et upsert nécessitent values ; upsert peut spécifier une cible onConflict selon les mêmes conditions que mentionné plus haut. data est aligné sur operations — la ligne écrite pour un create, update ou upsert, et null pour un delete — de sorte qu’un index dans l’un correspond à l’index dans l’autre.
$ref : pointer vers une ligne créée dans le même lot
Section intitulée « $ref : pointer vers une ligne créée dans le même lot »Une opération peut se nommer elle-même via ref, et toute opération ultérieure peut placer { "$ref": "<nom>.<champ>" } là où une valeur est attendue — dans values à n’importe quel niveau d’imbrication, ou en tant qu’id. Cela correspond au champ de la ligne écrite par l’opération désignée.
C’est la raison d’être de ce point de terminaison par rapport à une simple boucle : la clé étrangère d’un enfant est inconnue tant que le parent n’a pas été inséré, donc sans cela, le parent et les enfants doivent faire l’objet de requêtes distinctes — précisément la séquence susceptible d’échouer à mi-chemin. Seules les références vers l’arrière sont résolues ; une référence vers l’avant est refusée avant même l’ouverture de la transaction.
Ce qui est vérifié avant toute écriture
Section intitulée « Ce qui est vérifié avant toute écriture »Le format, les collections inconnues, les champs inconnus, les contraintes sur les valeurs, les opérations sur les champs, les cibles de conflit et la portée de $ref sont tous vérifiés avant l’ouverture de la transaction. Un lot s’exécute selon le principe du tout-ou-rien, et constater une faute de frappe à la 40e opération obligerait autrement à annuler (rollback) les 39 écritures précédentes.
Le volume est plafonné au même nombre d’opérations qu’une écriture en masse (1000 par défaut), car un lot conserve ses verrous pendant toute la durée de la transaction. Les callbacks de chaque opération s’exécutent à l’intérieur, l’un après l’autre : un hook lent est donc payé une fois par ligne avec la transaction ouverte — 100 lignes dont le beforeSave attend 50 ms retiennent une connexion, et les verrous de chaque ligne déjà écrite, pendant 5,3 s. Il en va de même pour une écriture /bulk. Voir Un callback retient une connexion. Un pilote incapable de rendre le lot atomique répond BATCH_UNSUPPORTED plutôt que de se rabattre sur une boucle d’écritures unitaires — ce qui n’assurerait ni l’atomicité ni l’aller-retour unique pour lesquels un lot est utilisé.
Un update ou un delete ciblant une ligne inexistante fait échouer l’ensemble du lot avec un code 404, pour la même raison qu’une écriture partielle est rejetée partout ailleurs : une application partielle est un état pour lequel il n’existe aucune reprise satisfaisante.