Mutaciones
Vincent Depassier · 29 de agosto de 2026
PraxQL — Mutaciones
Las mutaciones escriben datos por el mismo endpoint /query que las lecturas. Mandás una clave mutation en vez de query.
El cuerpo
{
"refs": {
"AliasTabla": "table-guid"
},
"mutation": {
"type": "insert",
"table": "AliasTabla"
}
}type es insert, update o delete. mutation y query son mutuamente excluyentes.
Quién puede mutar
El scope de tabla de la credencial necesita AccessLevel en write o readwrite. Una key de solo lectura recibe 403 SCOPE_VIOLATION antes de que se parsee nada. Un scope de tabla nuevo se crea como `read`, así que escribir siempre es algo que alguien habilitó a propósito.
Llamá a las mutaciones desde código de servidor con una Server Key (sk_live_). Una key con permiso de escritura en un navegador es una key con permiso de escritura en manos de cualquiera.
INSERT
{
"refs": {
"Clientes": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
},
"mutation": {
"type": "insert",
"table": "Clientes",
"values": [
{ "Nombre": "Acme Corp", "Email": "hola@acme.com", "Region": "EU" }
],
"returning": true
}
}Campo | Descripción |
| Array de filas, cada una mapeando columna a valor |
|
|
Columnas que no podés setear
Las inyecta la plataforma, y mandar cualquiera devuelve 400 INVALID_MUTATION:
Columna | Comportamiento |
| GUID generado |
| Timestamp UTC actual |
| El principal autenticado |
| Autoincremental |
| Asignada sola |
Que CREATEDBY no se pueda setear no es solo higiene — es lo que la vuelve útil como ancla de seguridad. Ver la sección del filtro de escritura más abajo.
Máximo 100 filas por insert. Para una importación masiva, partila.
UPDATE
{
"refs": {
"Clientes": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
},
"mutation": {
"type": "update",
"table": "Clientes",
"set": { "Estado": "Inactivo" },
"where": [
{ "field": "UltimaActividad", "op": "lt", "value": "2025-01-01" }
]
}
}Campo | Descripción |
| Columnas mapeadas a sus nuevos valores. Máximo 50. |
| Obligatorio. Al menos una condición. |
{ "affectedRows": 12, "durationMs": 8 }DELETE
{
"refs": {
"Clientes": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
},
"mutation": {
"type": "delete",
"table": "Clientes",
"where": [
{ "field": "Estado", "op": "eq", "value": "Archivado" },
{ "field": "EliminadoEn", "op": "isNull" }
]
}
}El where es obligatorio, y ese es el punto
Un update o delete sin where se rechaza con 400 INVALID_MUTATION. No hay flag para saltearlo.
Una mutación sin filtro no es un error raro — es lo que produce un bug cuando el valor de un filtro vuelve vacío y la condición se cae. Exigir la cláusula hace que ese bug falle ruidosamente en vez de vaciar una tabla.
Los row filters también aplican a las escrituras
Si el scope de tabla tiene un row filter, se inyecta en el where de todo update y delete, igual que en toda consulta. Quien llama no puede verlo, sobrescribirlo ni escribir esquivándolo: sus condiciones se combinan con AND, nunca lo reemplazan.
El filtro de escritura separado
Un scope puede llevar dos filtros:
Filtro | Aplica a |
Filtro de filas |
|
Filtro de escritura |
|
Dejá vacío el de escritura y una sola regla gobierna todo: una fila que quien llama no puede leer es una fila que no puede modificar. Ese es el default y suele ser el correcto.
Definilo cuando varias personas comparten un contenedor. Un canal de chat con scope Canal = X y permiso de escritura deja que cada miembro edite y borre los mensajes de todos los demás, porque un solo filtro solo decide qué filas se matchean. Dos filtros expresan la regla que querías de verdad:
filtro de lectura → Canal eq X ("leé toda la sala")
filtro de escritura → CREATEDBY eq {{claim:sub}} ("cambiá solo lo tuyo")`CREATEDBY` es el ancla a la que recurrir. El gateway la estampa desde el token verificado de quien llama en cada insert y se niega a que un cliente la setee, así que es la única columna que no se puede falsificar.
Un UPDATE se verifica una segunda vez después de ejecutarse: si las filas que tocó no satisfacen el filtro de escritura, la transacción se revierte en vez de commitear.
Permisos de columna
Escribir una columna necesita CanWrite en su scope de columna, y `CanWrite` viene apagado por defecto — al revés que CanRead. Así que apenas definís scopes de columna sobre una tabla, todas las columnas quedan de solo lectura hasta que digas lo contrario. Una tabla sin ningún scope de columna sigue siendo totalmente escribible.
Una columna que la credencial no puede escribir se rechaza, no se ignora. Una mutación que la nombra devuelve 400 INVALID_MUTATION y dice cuál — descartarla en silencio dejaría creer que el valor se guardó.
Marcadores de claims
Para quienes llaman con un JWT de usuario final, un valor puede referenciar un claim del token con {{claim:nombreClaim}}. Se reemplaza del lado del servidor antes de ejecutar:
"values": [
{ "OwnerId": "{{claim:sub}}", "Titulo": "Mi registro" }
]Así los inserts multi-tenant quedan marcados con el id de quien llama sin confiar en que el cliente lo mande. El valor sale del token validado, así que nadie puede hacerse pasar por otro editando el cuerpo del request.
O dejá que lo haga la columna
Un scope de columna puede llevar una plantilla de valor por defecto con la misma sintaxis {{claim:…}}. Cuando está definida, el valor se inyecta en todo insert y quien llama no lo puede sobrescribir, aunque mande esa columna explícitamente.
La diferencia importa. Un marcador en tu request body es tu app eligiendo marcar bien la fila; un default en el scope de columna es la plataforma negándose a que se marque de otra forma. Para columnas de propiedad, preferí lo segundo.
Anunciar una mutación en el Event Bus
Una mutación puede anunciarse sola en un bus del Event Bus apenas commitea, sin ninguna automatización en el camino:
{
"refs": { "m": "<table_id>" },
"mutation": {
"type": "insert",
"table": "m",
"values": [{ "Canal": "9f2c...", "Cuerpo": "hola" }],
"returning": ["Cuerpo", "Canal"],
"notify": { "bus": "chat:9f2c8ab1-...", "event": "message.created" }
}
}Campo | Significado |
| Clave completa, |
| Nombre sobre el que switchean los suscriptores. Por defecto |
Tres reglas definen para qué sirve:
Se dispara después del commit, nunca antes. No se anuncia nada que todavía podría revertirse.
El cuerpo del evento se construye con lo que realmente se escribió, nunca con tu request. Con
returningsalen las filas, mapeadas a nombres lógicos y enmascaradas con las mismas reglas que una lectura. Sinreturningsalen solo los ids.Es best-effort. La fila ya es durable; a un fan-out efímero jamás se le permite hacer fallar una escritura persistente.
Publicar requiere participate sobre el bus destino, y se verifica antes de ejecutar la escritura, así que un notify rechazado falla el request en vez de commitear y quedarse callado. El tópico reservado user: no puede ser destino.
Límites
Límite | Valor |
Filas por insert | 100 |
Columnas por | 50 |
Condiciones | 50 |
Timeout | 30 segundos |