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

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

Wiersz, który nie przejdzie walidacji, nie zatrzymuje pozostałych. Wraca w errors ze swoją pozycją w partii, liczoną od 1:

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

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

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

  • Import tylko dodaje. Dwukrotne uruchomienie tego samego pliku tworzy każdy wiersz jeszcze raz. Modele produktowe odrzucają powtórzone SKU wiersz po wierszu; każdy inny model przyjmie duplikat. Dopóki import nie umie aktualizować po kluczu, najpierw odczytaj to, co już jest, a potem nowe wiersze wysyłaj do import, a zmienione do patch.
  • Import nie zwraca id utworzonych rekordów. Żeby potem dopasować swoje wiersze do rekordów, wylistuj model i wyszukaj je po własnym kluczu.
  • Unikalne może być tylko SKU. Żadnego innego pola nie da się oznaczyć jako unikalne, więc Twoje własne numery referencyjne nie są chronione przed duplikatami.
  • 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.