Praxsuite

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

values

Array de filas, cada una mapeando columna a valor

returning

true devuelve las filas insertadas con todas las columnas legibles; false u omitido devuelve un conteo; un array de nombres devuelve solo esas

Columnas que no podés setear

Las inyecta la plataforma, y mandar cualquiera devuelve 400 INVALID_MUTATION:

Columna

Comportamiento

ID

GUID generado

CREATEDDATE / UPDATEDDATE

Timestamp UTC actual

CREATEDBY / UPDATEDBY

El principal autenticado

AUTONUMBER

Autoincremental

POSITION

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

set

Columnas mapeadas a sus nuevos valores. Máximo 50.

where

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

query — y a las mutaciones si no hay filtro de escritura

Filtro de escritura

insert, update, delete, reemplazando al de lectura

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

bus

Clave completa, {topico}:{instancia}. El tópico debe estar declarado y habilitado.

event

Nombre sobre el que switchean los suscriptores. Por defecto row.created / row.updated / row.deleted.

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 returning salen las filas, mapeadas a nombres lógicos y enmascaradas con las mismas reglas que una lectura. Sin returning salen 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 set

50

Condiciones where

50

Timeout

30 segundos