Zapis danych

Twórz modele, twórz i importuj rekordy oraz zmieniaj pola ze skryptu przez administracyjne API GraphQL.

16 września 2026

Wszystko, co robisz z rekordami w adminie - definiujesz model, tworzysz rekord, importujesz arkusz, zmieniasz pole - zrobisz też ze skryptu. Ta strona opisuje drogę, którą idzie integracja: eksport z ERP, feed z PIM, jednorazowa migracja. Kończy się kompletnym skryptem, który uruchomisz bez zmian.

Dokąd idą zapisy

Zapisy trafiają do administracyjnego API GraphQL, a nie do ścieżki dostawczej, z której czyta Twój frontend:

https://api.cmssy.io/graphql

Ścieżka dostawcza (/public/{org}/{workspace}/graphql) serwuje opublikowaną treść i nie ma żadnych mutacji rekordów - wysłany tam zapis nie przejdzie walidacji. Każde zapytanie do API administracyjnego niesie dwa nagłówki:

  • Authorization: Bearer cs_... - token API. Token działa jako użytkownik, który go utworzył, więc to rola tego użytkownika w workspace decyduje, co skrypt może zapisać.
  • x-workspace-id - id workspace'u, do którego zapisujesz, z Settings → Workspace. Token ograniczony do jednego workspace'u odrzuca każdy inny.

Sprawdź oba, zanim cokolwiek zapiszesz:

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

Lista modeli (może być pusta) oznacza, że masz dostęp. Not authorized oznacza błędny token albo id workspace'u, albo token należący do innego workspace'u.

Zdefiniuj model

Rekordy należą do modelu. Utwórz go raz, w adminie albo ze skryptu. Włączenie product robi z modelu katalog produktów: skuField wskazuje pole, które musi być unikalne, a priceField cenę w jednostkach głównych (149.99, nie grosze).

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

Slug jest unikalny w obrębie workspace'u; drugie create z tym samym slugiem zostanie odrzucone. Pole relacji wskazuje inny model przez "type": "relation", "relationTo": "model:<slug>".

Pola unikalne. Żeby dwa rekordy nie mogły mieć tej samej wartości w innym polu - własnego numeru referencyjnego, e-maila - wpisz jego klucz do uniqueFields przy tworzeniu albo edycji modelu. Unikalne mogą być pola tekstowe, e-mail, url, telefon i liczba; pola tłumaczone oraz pola ceny i stanu magazynowego produktu - nie. Drugi rekord z tą samą wartością zostanie odrzucony z komunikatem wskazującym pole. E-maile są porównywane bez względu na wielkość liter, a spacje na początku i końcu są pomijane. Ustawienie uniqueFields w modelu, który już ma duplikaty, zostanie odrzucone - błąd wymieni kolidujące wartości, a nic się nie zmieni.

Utwórz jeden rekord

data to obiekt JSON z kluczami pól. Jest walidowany względem modelu: brak wymaganego pola albo zduplikowane SKU zostaną odrzucone z komunikatem wskazującym pole.

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

Import hurtowy

record.import przyjmuje do 1000 wierszy na wywołanie. Limit 5 MB, który możesz znać z importu CSV w adminie, dotyczy tylko przeglądarki. Większe pliki wysyłaj partiami po 1000.

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

Wiersz, który nie przejdzie walidacji, nie zatrzymuje pozostałych. Wraca w errors ze swoją pozycją w partii, liczoną od 1, a records podaje id każdego rekordu, który wywołanie zapisało:

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

Traktuj niepuste errors jako nieudany przebieg - inaczej niepełny katalog przejdzie niezauważony.

Ponowne uruchomienie tego samego pliku

Przekaż upsertKey, żeby import dało się powtarzać. Wskazuje on skuField produktu albo jedno z pól z uniqueFields modelu. Wiersz, którego wartość pasuje do istniejącego rekordu, aktualizuje ten rekord - zmieniają się tylko pola obecne w wierszu, reszta zachowuje zapisaną wartość - a każdy inny wiersz tworzy nowy rekord. Dwukrotne uruchomienie tego samego pliku nie zmienia liczby rekordów.

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

importedCount liczy utworzone rekordy, a updatedCount zmienione. Dopasowany wiersz, który niczego nie zmienia, pojawia się w records jako UNCHANGED. Wiersz bez klucza albo z kluczem użytym już przez wcześniejszy wiersz w partii wraca w errors. upsertKey nie jest przyjmowany w modelach z kontami członków.

Zmień rekord

Użyj record.patch. To JSON merge patch: pola, których nie wyślesz, zachowują zapisaną wartość, null usuwa pole, a pole tłumaczone albo pole typu object, któremu podasz obiekt, jest scalane klucz po kluczu. Dwa skrypty zmieniające różne pola tego samego rekordu nie nadpisują się nawzajem.

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

record.update przyjmuje to samo wejście, ale zastępuje cały obiekt data - każde pole, którego nie wyślesz, znika. Używaj go tylko wtedy, gdy skrypt jest właścicielem wszystkich pól rekordu. record.delete(id) usuwa rekord.

Odczyt

record.list zwraca do 100 rekordów na wywołanie (20, gdy pominiesz limit); stronicuj przez offset, aż hasMore będzie false.

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

Limity i ponowienia

Workspace przyjmuje od tokenów API określoną liczbę zapisów rekordów na minutę - aktualna wartość jest na stronie Limity. Import kosztuje jeden za każdy wiersz, każdy inny zapis kosztuje jeden. Po przekroczeniu budżetu API odpowiada HTTP 429 z nagłówkiem Retry-After: odczekaj tyle sekund i wyślij to samo zapytanie ponownie. Pojedyncze wywołanie większe niż cały budżet zostanie od razu odrzucone - podziel je. Zapisy zrobione w adminie nie są liczone.

Kompletny skrypt

Node 22 lub nowszy, bez zależności. Ustaw CMSSY_TOKEN i CMSSY_WORKSPACE_ID, zapisz skrypt jako import-products.mjs i uruchom node import-products.mjs. Skrypt tworzy model i jeden rekord, importuje trzy wiersze (trzeci zostaje odrzucony jako zduplikowane SKU), zmienia pierwszy rekord i liczy, co jest w modelu.

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

Oczekiwany wynik:

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

Drugie uruchomienie zatrzyma się na pierwszym kroku, bo slug modelu jest już zajęty. Usuń model w adminie albo zmień slug.

Pełna synchronizacja od początku do końca

examples/catalog-import synchronizuje do cmssy publiczny katalog hurtowni (Wide World Importers od Microsoftu): dostawcy i grupy towarowe jako osobne modele, produkty powiązane z jednymi i drugimi, drugi przebieg zapisujący tylko to, co się zmieniło, oraz odtworzenie historii zmian katalogu jako patchy. Zrób fork i zacznij od niego prawdziwą integrację.

Czego jeszcze nie robi

  • Brak kolejki, bufora i ponowień po naszej stronie. API zapisuje to, co dostaje, w chwili, gdy to dostaje. Za kolejność, ponowienia i idempotencję odpowiada Twoja integracja; gdy dwa procesy zmieniają to samo pole, wygrywa ostatni zapis.