É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>".

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
      errors { row message }
    }
  }
}

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 :

{ "importedCount": 2, "errors": [{ "row": 3, "message": "sku: A record with this SKU already exists" }] }

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

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

  • L'import ne fait qu'insérer. Importer deux fois le même fichier recrée chaque ligne. Les modèles produit refusent un SKU répété ligne par ligne ; tout autre modèle accepte le doublon. Tant que l'import ne sait pas mettre à jour par clé, lisez d'abord l'existant, puis envoyez les nouvelles lignes à import et les lignes modifiées à patch.
  • L'import ne renvoie pas les identifiants créés. Pour rapprocher ensuite vos lignes des enregistrements, listez le modèle et retrouvez-les par votre propre clé.
  • Seul le SKU peut être unique. Aucun autre champ ne peut être déclaré unique : vos propres numéros de référence ne sont donc pas protégés contre les doublons.
  • 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.