Daten schreiben
Modelle definieren, Records anlegen und importieren und Felder per Skript über die Admin-GraphQL-API ändern.
Alles, was du im Admin mit Records machst - ein Modell definieren, einen Record anlegen, eine Tabelle importieren, ein Feld ändern -, geht auch per Skript. Diese Seite beschreibt den Weg einer Integration: ein ERP-Export, ein PIM-Feed, eine einmalige Migration. Am Ende steht ein vollständiges Skript, das du unverändert ausführen kannst.
Wohin Schreibzugriffe gehen
Schreibzugriffe gehen an die Admin-GraphQL-API, nicht an die Delivery-Route, von der dein Frontend liest:
https://api.cmssy.io/graphqlDie Delivery-Route (/public/{org}/{workspace}/graphql) liefert veröffentlichte Inhalte und hat überhaupt keine Record-Mutationen - ein Schreibzugriff dorthin scheitert an der Validierung. Jeder Request an die Admin-API trägt zwei Header:
Authorization: Bearer cs_...- ein API-Token. Das Token handelt als der Nutzer, der es erstellt hat; dessen Rolle im Workspace entscheidet also, was das Skript schreiben darf.x-workspace-id- die ID des Ziel-Workspace, zu finden unter Settings → Workspace. Ein auf einen Workspace beschränktes Token lehnt jeden anderen ab.
Prüfe beides, bevor du etwas schreibst:
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 } } }"}'Eine Liste von Modellen (auch eine leere) heißt: Du bist drin. Not authorized heißt: Token oder Workspace-ID sind falsch, oder das Token gehört zu einem anderen Workspace.
Ein Modell definieren
Records gehören zu einem Modell. Lege es einmal an, im Admin oder per Skript. Mit product wird das Modell zum Produktkatalog: skuField benennt das Feld, das eindeutig sein muss, und priceField den Preis in Haupteinheiten (149.99, nicht Cent).
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" }
]
}
}Der Slug ist innerhalb des Workspace eindeutig; ein zweites create mit demselben Slug wird abgelehnt. Ein Relationsfeld verweist mit "type": "relation", "relationTo": "model:<slug>" auf ein anderes Modell.
Einen Record anlegen
data ist ein JSON-Objekt mit den Feldschlüsseln als Keys. Es wird gegen das Modell validiert: Ein fehlendes Pflichtfeld oder eine doppelte SKU wird mit einer Meldung abgelehnt, die das Feld nennt.
mutation ($input: CreateModelRecordInput!) {
record {
create(input: $input) { id data }
}
}{ "input": { "modelId": "<model id>", "data": { "sku": "CHAIR-1", "name": "Oak chair", "price": 149.99 } } }Massenimport
record.import nimmt bis zu 1000 Zeilen pro Aufruf. Das 5-MB-Limit, das du vielleicht vom CSV-Import im Admin kennst, gilt nur im Browser. Schicke größere Dateien in Paketen von 1000.
mutation ($input: ImportModelRecordsInput!) {
record {
import(input: $input) {
importedCount
errors { row message }
}
}
}Eine Zeile, die die Validierung nicht besteht, hält die anderen nicht auf. Sie kommt in errors zurück, mit ihrer Position im Paket, ab 1 gezählt:
{ "importedCount": 2, "errors": [{ "row": 3, "message": "sku: A record with this SKU already exists" }] }Behandle ein nicht leeres errors als fehlgeschlagenen Lauf - sonst fällt ein unvollständiger Katalog niemandem auf.
Einen Record ändern
Nutze record.patch. Es ist ein JSON Merge Patch: Felder, die du weglässt, behalten ihren gespeicherten Wert, null entfernt ein Feld, und ein übersetzbares oder object-Feld, dem du ein Objekt gibst, wird Schlüssel für Schlüssel zusammengeführt. Zwei Skripte, die verschiedene Felder desselben Records ändern, überschreiben sich nicht gegenseitig.
mutation ($input: PatchModelRecordInput!) {
record {
patch(input: $input) { id data }
}
}{ "input": { "id": "<record id>", "data": { "price": 129.99, "color": "natural" } } }record.update nimmt dieselbe Eingabe, ersetzt aber das gesamte data-Objekt - jedes Feld, das du nicht mitschickst, ist weg. Nutze es nur, wenn das Skript alle Felder des Records verantwortet. record.delete(id) löscht einen Record.
Zurücklesen
record.list liefert bis zu 100 Records pro Aufruf (20, wenn limit fehlt); blättere mit offset, bis hasMore false ist.
query ($modelId: ID!, $offset: Int) {
record {
list(modelId: $modelId, limit: 100, offset: $offset, sort: "createdAt_asc") {
total
hasMore
items { id data }
}
}
}Limits und Wiederholungen
Ein Workspace akzeptiert von API-Tokens eine feste Zahl von Record-Schreibzugriffen pro Minute - den aktuellen Wert findest du unter Rate Limits. Ein Import kostet eins pro Zeile, jeder andere Schreibzugriff eins. Über dem Budget antwortet die API mit HTTP 429 und einem Retry-After-Header: Warte so viele Sekunden und schicke denselben Request erneut. Ein einzelner Aufruf, der größer ist als das ganze Budget, wird sofort abgelehnt - teile ihn auf. Schreibzugriffe aus dem Admin werden nicht gezählt.
Ein vollständiges Skript
Node 22 oder neuer, keine Abhängigkeiten. Setze CMSSY_TOKEN und CMSSY_WORKSPACE_ID, speichere das Skript als import-products.mjs und führe node import-products.mjs aus. Es legt das Modell und einen Record an, importiert drei Zeilen (die dritte wird als doppelte SKU abgelehnt), ändert den ersten Record und zählt, was vorhanden ist.
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);Erwartete Ausgabe:
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 3Ein zweiter Lauf bricht beim ersten Schritt ab, weil der Modell-Slug schon vergeben ist. Lösche das Modell im Admin oder ändere den Slug.
Ein vollständiger Sync, von Anfang bis Ende
examples/catalog-import synchronisiert einen öffentlichen Großhandelskatalog (Wide World Importers von Microsoft) nach cmssy: Lieferanten und Warengruppen als eigene Modelle, Produkte mit Relationen zu beiden, ein zweiter Lauf, der nur Änderungen schreibt, und ein Replay der Änderungshistorie des Katalogs als Patches. Forke es als Ausgangspunkt für eine echte Integration.
Was noch nicht geht
- Der Import fügt nur ein. Wer dieselbe Datei zweimal importiert, legt jede Zeile erneut an. Produktmodelle lehnen eine wiederholte SKU Zeile für Zeile ab; jedes andere Modell nimmt das Duplikat an. Solange der Import nicht per Schlüssel aktualisieren kann, lies zuerst den Bestand und schicke neue Zeilen an
import, geänderte anpatch. - Der Import liefert die angelegten IDs nicht zurück. Um deine Zeilen danach den Records zuzuordnen, liste das Modell und suche sie über deinen eigenen Schlüssel.
- Nur die SKU kann eindeutig sein. Kein anderes Feld lässt sich als eindeutig markieren, deine eigenen Referenznummern sind also nicht gegen Duplikate geschützt.
- Keine Queue, kein Puffer, keine Wiederholung auf unserer Seite. Die API schreibt, was sie bekommt, wenn sie es bekommt. Reihenfolge, Wiederholungen und Idempotenz liegen bei deiner Integration; ändern zwei Prozesse dasselbe Feld, gewinnt der letzte Schreibzugriff.