Ir al contenido

Escritura a través de REST

Los verbos se encuentran en la página de la REST API. Esta sección trata sobre las cinco cosas que una escritura puede solicitar más allá de su verbo, y el endpoint que escribe a través de múltiples colecciones a la vez. Todas ellas son opcionales por petición: una escritura que no solicite nada de esto se comporta exactamente como siempre lo ha hecho.

Más allá de los verbos, las rutas de escritura admiten cinco elementos que cambian el comportamiento de una escritura. Los cinco son opcionales por petición, por lo que nada de lo aquí expuesto cambia lo que hace una petición que no los solicite. Las rutas anidadas (/api/data/authors/42/posts) ejecutan el mismo código de escritura que las raíz, así que todo lo de esta sección se les aplica también, excepto ?on_conflict=.

El cuerpo de una escritura debe ser un objeto JSON. null, un número, una cadena o un array es un 400 BAD_REQUEST.

Una actualización no puede cambiar la clave de una fila. Un cuerpo que nombra la clave con otro valor es un 400 KEY_IMMUTABLE, antes de que se ejecute ningún hook y sin que se escriba nada, sea cual sea la vía por la que llega la actualización: REST, el socket en tiempo real, MCP o el rebase.data en proceso. La clave que la fila ya tiene se acepta, como la acepta un formulario que devuelve toda la fila. Para mover una fila a una clave nueva, créala ahí y elimina la antigua. En 0.23 tal actualización responde 500, y una en proceso ya ha movido la fila cuando lanza el error.

Idempotency-Key: <uuid> en cualquier escritura significa «si ya has respondido a esta petición exacta, responde de nuevo en lugar de ejecutarla dos veces».

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

Un cliente que nunca recibe una respuesta no puede saber si la escritura se confirmó, por lo que reintenta — y sin una clave, el servidor no puede distinguir ese reintento de una segunda escritura genuina. En una tabla con un id asignado por el servidor, esto genera una fila duplicada, ya que el id inventado por el cliente nunca se utilizó.

Una clave identifica una sola petición: registra el método, la ruta, la cadena de consulta y el cuerpo para los que fue solicitada. Si se vuelve a enviar esa petición exacta, su respuesta se reproduce; si se envía una diferente bajo la misma clave, se rechaza con IDEMPOTENCY_KEY_REUSED (422) en lugar de responder con el resultado de la primera. Un reintento que llega mientras la primera aún está en curso recibe IDEMPOTENCY_KEY_IN_PROGRESS (409) — envíela de nuevo una vez que la primera haya finalizado. La cadena de consulta cuenta porque cambia lo que hace una petición: DELETE /api/data/posts/5?hard=true bajo la clave de un borrado lógico anterior es una petición distinta, no una repetición. El orden de sus parámetros no importa.

Se admite en POST, PATCH, DELETE, las tres rutas /bulk y /_batch. Las claves duran 24 horas y se limitan al emisor autenticado; una petición no autenticada no tiene una entidad principal a la que asociarla, por lo que el encabezado se ignora en ese caso. Un backend que no pueda almacenar claves ignora el encabezado en lugar de rechazar la escritura.

DELETE es el caso que vale la pena leer dos veces. Si se repite sin una clave, el segundo intento comprueba que la fila ya no existe y responde con 404 — lo cual un cliente que reintenta interpreta como un error permanente de una eliminación que en realidad tuvo éxito. Con una clave, reproduce el 204.

GET /api/data/:slug/:id devuelve un ETag, y también lo hace el GET anidado de una sola fila. Envíelo de vuelta como If-Match en un PATCH o DELETE posterior y la escritura se rechazará con 412 si la fila ha cambiado en el ínterin.

Un PATCH correcto responde con el ETag de la versión que escribió, de modo que la siguiente escritura condicional no necesita leer antes. La etiqueta viaja también en un 204 respondido por Prefer: return=minimal, y una respuesta reenviada para una Idempotency-Key lleva la misma etiqueta que la primera. Una suscripción en vivo a una fila lleva la misma etiqueta en cada fotograma (consulta Tiempo real).

# 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

Sin esto, lectura-modificación-escritura funciona como «gana el último» sobre todo lo que la segunda escritura no haya enviado: dos editores con un segundo de diferencia tienen éxito y el cambio del primero desaparece sin ningún error en ninguna parte.

La etiqueta se deriva de una propiedad date con autoValue: "on_update" cuando la colección declara una — esa columna ya es una versión — y de un hash estable de la fila en caso contrario. If-Match: * solo afirma que la fila existe. No se escribe nada si la precondición falla.

El formulario de registro del panel de administración guarda con If-Match: un guardado hecho sobre una versión que alguien ha reemplazado entretanto se rechaza, el formulario lo indica y la edición se queda en el formulario, reajustada sobre la nueva versión con los campos en conflicto marcados.

La etiqueta se compara dentro de la propia transacción de la escritura, una vez bloqueada la fila. Dos escrituras con la misma etiqueta no pueden pasar ambas: la segunda espera a que la primera confirme, lee lo que escribió y se rechaza con 412.

