Écrire des données

Définir des modèles, créer et importer des enregistrements et modifier des champs depuis un script via l'API GraphQL d'administration.

16 septembre 2026

Tout ce que vous faites avec les enregistrements dans l'admin - définir un modèle, créer un enregistrement, importer un tableur, modifier un champ - se fait aussi depuis un script. Cette page décrit le chemin d'une intégration : un export d'ERP, un flux de PIM, une migration ponctuelle. Elle se termine par un script complet que vous pouvez exécuter tel quel.

Où vont les écritures

Les écritures vont vers l'API GraphQL d'administration, et non vers la route de diffusion que lit votre frontend :

https://api.cmssy.io/graphql

La route de diffusion (/public/{org}/{workspace}/graphql) sert le contenu publié et n'a aucune mutation d'enregistrement - une écriture envoyée là échoue à la validation. Chaque requête vers l'API d'administration porte deux en-têtes :

  • Authorization: Bearer cs_... - un jeton API. Le jeton agit en tant qu'utilisateur qui l'a créé : c'est donc le rôle de cet utilisateur dans l'espace de travail qui décide de ce que le script peut écrire.
  • x-workspace-id - l'identifiant de l'espace de travail cible, dans Settings → Workspace. Un jeton limité à un espace de travail refuse tous les autres.

Vérifiez les deux avant d'écrire quoi que ce soit :

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

Une liste de modèles (éventuellement vide) signifie que vous êtes connecté. Not authorized signifie que le jeton ou l'identifiant de l'espace de travail est incorrect, ou que le jeton appartient à un autre espace de travail.

Définir un modèle

Les enregistrements appartiennent à un modèle. Créez-le une fois, dans l'admin ou depuis le script. Activer product fait du modèle un catalogue produit : skuField désigne le champ qui doit être unique, et priceField le prix, en unités principales (149.99, pas en centimes).

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

Le slug est unique dans l'espace de travail ; un second create avec le même slug est refusé. Un champ de relation pointe vers un autre modèle avec "type": "relation", "relationTo": "model:<slug>".

Champs uniques. Pour empêcher deux enregistrements de partager une valeur dans un autre champ - votre propre numéro de référence, un e-mail - indiquez sa clé dans uniqueFields à la création ou à la modification du modèle. Les champs texte, e-mail, url, téléphone et nombre peuvent être uniques ; les champs traduisibles et les champs de prix et de stock d'un produit ne le peuvent pas. Un second enregistrement avec la même valeur est refusé avec un message qui nomme le champ. Les e-mails sont comparés sans tenir compte de la casse, et les espaces en début et en fin sont ignorés. Définir uniqueFields sur un modèle qui contient déjà des doublons est refusé : l'erreur liste les valeurs en conflit et rien ne change.

Créer un enregistrement

data est un objet JSON indexé par clé de champ. Il est validé par rapport au modèle : un champ obligatoire manquant ou un SKU en double est refusé avec un message qui nomme le champ.

mutation ($input: CreateModelRecordInput!) {
  record {
    create(input: $input) { id data }
  }
}
{ "input": { "modelId": "<model id>", "data": { "sku": "CHAIR-1", "name": "Oak chair", "price": 149.99 } } }

Import en masse

record.import accepte jusqu'à 1000 lignes par appel. La limite de 5 Mo que vous connaissez peut-être de l'import CSV de l'admin ne s'applique qu'au navigateur. Envoyez les fichiers plus gros par lots de 1000.

mutation ($input: ImportModelRecordsInput!) {
  record {
    import(input: $input) {
      importedCount
      updatedCount
      errors { row message }
      records { row id action }
    }
  }
}

Une ligne qui échoue à la validation n'arrête pas les autres. Elle revient dans errors avec sa position dans le lot, comptée à partir de 1, et records donne l'identifiant de chaque enregistrement écrit par l'appel :

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

Traitez un errors non vide comme un échec, sinon un catalogue incomplet passe inaperçu.

Relancer le même fichier

