Escribir datos
Define modelos, crea e importa registros y modifica campos desde un script con la API GraphQL de administración.
Todo lo que haces con los registros en el admin - definir un modelo, crear un registro, importar una hoja de cálculo, cambiar un campo - también puedes hacerlo desde un script. Esta página describe el camino de una integración: una exportación de un ERP, un feed de un PIM, una migración puntual. Termina con un script completo que puedes ejecutar tal cual.
Adónde van las escrituras
Las escrituras van a la API GraphQL de administración, no a la ruta de entrega de la que lee tu frontend:
https://api.cmssy.io/graphqlLa ruta de entrega (/public/{org}/{workspace}/graphql) sirve contenido publicado y no tiene ninguna mutación de registros: una escritura enviada ahí falla en la validación. Cada petición a la API de administración lleva dos cabeceras:
Authorization: Bearer cs_...- un token de API. El token actúa como el usuario que lo creó, así que el rol de ese usuario en el workspace decide qué puede escribir el script.x-workspace-id- el id del workspace de destino, en Settings → Workspace. Un token limitado a un workspace rechaza cualquier otro.
Comprueba ambos antes de escribir nada:
curl https://api.cmssy.io/graphql \
-H "Authorization: Bearer $CMSSY_TOKEN" \
-H "x-workspace-id: $CMSSY_WORKSPACE_ID" \
-H "Content-Type: application/json" \
-d '{"query":"{ model { list { id slug name } } }"}'Una lista de modelos (aunque esté vacía) significa que tienes acceso. Not authorized significa que el token o el id del workspace son incorrectos, o que el token pertenece a otro workspace.
Define un modelo
Los registros pertenecen a un modelo. Créalo una vez, desde el admin o desde el script. Activar product convierte el modelo en un catálogo de productos: skuField indica el campo que debe ser único y priceField el precio, en unidades principales (149.99, no céntimos).
mutation ($input: CreateModelDefinitionInput!) {
model {
create(input: $input) { id }
}
}{
"input": {
"name": "Catalog product",
"slug": "catalog-product",
"displayField": "name",
"product": { "enabled": true, "skuField": "sku", "priceField": "price" },
"fields": [
{ "key": "sku", "label": "SKU", "type": "text", "required": true },
{ "key": "name", "label": "Name", "type": "text", "required": true },
{ "key": "price", "label": "Price", "type": "number" },
{ "key": "color", "label": "Color", "type": "text" }
]
}
}El slug es único dentro del workspace; un segundo create con el mismo slug se rechaza. Un campo de relación apunta a otro modelo con "type": "relation", "relationTo": "model:<slug>".
Campos únicos. Para que dos registros no compartan un valor en otro campo - tu propio número de referencia, un email - añade su clave a uniqueFields al crear o modificar el modelo. Pueden ser únicos los campos de texto, email, url, teléfono y número; los campos traducibles y los de precio y stock del producto, no. Un segundo registro con el mismo valor se rechaza con un mensaje que nombra el campo. Los emails se comparan sin distinguir mayúsculas y minúsculas, y los espacios al principio y al final no cuentan. Definir uniqueFields en un modelo que ya tiene duplicados se rechaza: el error lista los valores en conflicto y no cambia nada.
Crea un registro
data es un objeto JSON indexado por clave de campo. Se valida contra el modelo: un campo obligatorio ausente o un SKU duplicado se rechaza con un mensaje que nombra el campo.
mutation ($input: CreateModelRecordInput!) {
record {
create(input: $input) { id data }
}
}{ "input": { "modelId": "<model id>", "data": { "sku": "CHAIR-1", "name": "Oak chair", "price": 149.99 } } }Importación en bloque
record.import acepta hasta 1000 filas por llamada. El límite de 5 MB que quizá conozcas de la importación CSV del admin solo se aplica en el navegador. Envía los archivos más grandes en lotes de 1000.
mutation ($input: ImportModelRecordsInput!) {
record {
import(input: $input) {
importedCount
updatedCount
errors { row message }
records { row id action }
}
}
}Una fila que no pasa la validación no detiene a las demás. Vuelve en errors con su posición en el lote, contando desde 1, y records da el id de cada registro que escribió la llamada:
{
"importedCount": 2,
"updatedCount": 0,
"errors": [{ "row": 3, "message": "sku: A record with this SKU already exists" }],
"records": [{ "row": 1, "id": "6aaa...", "action": "CREATED" }, { "row": 2, "id": "6aab...", "action": "CREATED" }]
}Trata un errors no vacío como una ejecución fallida; si no, un catálogo incompleto pasa desapercibido.
Volver a importar el mismo archivo
Pasa upsertKey para que una importación se pueda repetir. Indica el skuField del producto o uno de los uniqueFields del modelo. Una fila cuyo valor coincide con un registro existente actualiza ese registro - solo cambian los campos que trae la fila, el resto conserva su valor - y cualquier otra fila se crea. Importar dos veces el mismo archivo no cambia el número de registros.
{
"input": {
"modelId": "<model id>",
"upsertKey": "sku",
"rows": [
{ "sku": "CHAIR-1", "price": 139 },
{ "sku": "STOOL-1", "name": "Oak stool", "price": 59 }
]
}
}importedCount cuenta los registros creados y updatedCount los modificados. Una fila emparejada que no cambia nada aparece en records como UNCHANGED. Una fila sin clave, o con una clave que ya usó una fila anterior del lote, vuelve en errors. upsertKey no se acepta en modelos con cuentas de miembros.
Modifica un registro
Usa record.patch. Es un JSON merge patch: los campos que omites conservan su valor guardado, null elimina un campo, y un campo traducible o de tipo object al que le das un objeto se fusiona clave a clave. Dos scripts que modifican campos distintos del mismo registro no se sobrescriben entre sí.
mutation ($input: PatchModelRecordInput!) {
record {
patch(input: $input) { id data }
}
}{ "input": { "id": "<record id>", "data": { "price": 129.99, "color": "natural" } } }record.update recibe la misma entrada pero reemplaza todo el objeto data: cualquier campo que no envíes desaparece. Úsalo solo cuando el script sea responsable de todos los campos del registro. record.delete(id) elimina un registro.
Leer de vuelta
record.list devuelve hasta 100 registros por llamada (20 si omites limit); pagina con offset hasta que hasMore sea false.
query ($modelId: ID!, $offset: Int) {
record {
list(modelId: $modelId, limit: 100, offset: $offset, sort: "createdAt_asc") {
total
hasMore
items { id data }
}
}
}Límites y reintentos
Un workspace acepta de los tokens de API un número fijo de escrituras de registros por minuto; el valor actual está en Límites de uso. Una importación cuesta uno por fila; cualquier otra escritura cuesta uno. Pasado el presupuesto, la API responde HTTP 429 con una cabecera Retry-After: espera esos segundos y vuelve a enviar la misma petición. Una sola llamada mayor que todo el presupuesto se rechaza directamente: divídela. Las escrituras hechas en el admin no cuentan.
Un script completo
Node 22 o superior, sin dependencias. Define CMSSY_TOKEN y CMSSY_WORKSPACE_ID, guarda el script como import-products.mjs y ejecuta node import-products.mjs. Crea el modelo y un registro, importa tres filas (la tercera se rechaza por SKU duplicado), modifica el primer registro y cuenta lo que hay.
const API = "https://api.cmssy.io/graphql";
const headers = {
"content-type": "application/json",
authorization: `Bearer ${process.env.CMSSY_TOKEN}`,
"x-workspace-id": process.env.CMSSY_WORKSPACE_ID,
};
async function gql(query, variables) {
const res = await fetch(API, { method: "POST", headers, body: JSON.stringify({ query, variables }) });
if (res.status === 429) {
const wait = Number(res.headers.get("retry-after") ?? 1);
await new Promise((r) => setTimeout(r, wait * 1000));
return gql(query, variables);
}
const body = await res.json();
if (body.errors) throw new Error(body.errors.map((e) => e.message).join("; "));
return body.data;
}
const { model } = await gql(
`mutation ($input: CreateModelDefinitionInput!) { model { create(input: $input) { id } } }`,
{
input: {
name: "Catalog product",
slug: "catalog-product",
displayField: "name",
product: { enabled: true, skuField: "sku", priceField: "price" },
fields: [
{ key: "sku", label: "SKU", type: "text", required: true },
{ key: "name", label: "Name", type: "text", required: true },
{ key: "price", label: "Price", type: "number" },
{ key: "color", label: "Color", type: "text" },
],
},
},
);
const modelId = model.create.id;
const { record } = await gql(
`mutation ($input: CreateModelRecordInput!) { record { create(input: $input) { id data } } }`,
{ input: { modelId, data: { sku: "CHAIR-1", name: "Oak chair", price: 149.99 } } },
);
console.log("created", record.create.id);
const rows = [
{ sku: "TABLE-1", name: "Oak table", price: 499 },
{ sku: "LAMP-1", name: "Desk lamp", price: 39.5, color: "black" },
{ sku: "CHAIR-1", name: "Duplicate chair", price: 1 },
];
const imported = await gql(
`mutation ($input: ImportModelRecordsInput!) { record { import(input: $input) { importedCount errors { row message } } } }`,
{ input: { modelId, rows } },
);
console.log("imported", imported.record.import);
const patched = await gql(
`mutation ($input: PatchModelRecordInput!) { record { patch(input: $input) { data } } }`,
{ input: { id: record.create.id, data: { price: 129.99, color: "natural" } } },
);
console.log("patched", patched.record.patch.data);
const list = await gql(
`query ($modelId: ID!) { record { list(modelId: $modelId, limit: 100) { total items { id data } } } }`,
{ modelId },
);
console.log("total", list.record.list.total);Salida esperada:
created 6aaa...
imported {
importedCount: 2,
errors: [ { row: 3, message: 'sku: A record with this SKU already exists' } ]
}
patched { sku: 'CHAIR-1', name: 'Oak chair', price: 129.99, color: 'natural' }
total 3Una segunda ejecución se detiene en el primer paso, porque el slug del modelo ya está ocupado. Elimina el modelo en el admin o cambia el slug.
Una sincronización completa, de principio a fin
examples/catalog-import sincroniza en cmssy un catálogo mayorista público (Wide World Importers, de Microsoft): proveedores y grupos de artículos como modelos propios, productos relacionados con ambos, una segunda ejecución que solo escribe lo que cambió y la reproducción del historial de cambios del catálogo como patches. Haz un fork y úsalo como punto de partida de una integración real.
Lo que todavía no hace
- Sin cola, búfer ni reintentos por nuestra parte. La API escribe lo que recibe, en el momento en que lo recibe. El orden, los reintentos y la idempotencia son responsabilidad de tu integración; cuando dos procesos cambian el mismo campo, gana la última escritura.