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>".
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
errors { row message }
}
}
}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:
{ "importedCount": 2, "errors": [{ "row": 3, "message": "sku: A record with this SKU already exists" }] }Trata un errors no vacío como una ejecución fallida; si no, un catálogo incompleto pasa desapercibido.
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
- La importación solo inserta. Importar dos veces el mismo archivo vuelve a crear cada fila. Los modelos de producto rechazan un SKU repetido fila a fila; cualquier otro modelo acepta el duplicado. Hasta que la importación pueda actualizar por clave, lee primero lo que ya existe y envía las filas nuevas a
importy las modificadas apatch. - La importación no devuelve los ids que crea. Para relacionar después tus filas con los registros, lista el modelo y búscalos por tu propia clave.
- Solo el SKU puede ser único. Ningún otro campo puede declararse único, así que tus propios números de referencia no están protegidos contra duplicados.
- 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.