Passez upsertKey pour rendre un import rejouable. Il désigne le skuField du produit ou l'un des uniqueFields du modèle. Une ligne dont la valeur correspond à un enregistrement existant met cet enregistrement à jour - seuls les champs présents dans la ligne changent, les autres gardent leur valeur - et toute autre ligne est créée. Importer deux fois le même fichier ne change pas le nombre d'enregistrements.

{
  "input": {
    "modelId": "<model id>",
    "upsertKey": "sku",
    "rows": [
      { "sku": "CHAIR-1", "price": 139 },
      { "sku": "STOOL-1", "name": "Oak stool", "price": 59 }
    ]
  }
}

importedCount compte les enregistrements créés et updatedCount ceux modifiés. Une ligne rapprochée qui ne change rien apparaît dans records comme UNCHANGED. Une ligne sans clé, ou avec une clé déjà utilisée par une ligne précédente du lot, revient dans errors. upsertKey n'est pas accepté sur les modèles avec comptes membres.

Modifier un enregistrement

Utilisez record.patch. C'est un JSON merge patch : les champs que vous omettez gardent leur valeur enregistrée, null supprime un champ, et un champ traduisible ou de type object qui reçoit un objet est fusionné clé par clé. Deux scripts qui modifient des champs différents du même enregistrement ne s'écrasent pas mutuellement.

mutation ($input: PatchModelRecordInput!) {
  record {
    patch(input: $input) { id data }
  }
}
{ "input": { "id": "<record id>", "data": { "price": 129.99, "color": "natural" } } }

record.update prend la même entrée mais remplace tout l'objet data - tout champ que vous n'envoyez pas disparaît. Ne l'utilisez que si le script est responsable de tous les champs de l'enregistrement. record.delete(id) supprime un enregistrement.

Relire

record.list renvoie jusqu'à 100 enregistrements par appel (20 si limit est omis) ; paginez avec offset jusqu'à ce que hasMore soit false.

query ($modelId: ID!, $offset: Int) {
  record {
    list(modelId: $modelId, limit: 100, offset: $offset, sort: "createdAt_asc") {
      total
      hasMore
      items { id data }
    }
  }
}

Limites et nouvelles tentatives

Un espace de travail accepte un nombre fixe d'écritures d'enregistrements par minute de la part des jetons API - la valeur actuelle se trouve sur Limites de débit. Un import coûte un par ligne ; toute autre écriture coûte un. Au-delà du budget, l'API répond HTTP 429 avec un en-tête Retry-After : attendez ce nombre de secondes et renvoyez la même requête. Un appel unique plus grand que tout le budget est refusé d'emblée - découpez-le. Les écritures faites dans l'admin ne sont pas comptées.

Un script complet

Node 22 ou plus récent, sans dépendance. Définissez CMSSY_TOKEN et CMSSY_WORKSPACE_ID, enregistrez le script sous import-products.mjs et lancez node import-products.mjs. Il crée le modèle et un enregistrement, importe trois lignes (la troisième est refusée pour SKU en double), modifie le premier enregistrement et compte ce qui s'y trouve.

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);

Sortie attendue :

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 3

Une seconde exécution s'arrête à la première étape, car le slug du modèle est déjà pris. Supprimez le modèle dans l'admin ou changez le slug.

Une synchronisation complète, de bout en bout

examples/catalog-import synchronise un catalogue de grossiste public (Wide World Importers de Microsoft) vers cmssy : fournisseurs et groupes d'articles comme modèles à part entière, produits reliés aux deux, une seconde exécution qui n'écrit que ce qui a changé, et le rejeu de l'historique des modifications du catalogue sous forme de patchs. Forkez-le comme point de départ d'une vraie intégration.

Ce qui n'est pas encore possible

  • Ni file d'attente, ni tampon, ni nouvelle tentative de notre côté. L'API écrit ce qu'elle reçoit, au moment où elle le reçoit. L'ordre, les nouvelles tentatives et l'idempotence relèvent de votre intégration ; quand deux processus modifient le même champ, la dernière écriture l'emporte.