El valor de una propiedad en el cuerpo de un PATCH puede ser una operación sobre el valor almacenado en lugar de un valor estático:

curl -X PATCH /api/data/posts/p1 -d '{
"views": { "$inc": 1 },
"tags": { "$push": "featured" },
"meta": { "$merge": { "seen": true } }
}'
Operador Tipo de propiedad Se convierte en
$inc number SET col = COALESCE(col, 0) + n
$push array array_append(col, …) o una concatenación jsonb
$pull array array_remove(col, …) o una reagregación jsonb
$merge map `col

El beneficio principal es la lectura que el emisor ya no necesita hacer. Expresar views + 1 como un valor implica leerlo primero, y dos peticiones que lean 4, sumen uno y escriban 5 terminarán con 5 — sin que ninguna de las dos respuestas indique que se perdió un incremento. Al compilarse dentro de la sentencia, la aritmética se ejecuta dentro del bloqueo de la fila y no puede perderse.

Exactamente un operador por campo. Un operador sobre un tipo de propiedad para el que no está definido, un $operator desconocido o un operando con un formato incorrecto resulta en un 400 (INVALID_FIELD_OPERATION) indicando el campo — un error tipográfico nunca se escribe en la columna como un documento JSON.

Un map es la excepción, porque su valor es un documento JSON y puede traer claves $ propias — el $schema y el $id de un JSON Schema, una exportación de MongoDB. En un map, solo un objeto con exactamente una clave que sea uno de los operadores anteriores es una operación; cualquier otro objeto se almacena tal como se envió, claves $ incluidas. Así, { "meta": { "$merge": { … } } } fusiona, y { "meta": { "$schema": "https://json-schema.org/draft/2020-12/schema" } } se almacena. Por tanto, un operador mal escrito en un map también se almacena; en cualquier otro tipo se sigue rechazando. El cliente sin conexión no tiene esquema para distinguir un map de cualquier otra cosa, así que sin conexión rechaza ese valor como una operación de campo; envíalo cuando vuelva la conexión.

Las operaciones se aplican únicamente a actualizaciones: en una fila que aún no existe no hay nada sobre lo que operar, por lo que se rechazan en POST, en creaciones /bulk y en upserts.

Una operación responde a la validation de la propiedad igual que un valor. Los elementos añadidos se juzgan con las reglas del elemento, las claves fusionadas con las propiedades del mapa y el valor que una operación produce con los límites declarados: min, max, moreThan, lessThan, positive y negative de un número, y el mínimo y máximo de elementos (min, max) de un array. Esta última comprobación es una condición del mismo UPDATE, no una lectura previa. Con stock: { validation: { min: 0 } }, { "stock": { "$inc": -1 } } tiene éxito mientras el stock sea al menos 1 y se rechaza en 0 con el mismo 400 (VALIDATION_CONSTRAINT) que recibe { "stock": -1 }, indicando el campo y el límite. Dos decrementos concurrentes de un stock de 1 no pueden tener éxito ambos, porque el segundo se evalúa contra la fila que confirmó el primero. Un número sin valor cuenta como 0 y un array sin valor como vacío. Una operación rechazada no escribe nada, ni siquiera los valores simples que la acompañan en el cuerpo. Como la comprobación forma parte de la sentencia, también se aplica a las escrituras en proceso a través de rebase.data, que omiten la validación a nivel de petición.

POST /api/data/:slug?on_conflict=sku inserta la fila, o actualiza la que ya tiene ese SKU, en lugar de fallar por ella. La ruta bulk admite el mismo objetivo como onConflict junto a upsert: true, al igual que cada operación upsert de un lote.

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

El objetivo debe contar con una garantía de unicidad con la que la base de datos pueda coincidir: la clave primaria (el valor por defecto si no se especifica ninguno), una propiedad con validation: { unique: true } o las columnas de un índice unique: true. Cualquier otra cosa produce un error 400 (INVALID_CONFLICT_TARGET) listando los objetivos que sí existen; de lo contrario, Postgres respondería there is no unique or exclusion constraint matching the ON CONFLICT specification desde el interior de una transacción que ya ha realizado operaciones.

Especificar un objetivo sin upsert: true en una escritura masiva también genera un 400: ignorarlo silenciosamente transformaría una importación reejecutable en una que duplica registros.

?on_conflict= se rechaza en una creación anidada (INVALID_CONFLICT_TARGET). La fila con la que coincidiera podría estar bajo otro padre, y el upsert la movería bajo este. Envíe el upsert a la ruta propia de la colección.

Un upsert que encuentra una fila almacenada es una actualización de ella. El servidor lee la fila que nombra la clave, en el ámbito del emisor de la llamada y dentro de la transacción de la escritura, y ejecuta la actualización ordinaria: se escriben los campos del cuerpo, los que omite conservan sus valores almacenados en lugar de restablecerse a su defaultValue, beforeSave y afterSave ven status: "existing" con los valores anteriores, y el historial registra una actualización. Una fila que la lectura no pudo ver, o una insertada de forma concurrente, la sigue capturando ON CONFLICT … DO UPDATE, que establece solo lo que escribieron el cuerpo y los hooks, y las marcas on_update. Una clave que nombra una fila fuera del ámbito beforeQuery del emisor de la llamada responde 404. Una clave que pertenece a una fila en la papelera responde 409 ROW_IN_TRASH y no escribe nada: restaura la fila primero, o púrgala.

Una fila que ya existía conserva su marca de tiempo on_create y su creador user_on_create. Un conflicto implica que la creación de la fila es un hecho del pasado, y una reimportación nocturna que restableciera createdAt en todo lo que modificara alteraría por completo cualquier consulta de «nuevos esta semana».

La colección de usuarios (la que tiene auth activado) rechaza un upsert por cualquier vía — ?on_conflict=, /bulk con upsert: true, una operación upsert de _batch, el upsert() del SDK y el socket en tiempo real — con un 400 (INVALID_CONFLICT_TARGET). Sus filas son los usuarios, y crear uno y modificarlo siguen reglas distintas: una creación cifra la contraseña con hash, normaliza el email, ejecuta onCreateUser y envía la invitación, y una actualización no puede degradar al último administrador. Un upsert solo sabe cuál de las dos es dentro de la escritura, demasiado tarde para aplicar ninguna. Cree los usuarios con un POST normal y modifíquelos con PATCH.

Por defecto, cada escritura responde con la fila completa, que es la que contiene las decisiones tomadas por el servidor: un id serial, una marca autoValue, o lo que beforeSave haya reescrito. Envíe Prefer: return=minimal cuando no necesite nada de esto:

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

Una escritura individual responde con 204. Una escritura /bulk o /_batch responde con 200 devolviendo los ids en lugar de las filas — en una creación, el id es lo único que el emisor no puede calcular, por lo que descartarlo requeriría volver a leer la tabla mediante alguna clave natural para conocer lo que se acaba de escribir. Una clave de una sola columna se devuelve como un escalar; una clave compuesta, como un objeto con sus columnas.

POST /api/data/_batch escribe a través de colecciones en una única transacción, bajo el propio rol del emisor, con la misma validación, callbacks y seguridad a nivel de fila que aplicaría la ruta de una sola fila de cada operación.

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 es create, update, upsert o delete. update y delete requieren un id; create, update y upsert requieren values; upsert puede especificar un objetivo onConflict bajo los mismos términos descritos anteriormente. data está alineado con operations — la fila escrita para create, update o upsert, y null para delete —, de modo que un índice en uno corresponde al mismo índice en el otro.

$ref: apuntar a una fila creada por el mismo lote

Sección titulada «$ref: apuntar a una fila creada por el mismo lote»

Una operación puede asignarse un identificador con ref, y cualquier operación posterior puede colocar { "$ref": "<name>.<field>" } donde iría un valor — dentro de values a cualquier nivel de anidamiento, o como un id. Esto se resuelve al campo correspondiente de la fila que escribió la operación nombrada.

Esta es la razón por la que existe este endpoint en lugar de ser un bucle: la clave foránea de un elemento secundario no se puede conocer hasta que el elemento principal haya sido insertado, por lo que sin esto, el elemento principal y los secundarios tendrían que ser peticiones independientes — precisamente la secuencia que puede completarse a medias. Solo se resuelven las referencias hacia atrás; una referencia hacia adelante se rechaza antes de que se inicie la transacción.

La estructura, las colecciones desconocidas, los campos desconocidos, las restricciones de valores, las operaciones de campo, los objetivos de conflicto y la accesibilidad de $ref se determinan antes de que se inicie la transacción. Un lote es de tipo todo o nada, y encontrar un error tipográfico en la operación 40 costaría, de lo contrario, la reversión de las 39 escrituras anteriores.

Está limitado al mismo número de operaciones que una escritura masiva (1000 por defecto), ya que un lote mantiene sus bloqueos durante toda la transacción. Los callbacks de cada operación se ejecutan dentro de ella, uno tras otro, así que un hook lento se paga una vez por fila con la transacción abierta: 100 filas cuyo beforeSave espera 50 ms retienen una conexión, y los bloqueos de cada fila escrita hasta el momento, durante 5,3 s. Lo mismo vale para una escritura /bulk. Consulta Un callback retiene una conexión. Un controlador que no pueda hacer que el lote sea atómico responderá con BATCH_UNSUPPORTED en lugar de recurrir a un bucle de escrituras individuales — lo que no proporcionaría ni la atomicidad ni el único viaje de ida y vuelta por los que se recurre a un lote.

Un update o delete que haga referencia a una fila inexistente hace fallar a todo el lote con un 404, por la misma razón por la que se rechaza una escritura parcial en cualquier otro lugar: el estado aplicado a medias es un estado sin una buena recuperación.