From ed58cbaf89b2eddc769dcb0294164ac1c5743ae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A5rd=20Farstad?= Date: Tue, 15 Sep 2026 14:35:50 +0200 Subject: [PATCH] fix(skills): stop make codeclean from corrupting the templating reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make codeclean` runs `oxfmt --write .`, and oxfmt formats fenced code blocks inside Markdown. It parses ```handlebars blocks as JavaScript and rewrites them — `{{ 0.firstName }}` becomes `{{[0].firstName}}`, which is a different Handlebars expression, not a reformatting of the same one. mass-operations/references/templating.md documents exactly which template syntax the runner accepts, so that rewrite turns correct documentation into incorrect documentation. Anyone running `make codeclean` today does this silently, and it is the reason the mass-operations skill has stayed unformatted since it was added. Adds a .prettierignore — oxfmt reads .gitignore and .prettierignore by default — excluding only that one file, with the reason recorded inline. With the hazard fenced off, the remaining unformatted skill files are brought up to date, so `make codeclean` is now a no-op across use-crystallize/skills/ instead of producing a diff on every run. Verified that every change here is formatting only. permissions/SKILL.md is whitespace alone; mass-operations SKILL.md, intents.md and limits.md differ only in table padding and italic markers (*x* to _x_); lifecycle.md additionally has 18 changes inside TypeScript blocks — single to double quotes, argument commas, and one arrow-function paren — all semantically identical JavaScript. Left alone: the MCP server sources and the two plugin.json files are also unformatted on main. Formatting the TypeScript deserves its own PR where the test suite can vouch for it. Co-Authored-By: Claude Opus 5 (1M context) --- .prettierignore | 9 + .../skills/mass-operations/SKILL.md | 68 ++-- .../mass-operations/references/intents.md | 328 +++++++++--------- .../mass-operations/references/lifecycle.md | 223 +++++++----- .../mass-operations/references/limits.md | 126 +++---- use-crystallize/skills/permissions/SKILL.md | 28 +- 6 files changed, 410 insertions(+), 372 deletions(-) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..bf4d391 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +# oxfmt (via `make codeclean`) reads this file. +# +# oxfmt formats fenced code blocks inside Markdown. It parses ```handlebars +# blocks as JavaScript and rewrites them, which changes documented template +# syntax rather than just its layout — `{{ 0.firstName }}` becomes +# `{{[0].firstName}}`, a different Handlebars expression. The mass-operations +# templating reference documents exactly what the runner accepts, so it must +# not be reformatted until oxfmt leaves handlebars blocks alone. +use-crystallize/skills/mass-operations/references/templating.md diff --git a/use-crystallize/skills/mass-operations/SKILL.md b/use-crystallize/skills/mass-operations/SKILL.md index 4f40f71..e4a0612 100644 --- a/use-crystallize/skills/mass-operations/SKILL.md +++ b/use-crystallize/skills/mass-operations/SKILL.md @@ -25,9 +25,9 @@ outside API rate limits, coordinated multi-step changes, and large-scale content ## Decide the execution path first -| Situation | Path | -| --- | --- | -| User has the Crystallize CLI, or is doing a production migration | **CLI** (default — recommended) | +| Situation | Path | +| ------------------------------------------------------------------ | ------------------------------------------- | +| User has the Crystallize CLI, or is doing a production migration | **CLI** (default — recommended) | | Browser/app context, CI without CLI, or building tooling around it | **Raw API** (see `references/lifecycle.md`) | ## Workflow @@ -46,22 +46,22 @@ Component IDs that don't exist on the target shape are the single most common ca ```json { - "version": "1.0.0", - "operations": [ - { - "intent": "piece/upsert", - "identifier": "rating-system", - "name": "Rating System", - "components": [ + "version": "1.0.0", + "operations": [ { - "id": "name", - "name": "Name", - "type": "singleLine", - "config": { "singleLine": { "required": true } } + "intent": "piece/upsert", + "identifier": "rating-system", + "name": "Rating System", + "components": [ + { + "id": "name", + "name": "Name", + "type": "singleLine", + "config": { "singleLine": { "required": true } } + } + ] } - ] - } - ] + ] } ``` @@ -114,18 +114,18 @@ dependency is positional — the referencing operation has to come later in the that creates its target. This is a universal rule: it applies to content exactly as it applies to the content model. There is no deferred resolution and no second pass. -| Reference | Must already exist | -| --- | --- | -| `shape/upsert` choice → piece | the `piece/upsert` | -| item's `shapeIdentifier` | the `shape/upsert` | -| `tree.parentId` | the parent `folder/create` (or `{{ defaults.rootItemId }}`) | -| `topicIds` on an item | the `topic/*` operations | -| `itemRelations` content | every item it points at | -| `item/updateComponent/*` | the item, and the component on its shape | -| `product/variant/*`, `…/price/modify`, `…/stock/modify` | the product, price variant, stock location | -| `item/flow/stage/addItems` | the `flow/*` defining that stage | -| `order`/`subscription-contract` customer link | the `customer/upsert` | -| any `{{ myRef.… }}` | the operation carrying that `_ref` | +| Reference | Must already exist | +| ------------------------------------------------------- | ----------------------------------------------------------- | +| `shape/upsert` choice → piece | the `piece/upsert` | +| item's `shapeIdentifier` | the `shape/upsert` | +| `tree.parentId` | the parent `folder/create` (or `{{ defaults.rootItemId }}`) | +| `topicIds` on an item | the `topic/*` operations | +| `itemRelations` content | every item it points at | +| `item/updateComponent/*` | the item, and the component on its shape | +| `product/variant/*`, `…/price/modify`, `…/stock/modify` | the product, price variant, stock location | +| `item/flow/stage/addItems` | the `flow/*` defining that stage | +| `order`/`subscription-contract` customer link | the `customer/upsert` | +| any `{{ myRef.… }}` | the operation carrying that `_ref` | Chain with `_ref` where there is no natural identifier, and with a shared `identifier` / `resourceIdentifier` where there is. @@ -174,7 +174,7 @@ logs, not to you. Validate locally to see them. ## Rules that prevent most failures - **An item `upsert` with neither `itemId` nor `resourceIdentifier` always CREATES** — on every run. - `externalReference` is *not* a lookup key. This is the number-one duplicate-data trap: give every + `externalReference` is _not_ a lookup key. This is the number-one duplicate-data trap: give every `product|document|folder /upsert` a `resourceIdentifier`. Identifier-keyed upserts (`piece`, `shape`, `customer`, `customer/group`, `pricelist`, `flow`, `product/variant`) are genuinely idempotent; `order/upsert` and `subscription-contract/upsert` are not unless you pass a real `id`. @@ -182,8 +182,8 @@ logs, not to you. Validate locally to see them. uploads fine, then fails the whole task at execution and abandons every remaining operation. It is the only one of the 59 intents in this position. Unpublish via the Core API `unpublishItem` mutation instead. -- **Prefer `upsert` for re-runnability — but upserts derive from the *create* input schema**, so they - need the *full* create payload, not a sparse patch. `product/upsert` requires `tree`, `vatTypeId`, and +- **Prefer `upsert` for re-runnability — but upserts derive from the _create_ input schema**, so they + need the _full_ create payload, not a sparse patch. `product/upsert` requires `tree`, `vatTypeId`, and `variants` exactly as `product/create` does. (`product/variant/update` is the exception — it's `.partial()`, so it genuinely accepts a sparse patch.) - **At `version: "1.0.0"` there is no upsert return-shape trap.** The v1.0.0 converters normalise every @@ -203,9 +203,9 @@ logs, not to you. Validate locally to see them. `{{ ... }}` expression, nothing else. This is why `{{ defaults.vatTypeIds.[0] }}` and `{{ defaults.rootItemId }}` work where a literal placeholder like `"TODO"` fails validation. - **A handlebars expression that fails to render is written through literally.** The renderer catches - the error, logs it, and returns the *raw template string* — so a bad reference silently stores + the error, logs it, and returns the _raw template string_ — so a bad reference silently stores `{{ myRef.id }}` as data rather than failing the operation. -- **`item/paths/set*` needs two operations to converge.** If anything must be removed, that run *only* +- **`item/paths/set*` needs two operations to converge.** If anything must be removed, that run _only_ removes; the add is expected as a separate operation. Not atomic, despite the name. - **Respect the domain limits — the schema does not.** A file that exceeds one validates, uploads, and fails at execution, or worse succeeds with altered data. **One invalid operation rejects the entire diff --git a/use-crystallize/skills/mass-operations/references/intents.md b/use-crystallize/skills/mass-operations/references/intents.md index 8ad4445..8c47bb1 100644 --- a/use-crystallize/skills/mass-operations/references/intents.md +++ b/use-crystallize/skills/mass-operations/references/intents.md @@ -36,57 +36,57 @@ Unpublish via the Core API `unpublishItem` mutation instead. ## Required fields per intent Derived by parsing empty payloads against the real schema. `_ref` is optional on every intent. -"+ id" means `checkResourceIdentifierOrId` applies at the *schema* level: supply `itemId`/`id`/`topicId` +"+ id" means `checkResourceIdentifierOrId` applies at the _schema_ level: supply `itemId`/`id`/`topicId` **or** `resourceIdentifier`. Whether the runner then honours `resourceIdentifier` is a separate question — see the next section. -| Intent | Required fields | -| --- | --- | -| `folder/create` | `name`, `shapeIdentifier`, `tree`, `language` | -| `folder/update` | as create, + id | -| `folder/upsert` | as create | -| `document/create` | `name`, `shapeIdentifier`, `tree`, `language` | -| `document/update` | as create, + id | -| `document/upsert` | as create | -| `product/create` | `name`, `shapeIdentifier`, `tree`, `vatTypeId`, `variants`, `language` | -| `product/update` | as create, + id | -| `product/upsert` | as create | -| `item/updateComponent/item` | `language`, `component`, + id — **runner requires `itemId` specifically** | -| `item/updateComponent/sku` | `language`, `component`, `sku` | -| `item/publish` | `language`, + id — **runner requires `itemId` specifically** | -| `item/unpublish` | schema-valid, **not implemented** — see above | -| `item/delete` | + id. Passing *both* `itemId` and `resourceIdentifier` throws | -| `item/flow/stage/addItems` | `items` (min 1), `stageIdentifier`. Optional `moveFromFlowIdentifier`, `actionConfig` | -| `shape/create` | `identifier`, `name` | -| `shape/update`, `shape/upsert` | `identifier`, `name` | -| `piece/create`, `piece/update`, `piece/upsert` | `identifier`, `name` | -| `product/variant/create` | `sku`, `name`, `language`, `productId` | -| `product/variant/update` | `language`, `sku` — **everything else optional** (`.partial()`) | -| `product/variant/upsert` | `sku`, `name`, `language`, `productId` | -| `product/variant/delete` | `sku` | -| `product/variant/stock/modify` | `sku`, `quantity`, `stockLocationIdentifier` | -| `product/variant/price/modify` | `sku`, `priceVariantIdentifier`, `price` | -| `customer/create`, `customer/upsert` | `identifier` | -| `customer/update` | per `UpdateCustomerInputSchema` | -| `customer/group/create`, `customer/group/update`, `customer/group/upsert` | per group input schema | -| `order/register` | `cart`, `customer` | -| `order/update` | `cart`, `customer`, + id — **runner requires `id` specifically** | -| `order/upsert` | `cart`, `customer` — **`pipelines` is omitted from this intent** | -| `subscription-contract/create` | `customerIdentifier`, `subscriptionPlan`, `status`, `item`, `recurring` | -| `subscription-contract/update` | as create, + id — **runner requires `id` specifically** | -| `subscription-contract/upsert` | as create | -| `pricelist/create`, `pricelist/upsert` | `identifier`, `name`, `priceVariants`, `selectedProductVariants`, `targetAudience` | -| `pricelist/update` | per `UpdatePriceListInputSchema` | -| `topic/create` | `name`, `language` | -| `topic/update`, `topic/upsert` | `name`, `language`, + id (`topicId` or `resourceIdentifier`) | -| `topic/delete` | + id | -| `flow/create`, `flow/upsert` | `name`, `stages`, `identifier` (`type` drives restriction mapping) | -| `flow/update` | `identifier` + per `UpdateFlowInputSchema` | -| `image/register` | `key` | -| `item/paths/addAliases`, `setAliases`, `removeAliases` | `language`, `paths`, + `itemId`/`resourceIdentifier` | -| `item/paths/addHistory`, `setHistory`, `removeHistory` | `language`, `paths`, + `itemId`/`resourceIdentifier` | -| `item/paths/addShortcuts`, `setShortcuts` | **`shortcuts`** (`[{parentId, position?}]`), + `itemId`/`resourceIdentifier` | -| `item/paths/removeShortcuts` | **`parentIds`**, + `itemId`/`resourceIdentifier` | +| Intent | Required fields | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| `folder/create` | `name`, `shapeIdentifier`, `tree`, `language` | +| `folder/update` | as create, + id | +| `folder/upsert` | as create | +| `document/create` | `name`, `shapeIdentifier`, `tree`, `language` | +| `document/update` | as create, + id | +| `document/upsert` | as create | +| `product/create` | `name`, `shapeIdentifier`, `tree`, `vatTypeId`, `variants`, `language` | +| `product/update` | as create, + id | +| `product/upsert` | as create | +| `item/updateComponent/item` | `language`, `component`, + id — **runner requires `itemId` specifically** | +| `item/updateComponent/sku` | `language`, `component`, `sku` | +| `item/publish` | `language`, + id — **runner requires `itemId` specifically** | +| `item/unpublish` | schema-valid, **not implemented** — see above | +| `item/delete` | + id. Passing _both_ `itemId` and `resourceIdentifier` throws | +| `item/flow/stage/addItems` | `items` (min 1), `stageIdentifier`. Optional `moveFromFlowIdentifier`, `actionConfig` | +| `shape/create` | `identifier`, `name` | +| `shape/update`, `shape/upsert` | `identifier`, `name` | +| `piece/create`, `piece/update`, `piece/upsert` | `identifier`, `name` | +| `product/variant/create` | `sku`, `name`, `language`, `productId` | +| `product/variant/update` | `language`, `sku` — **everything else optional** (`.partial()`) | +| `product/variant/upsert` | `sku`, `name`, `language`, `productId` | +| `product/variant/delete` | `sku` | +| `product/variant/stock/modify` | `sku`, `quantity`, `stockLocationIdentifier` | +| `product/variant/price/modify` | `sku`, `priceVariantIdentifier`, `price` | +| `customer/create`, `customer/upsert` | `identifier` | +| `customer/update` | per `UpdateCustomerInputSchema` | +| `customer/group/create`, `customer/group/update`, `customer/group/upsert` | per group input schema | +| `order/register` | `cart`, `customer` | +| `order/update` | `cart`, `customer`, + id — **runner requires `id` specifically** | +| `order/upsert` | `cart`, `customer` — **`pipelines` is omitted from this intent** | +| `subscription-contract/create` | `customerIdentifier`, `subscriptionPlan`, `status`, `item`, `recurring` | +| `subscription-contract/update` | as create, + id — **runner requires `id` specifically** | +| `subscription-contract/upsert` | as create | +| `pricelist/create`, `pricelist/upsert` | `identifier`, `name`, `priceVariants`, `selectedProductVariants`, `targetAudience` | +| `pricelist/update` | per `UpdatePriceListInputSchema` | +| `topic/create` | `name`, `language` | +| `topic/update`, `topic/upsert` | `name`, `language`, + id (`topicId` or `resourceIdentifier`) | +| `topic/delete` | + id | +| `flow/create`, `flow/upsert` | `name`, `stages`, `identifier` (`type` drives restriction mapping) | +| `flow/update` | `identifier` + per `UpdateFlowInputSchema` | +| `image/register` | `key` | +| `item/paths/addAliases`, `setAliases`, `removeAliases` | `language`, `paths`, + `itemId`/`resourceIdentifier` | +| `item/paths/addHistory`, `setHistory`, `removeHistory` | `language`, `paths`, + `itemId`/`resourceIdentifier` | +| `item/paths/addShortcuts`, `setShortcuts` | **`shortcuts`** (`[{parentId, position?}]`), + `itemId`/`resourceIdentifier` | +| `item/paths/removeShortcuts` | **`parentIds`**, + `itemId`/`resourceIdentifier` | The three shortcut intents take `shortcuts`/`parentIds`, **not `paths`**, and they ignore any `language` you pass — the converter hardcodes the tenant default language for shortcut operations. @@ -96,23 +96,23 @@ you pass — the converter hardcodes the tenant default language for shortcut op The schema accepts `resourceIdentifier` far more widely than the runner implements it. Several converters carry a literal `ResourceIdentifier is not implemented yet` error. -| Intent | Behaviour when you supply only `resourceIdentifier` | -| --- | --- | -| `product\|document\|folder /update` | ✅ resolved via `loadByResourceIdentifier` (scoped by `language`) | -| `product\|document\|folder /upsert` | ✅ resolved; used to decide create-vs-update | -| `product\|document\|folder /create` | ✅ stored on the new item, so later ops can find it | -| `item/delete` | ✅ resolved. Supplying both it and `itemId` **throws** | -| `topic/update`, `topic/upsert` | ✅ resolved; mismatch against a supplied `topicId` throws | -| `topic/delete` | ✅ resolved (default language) | -| `topic/create` | ✅ stored on the new topic | -| `image/register` | ✅ used as a dedupe key — if already registered, the operation is skipped | -| `item/paths/*` | ✅ resolved; throws `Item with resourceIdentifier … not found` if missing | -| `item/updateComponent/item` | ❌ **throws** `itemId is required … ResourceIdentifier is not implemented yet` | -| `item/publish` | ❌ **throws** `Operation is missing itemId. ResourceIdentifier is not implemented yet` | -| `order/update` | ❌ **throws** `Operation is missing id. ResourceIdentifier is not implemented yet` | -| `order/upsert` | ❌ **silently registers a brand-new order** | -| `subscription-contract/update` | ❌ **throws** the same "not implemented" error | -| `subscription-contract/upsert` | ❌ **silently creates a new contract** | +| Intent | Behaviour when you supply only `resourceIdentifier` | +| ----------------------------------- | -------------------------------------------------------------------------------------- | +| `product\|document\|folder /update` | ✅ resolved via `loadByResourceIdentifier` (scoped by `language`) | +| `product\|document\|folder /upsert` | ✅ resolved; used to decide create-vs-update | +| `product\|document\|folder /create` | ✅ stored on the new item, so later ops can find it | +| `item/delete` | ✅ resolved. Supplying both it and `itemId` **throws** | +| `topic/update`, `topic/upsert` | ✅ resolved; mismatch against a supplied `topicId` throws | +| `topic/delete` | ✅ resolved (default language) | +| `topic/create` | ✅ stored on the new topic | +| `image/register` | ✅ used as a dedupe key — if already registered, the operation is skipped | +| `item/paths/*` | ✅ resolved; throws `Item with resourceIdentifier … not found` if missing | +| `item/updateComponent/item` | ❌ **throws** `itemId is required … ResourceIdentifier is not implemented yet` | +| `item/publish` | ❌ **throws** `Operation is missing itemId. ResourceIdentifier is not implemented yet` | +| `order/update` | ❌ **throws** `Operation is missing id. ResourceIdentifier is not implemented yet` | +| `order/upsert` | ❌ **silently registers a brand-new order** | +| `subscription-contract/update` | ❌ **throws** the same "not implemented" error | +| `subscription-contract/upsert` | ❌ **silently creates a new contract** | For the ❌ rows, resolve the ID yourself first — either with a `_ref` to the operation that created the entity, or with a `fetch*` helper (`references/templating.md`). @@ -121,22 +121,22 @@ entity, or with a `fetch*` helper (`references/templating.md`). An `upsert` is only idempotent if the converter has something to look the entity up by. -| Intent | Lookup key | Re-run safe? | -| --- | --- | --- | -| `piece/upsert` | `identifier` | ✅ | -| `shape/upsert` | `identifier` | ✅ | -| `customer/upsert` | `identifier` | ✅ | -| `customer/group/upsert` | `identifier` | ✅ | -| `pricelist/upsert` | `identifier` | ✅ | -| `flow/upsert` | `identifier` | ✅ | -| `product/variant/upsert` | `productId` + `sku` | ✅ | -| `topic/upsert` | `topicId` or `resourceIdentifier` (one is required) | ✅ | -| `product\|document\|folder /upsert` | `itemId` or `resourceIdentifier` — **only if you supply one** | ⚠️ | -| `order/upsert` | `id` only | ⚠️ | -| `subscription-contract/upsert` | `id` only | ⚠️ | +| Intent | Lookup key | Re-run safe? | +| ----------------------------------- | ------------------------------------------------------------- | ------------ | +| `piece/upsert` | `identifier` | ✅ | +| `shape/upsert` | `identifier` | ✅ | +| `customer/upsert` | `identifier` | ✅ | +| `customer/group/upsert` | `identifier` | ✅ | +| `pricelist/upsert` | `identifier` | ✅ | +| `flow/upsert` | `identifier` | ✅ | +| `product/variant/upsert` | `productId` + `sku` | ✅ | +| `topic/upsert` | `topicId` or `resourceIdentifier` (one is required) | ✅ | +| `product\|document\|folder /upsert` | `itemId` or `resourceIdentifier` — **only if you supply one** | ⚠️ | +| `order/upsert` | `id` only | ⚠️ | +| `subscription-contract/upsert` | `id` only | ⚠️ | The ⚠️ rows are the trap. With neither key present the runner short-circuits straight to a create, every run. -`externalReference` is *not* consulted. A `product/upsert` keyed only on `externalReference` creates a +`externalReference` is _not_ consulted. A `product/upsert` keyed only on `externalReference` creates a duplicate product on every run. The item lookup is also **language-scoped**, so upserting the same item under a second language without an `itemId` will create a second item. @@ -147,17 +147,17 @@ in `OperationLog.output`, which is the authoritative view. **Verified** (at `version: "1.0.0"`): -| Intent | `_ref` output | -| --- | --- | -| `product/upsert`, `folder/upsert`, `document/upsert` | `{ id: string }` — flat, in all branches | -| `shape/upsert` | `{ identifier: string }` | -| `piece/create`, `piece/upsert` | `{ identifier: string }` | -| `customer/create`, `customer/update`, `customer/upsert` | `{ identifier: string }` | -| `product/variant/create`, `/update`, `/upsert` | the **full variant DTO** minus `tenantId`/`language` (id, sku, name, isDefault, priceVariants, stockLocations, components, …); falls back to `{ sku }` if the variant can't be resolved | -| `product/variant/delete` | `{ sku: string }` | -| `item/publish` | `{ language, success: string[], failure: [{ itemId, error }] }` | -| `item/updateComponent/item`, `item/updateComponent/sku` | a **bare ID string**, not `{ id }` — reference it as `{{ myRef }}` | -| `product/variant/stock/modify` | a **bare ID string**, same as above | +| Intent | `_ref` output | +| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `product/upsert`, `folder/upsert`, `document/upsert` | `{ id: string }` — flat, in all branches | +| `shape/upsert` | `{ identifier: string }` | +| `piece/create`, `piece/upsert` | `{ identifier: string }` | +| `customer/create`, `customer/update`, `customer/upsert` | `{ identifier: string }` | +| `product/variant/create`, `/update`, `/upsert` | the **full variant DTO** minus `tenantId`/`language` (id, sku, name, isDefault, priceVariants, stockLocations, components, …); falls back to `{ sku }` if the variant can't be resolved | +| `product/variant/delete` | `{ sku: string }` | +| `item/publish` | `{ language, success: string[], failure: [{ itemId, error }] }` | +| `item/updateComponent/item`, `item/updateComponent/sku` | a **bare ID string**, not `{ id }` — reference it as `{{ myRef }}` | +| `product/variant/stock/modify` | a **bare ID string**, same as above | **Not verified.** Every other intent returns whatever its underlying operation produces, and the shape varies — several return nothing at all, which normalises to `null`. The public docs' table for these @@ -196,7 +196,7 @@ counting the whole subtree; **50 items** per `item/flow/stage/addItems`; **500 c ## Schema quirks worth knowing - **Upsert = create schema.** `UpsertProductOperationSchema` extends `CreateProductInputSchema`, not the - update one. So an upsert needs the full create payload — it is *not* a sparse patch. Same for + update one. So an upsert needs the full create payload — it is _not_ a sparse patch. Same for document, folder, piece, shape, customer, customer group, pricelist, topic, flow, variant, and subscription contract. - **`piece/update` also uses `CreatePieceInputSchema`**, so it too requires the full payload — and the @@ -213,7 +213,7 @@ counting the whole subtree; **50 items** per `item/flow/stage/addItems`; **500 c `product/create|update|upsert` and `product/variant/create|update|upsert`. So tiered pricing works even though the fields don't appear in the schema. Matching is by `identifier`, falling back to array position only when no raw entry has one. -- **`variant.topicIds` is silently dropped.** Also stripped by the schema, and *not* re-attached — +- **`variant.topicIds` is silently dropped.** Also stripped by the schema, and _not_ re-attached — there is an explicit `blocked on @crystallize/schema bump` comment in both variant converters. Assign variant topics another way. - **`item/updateComponent/item` runs with `disableContentValidation: true`.** Component content is @@ -226,44 +226,44 @@ counting the whole subtree; **50 items** per `item/flow/stage/addItems`; **500 c ```json { - "version": "1.0.0", - "operations": [ - { - "intent": "customer/upsert", - "identifier": "customer-for-order-123", - "firstName": "John", - "lastName": "Doe", - "type": "individual" - }, - { - "intent": "order/register", - "customer": { "identifier": "customer-for-order-123", "type": "individual" }, - "additionalInformation": "Please deliver between 9am-5pm", - "cart": [ + "version": "1.0.0", + "operations": [ { - "sku": "SP-RED-001", - "name": "Sample Product", - "productId": "67e5d2d12d31ee752710a74b", - "quantity": 2, - "price": { - "currency": "USD", - "gross": 1000, - "net": 800, - "tax": { "name": "VAT", "percent": 20 } - } + "intent": "customer/upsert", + "identifier": "customer-for-order-123", + "firstName": "John", + "lastName": "Doe", + "type": "individual" + }, + { + "intent": "order/register", + "customer": { "identifier": "customer-for-order-123", "type": "individual" }, + "additionalInformation": "Please deliver between 9am-5pm", + "cart": [ + { + "sku": "SP-RED-001", + "name": "Sample Product", + "productId": "67e5d2d12d31ee752710a74b", + "quantity": 2, + "price": { + "currency": "USD", + "gross": 1000, + "net": 800, + "tax": { "name": "VAT", "percent": 20 } + } + } + ] + }, + { + "intent": "item/updateComponent/item", + "itemId": "632958a35dfc2c90cbbad20d", + "language": "en", + "component": { + "componentId": "title", + "singleLine": { "text": "Mass operation updated title" } + } } - ] - }, - { - "intent": "item/updateComponent/item", - "itemId": "632958a35dfc2c90cbbad20d", - "language": "en", - "component": { - "componentId": "title", - "singleLine": { "text": "Mass operation updated title" } - } - } - ] + ] } ``` @@ -278,30 +278,30 @@ it duplicates the product on every run. Corrected: ```json { - "version": "1.0.0", - "operations": [ - { - "_ref": "rootProducts", - "intent": "product/upsert", - "resourceIdentifier": "product-sku-12345", - "externalReference": "SKU-12345", - "name": "My Product", - "language": "en", - "shapeIdentifier": "product", - "tree": { "parentId": "{{ defaults.rootItemId }}" }, - "vatTypeId": "{{ defaults.vatTypeIds.[0] }}", - "variants": [{ "sku": "SKU-12345", "name": "My Product", "isDefault": true }] - }, - { - "intent": "item/updateComponent/item", - "itemId": "{{ rootProducts.id }}", - "language": "en", - "component": { - "componentId": "description", - "richText": { "html": ["

Updated description

"] } - } - } - ] + "version": "1.0.0", + "operations": [ + { + "_ref": "rootProducts", + "intent": "product/upsert", + "resourceIdentifier": "product-sku-12345", + "externalReference": "SKU-12345", + "name": "My Product", + "language": "en", + "shapeIdentifier": "product", + "tree": { "parentId": "{{ defaults.rootItemId }}" }, + "vatTypeId": "{{ defaults.vatTypeIds.[0] }}", + "variants": [{ "sku": "SKU-12345", "name": "My Product", "isDefault": true }] + }, + { + "intent": "item/updateComponent/item", + "itemId": "{{ rootProducts.id }}", + "language": "en", + "component": { + "componentId": "description", + "richText": { "html": ["

Updated description

"] } + } + } + ] } ``` @@ -315,17 +315,15 @@ The published version omits `name` on the component. Every component definition ```json { - "version": "1.0.0", - "operations": [ - { - "intent": "piece/upsert", - "identifier": "rating-system", - "name": "Rating System", - "components": [ - { "id": "name", "name": "Name", "type": "singleLine", "singleLine": { "required": true } } - ] - } - ] + "version": "1.0.0", + "operations": [ + { + "intent": "piece/upsert", + "identifier": "rating-system", + "name": "Rating System", + "components": [{ "id": "name", "name": "Name", "type": "singleLine", "singleLine": { "required": true } }] + } + ] } ``` @@ -337,7 +335,7 @@ does. The skill follows the runner. ### Examples that fail schema validation 1. `piece/upsert` example omits `name` on the component definition. -2. `product/upsert` example omits `tree`, `vatTypeId` and `variants` (upserts extend the *create* +2. `product/upsert` example omits `tree`, `vatTypeId` and `variants` (upserts extend the _create_ schema). 3. `richText.html` is passed as a string; the schema requires an array of strings. @@ -355,7 +353,7 @@ does. The skill follows the runner. `subscription-contract/update|upsert`. 8. `item/unpublish` is documented as a usable intent; it has no converter and aborts the whole task. 9. `item/paths/*Shortcuts` are documented as taking `paths`; they take `shortcuts` / `parentIds`. -10. `item/paths/set*` is presented as a set operation; it removes *or* adds in a single run, never both. +10. `item/paths/set*` is presented as a set operation; it removes _or_ adds in a single run, never both. 11. `version` is presented as a version label. It selects the converter set, and three of the four formats the regex accepts crash the runner. 12. `externalReference` is used as the de-duplication key in the flagship `product/upsert` example, diff --git a/use-crystallize/skills/mass-operations/references/lifecycle.md b/use-crystallize/skills/mass-operations/references/lifecycle.md index 533330c..a0d88ee 100644 --- a/use-crystallize/skills/mass-operations/references/lifecycle.md +++ b/use-crystallize/skills/mass-operations/references/lifecycle.md @@ -20,18 +20,21 @@ that detail away (see Troubleshooting). ``` crystallize mass-operation dump-content-model ``` + Generates a starter file containing the tenant's current shapes and pieces. Use this to ground shape identifiers and component IDs instead of guessing them. ``` crystallize mass-operation run ``` + Validates → requests presigned upload → pushes the file → creates the bulk task with `autoStart` → waits for completion while tailing logs. ``` crystallize mass-operation execute-mutations [image-mapping-file] ``` + Runs client-side GraphQL mutations alongside mass operations, for steps the runner doesn't cover. All commands support `--no-interactive` for CI and reuse stored credentials. `--legacy-spec` converts @@ -44,9 +47,9 @@ verified against the runner source and may drift. `crystallize mass-operation -- ### Endpoints and auth -| Endpoint | URL | Auth | -| --- | --- | --- | -| Core API | `https://api.crystallize.com/@{tenant}` | Headers below | +| Endpoint | URL | Auth | +| ----------- | ------------------------------------------------ | -------------------------- | +| Core API | `https://api.crystallize.com/@{tenant}` | Headers below | | File upload | Returned in `generatePresignedUploadRequest.url` | Presigned — no auth needed | ``` @@ -59,19 +62,19 @@ X-Crystallize-Access-Token-Secret: ```graphql mutation GeneratePresignedUpload($filename: String!, $contentType: String!) { - generatePresignedUploadRequest( - input: { - type: MASS_OPERATIONS - filename: $filename - contentType: $contentType - } - ) { - ... on PresignedUploadRequest { - url - fields { name value } + generatePresignedUploadRequest(input: { type: MASS_OPERATIONS, filename: $filename, contentType: $contentType }) { + ... on PresignedUploadRequest { + url + fields { + name + value + } + } + ... on BasicError { + error + errorName + } } - ... on BasicError { error errorName } - } } ``` @@ -84,27 +87,27 @@ Multipart form POST. **Order matters: all presigned fields first, the file last. ```typescript async function uploadToPresignedUrl( - presignedUrl: string, - fields: Array<{ name: string; value: string }>, - fileContent: string + presignedUrl: string, + fields: Array<{ name: string; value: string }>, + fileContent: string, ): Promise { - const formData = new FormData(); + const formData = new FormData(); - // Add all presigned fields first (order matters!) - for (const field of fields) { - formData.append(field.name, field.value); - } + // Add all presigned fields first (order matters!) + for (const field of fields) { + formData.append(field.name, field.value); + } - // Add the file last - const blob = new Blob([fileContent], { type: 'application/json' }); - formData.append('file', blob); + // Add the file last + const blob = new Blob([fileContent], { type: "application/json" }); + formData.append("file", blob); - const response = await fetch(presignedUrl, { method: 'POST', body: formData }); - if (!response.ok) throw new Error(`Upload failed: ${response.status}`); + const response = await fetch(presignedUrl, { method: "POST", body: formData }); + if (!response.ok) throw new Error(`Upload failed: ${response.status}`); } // The storage key is inside `fields` — there is no top-level `key` -const storageKey = fields.find(f => f.name === 'key')?.value; +const storageKey = fields.find((f) => f.name === "key")?.value; ``` Equivalent with curl: @@ -126,10 +129,16 @@ curl -X POST "$PRESIGNED_URL" \ ```graphql mutation CreateMassOperationBulkTask($key: String!, $autoStart: Boolean) { - createMassOperationBulkTask(input: { key: $key, autoStart: $autoStart }) { - ... on BulkTaskMassOperation { id status } - ... on BasicError { error errorName } - } + createMassOperationBulkTask(input: { key: $key, autoStart: $autoStart }) { + ... on BulkTaskMassOperation { + id + status + } + ... on BasicError { + error + errorName + } + } } ``` @@ -139,10 +148,16 @@ mutation CreateMassOperationBulkTask($key: String!, $autoStart: Boolean) { ```graphql mutation StartMassOperationBulkTask($id: ID!) { - startMassOperationBulkTask(id: $id) { - ... on BulkTaskMassOperation { id status } - ... on BasicError { error errorName } - } + startMassOperationBulkTask(id: $id) { + ... on BulkTaskMassOperation { + id + status + } + ... on BasicError { + error + errorName + } + } } ``` @@ -174,14 +189,21 @@ Worth knowing when a task behaves oddly: ```graphql query GetBulkTaskStatus($id: ID!) { - bulkTask(id: $id) { - ... on BulkTaskMassOperation { - id - status - info { error errorName stack } + bulkTask(id: $id) { + ... on BulkTaskMassOperation { + id + status + info { + error + errorName + stack + } + } + ... on BasicError { + error + errorName + } } - ... on BasicError { error errorName } - } } ``` @@ -191,12 +213,12 @@ Lifecycle: **`pending → started → complete | error`**. The status enum is ex `{pending, started, complete, error}` — there is **no `running` state**, despite what the public docs say. -| Status | Meaning | -| --- | --- | -| `pending` | Created but not started. Only a `pending` task will be picked up | -| `started` | The operation loop is running | +| Status | Meaning | +| ---------- | ------------------------------------------------------------------ | +| `pending` | Created but not started. Only a `pending` task will be picked up | +| `started` | The operation loop is running | | `complete` | The loop finished. **Individual operations may still have failed** | -| `error` | The task aborted — check `info` | +| `error` | The task aborted — check `info` | `complete` means "the runner reached the end of the array", not "everything worked". @@ -204,12 +226,21 @@ say. ```graphql query OperationLogs($id: ID!, $first: Int) { - operationLogs(filter: { operationId: $id }, first: $first) { - edges { - node { status statusCode message input output } + operationLogs(filter: { operationId: $id }, first: $first) { + edges { + node { + status + statusCode + message + input + output + } + } + pageInfo { + hasNextPage + endCursor + } } - pageInfo { hasNextPage endCursor } - } } ``` @@ -221,11 +252,11 @@ will not return every log in one call. The filter also accepts two fields the do Each entry stores the original input payload, the command executed, the result, a `status` (`success` / `partial` / `failure`), and a `statusCode`. -| status | statusCode | When | -| --- | --- | --- | -| `success` | `200` | Command executed without throwing | -| `partial` | `206` | `item/publish` only — some items failed, or zero succeeded | -| `failure` | `500` | The command threw, or the converter failed to build it | +| status | statusCode | When | +| --------- | ---------- | ---------------------------------------------------------- | +| `success` | `200` | Command executed without throwing | +| `partial` | `206` | `item/publish` only — some items failed, or zero succeeded | +| `failure` | `500` | The command threw, or the converter failed to build it | Those are the only three the runner emits. The underlying DTO permits `201/400/401/403/404/502/503/504` too, but nothing writes them. @@ -244,15 +275,15 @@ operation whose `{{ upload ... }}` queued them. ```json { - "bulkTask": { - "id": "abc123", - "status": "error", - "info": { - "error": "Invalid Operation File", - "errorName": "Error", - "stack": "Error: Invalid Operation File\n at OperationsFetcherService.fetch..." + "bulkTask": { + "id": "abc123", + "status": "error", + "info": { + "error": "Invalid Operation File", + "errorName": "Error", + "stack": "Error: Invalid Operation File\n at OperationsFetcherService.fetch..." + } } - } } ``` @@ -261,27 +292,27 @@ runtime failure on a single operation, which is logged and stepped over. ## Troubleshooting -| Symptom | Cause | Fix | -| --- | --- | --- | -| `Invalid Operation File`, no detail | Server logs per-field zod issues to its own logger, not to `info` | Parse locally with `OperationsSchema`, or run via the CLI | -| Task `error`: `No converter found for intent item/unpublish` | `item/unpublish` is unimplemented in the runner | Remove it; use the Core API `unpublishItem` mutation | -| Task `error`: `TypeError: Invalid Version: 1` | `version` is `1`, `1.0` or `*` — valid per the regex, invalid per semver | Use `"1.0.0"` | -| Task `error`: `No operations retrieved from spec file` | Empty `operations` array, or an unreadable/empty upload | Check the file actually uploaded | -| Task stuck in `pending` | `autoStart: false` and never started | Call `startMassOperationBulkTask` | -| Re-run created duplicate products/folders | Item `upsert` without `itemId` or `resourceIdentifier` always creates | Add a `resourceIdentifier` to every item upsert | -| Re-run created duplicate orders/contracts | `order/upsert` and `subscription-contract/upsert` only dedupe on a real `id` | Track ids, or accept non-idempotence | -| `ResourceIdentifier is not implemented yet` | That intent doesn't support it | See `intents.md` § "Where `resourceIdentifier` actually works" | -| Operation missing entirely from `operationLogs` | Converter returned no command | See `intents.md` § "Silent skips" | -| A field literally contains `{{ myRef.id }}` | Template render threw; the renderer returns the raw string | Fix the reference; check the intent's real `_ref` output | -| Reference resolves to an object, not an ID | Running at `version: "0.0.1"` | Use `"1.0.0"`, where upserts are normalised flat | -| `fetch*` returned pre-update data | Fetch cache isn't invalidated by that intent | Use an uncached helper (`*ByResourceIdentifier`, `fetchProductVariantBySku`) | -| Variant `topicIds` didn't apply | Stripped by the schema, not re-attached by the runner | Assign variant topics via another API | -| Component content saved but wrong | `item/updateComponent/item` runs with content validation disabled | Verify component IDs against `dump-content-model` | -| `400 Bad Request` on mutations | Missing union type fragments | Add `... on BulkTaskMassOperation` and `... on BasicError` | -| `Failed to fetch` on upload | Browser CORS restriction | Upload server-side or proxy the S3 request | -| `key` is undefined | Looking for a top-level `key` | Read it from the fields array: `fields.find(f => f.name === 'key').value` | -| Task `error` with no message | Not querying `info` | Add `info { error errorName stack }` to the `bulkTask` query | -| `Invalid component ID …` | Component doesn't exist on that shape | Re-check against `dump-content-model` output | +| Symptom | Cause | Fix | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `Invalid Operation File`, no detail | Server logs per-field zod issues to its own logger, not to `info` | Parse locally with `OperationsSchema`, or run via the CLI | +| Task `error`: `No converter found for intent item/unpublish` | `item/unpublish` is unimplemented in the runner | Remove it; use the Core API `unpublishItem` mutation | +| Task `error`: `TypeError: Invalid Version: 1` | `version` is `1`, `1.0` or `*` — valid per the regex, invalid per semver | Use `"1.0.0"` | +| Task `error`: `No operations retrieved from spec file` | Empty `operations` array, or an unreadable/empty upload | Check the file actually uploaded | +| Task stuck in `pending` | `autoStart: false` and never started | Call `startMassOperationBulkTask` | +| Re-run created duplicate products/folders | Item `upsert` without `itemId` or `resourceIdentifier` always creates | Add a `resourceIdentifier` to every item upsert | +| Re-run created duplicate orders/contracts | `order/upsert` and `subscription-contract/upsert` only dedupe on a real `id` | Track ids, or accept non-idempotence | +| `ResourceIdentifier is not implemented yet` | That intent doesn't support it | See `intents.md` § "Where `resourceIdentifier` actually works" | +| Operation missing entirely from `operationLogs` | Converter returned no command | See `intents.md` § "Silent skips" | +| A field literally contains `{{ myRef.id }}` | Template render threw; the renderer returns the raw string | Fix the reference; check the intent's real `_ref` output | +| Reference resolves to an object, not an ID | Running at `version: "0.0.1"` | Use `"1.0.0"`, where upserts are normalised flat | +| `fetch*` returned pre-update data | Fetch cache isn't invalidated by that intent | Use an uncached helper (`*ByResourceIdentifier`, `fetchProductVariantBySku`) | +| Variant `topicIds` didn't apply | Stripped by the schema, not re-attached by the runner | Assign variant topics via another API | +| Component content saved but wrong | `item/updateComponent/item` runs with content validation disabled | Verify component IDs against `dump-content-model` | +| `400 Bad Request` on mutations | Missing union type fragments | Add `... on BulkTaskMassOperation` and `... on BasicError` | +| `Failed to fetch` on upload | Browser CORS restriction | Upload server-side or proxy the S3 request | +| `key` is undefined | Looking for a top-level `key` | Read it from the fields array: `fields.find(f => f.name === 'key').value` | +| Task `error` with no message | Not querying `info` | Add `info { error errorName stack }` to the `bulkTask` query | +| `Invalid component ID …` | Component doesn't exist on that shape | Re-check against `dump-content-model` output | ## Browser CORS @@ -293,14 +324,14 @@ The S3 bucket may block cross-origin requests from a browser. Options, in order ```javascript export default defineConfig({ - server: { - proxy: { - '/api/s3-upload': { - target: 'https://crystallize-mass-operations-production.s3.eu-central-1.amazonaws.com', - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api\/s3-upload/, ''), - }, + server: { + proxy: { + "/api/s3-upload": { + target: "https://crystallize-mass-operations-production.s3.eu-central-1.amazonaws.com", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/s3-upload/, ""), + }, + }, }, - }, }); ``` diff --git a/use-crystallize/skills/mass-operations/references/limits.md b/use-crystallize/skills/mass-operations/references/limits.md index 3b06de8..e9b3b6a 100644 --- a/use-crystallize/skills/mass-operations/references/limits.md +++ b/use-crystallize/skills/mass-operations/references/limits.md @@ -21,24 +21,24 @@ re-check anything load-bearing before depending on it. Three distinct modes. Knowing which one applies tells you whether to expect an error at all. -| Mode | What you see | Blast radius | -| --- | --- | --- | -| **schema-validation** | task `error`, `Invalid Operation File`, nothing runs | the whole file | -| **runtime-throw** | that operation logs `failure` / 500, run continues | one operation | -| **silent** | **nothing** — no error, no log, wrong data stored | however many rows carry it | +| Mode | What you see | Blast radius | +| --------------------- | ---------------------------------------------------- | -------------------------- | +| **schema-validation** | task `error`, `Invalid Operation File`, nothing runs | the whole file | +| **runtime-throw** | that operation logs `failure` / 500, run continues | one operation | +| **silent** | **nothing** — no error, no log, wrong data stored | however many rows carry it | The silent class is the dangerous one and it is large. Local validation cannot catch any of it, -because the value is legal — it is the *behaviour* that differs from what you wrote. +because the value is legal — it is the _behaviour_ that differs from what you wrote. ## File-level -| Limit | Value | Mode | -| --- | --- | --- | -| **One invalid operation rejects the entire file** | all-or-nothing `safeParse` | schema-validation | -| Spec file size (presigned POST content-length-range) | **50 MiB** max, 1 byte min (`UPLOAD_MAX_SIZE`) | upload rejected | -| Offload to standalone task | **1 MiB** (`MASS_OPERATIONS_STANDALONE_TASK_FILE_SIZE_THRESHOLD`) | none — runs elsewhere | -| Operations per file | **no cap** | — | -| `version` format | `/^(\d+\.)?(\d+\.)?(\*|\d+)$/` — but only `1.0.0` works, see `SKILL.md` | runtime-throw | +| Limit | Value | Mode | +| ---------------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------- | +| **One invalid operation rejects the entire file** | all-or-nothing `safeParse` | schema-validation | +| Spec file size (presigned POST content-length-range) | **50 MiB** max, 1 byte min (`UPLOAD_MAX_SIZE`) | upload rejected | +| Offload to standalone task | **1 MiB** (`MASS_OPERATIONS_STANDALONE_TASK_FILE_SIZE_THRESHOLD`) | none — runs elsewhere | +| Operations per file | **no cap** | — | +| `version` format | `/^(\d+\.)?(\d+\.)?(\* | \d+)$/`— but only`1.0.0`works, see`SKILL.md` | runtime-throw | **All-or-nothing validation is the one to internalise.** There is no partial acceptance: operation 500 of 500 being malformed means operations 1–499 never run. Chunk large files so one bad row costs @@ -46,15 +46,15 @@ you one chunk, and validate locally before every upload. ## Batch caps per operation -| Intent | Cap | Counting subtlety | -| --- | --- | --- | -| `topic/create`, `topic/upsert` | **30 topics** | counts the **whole subtree recursively** — 1 root + 29 descendants | -| `item/flow/stage/addItems` | **50 items** | raw array length; throws during conversion, so the op is skipped with a 500 | -| `order/*` | **500 cart items** | | -| `order/*` | **100 applied promotions**, **50 related orders** | | -| Any item or variant | **250 `topicIds`** | product variants share one **deduplicated union** across all variants | -| `flow/*` | **30 stages**, **8 actions per stage** | nested `onFailure` actions are **not** counted | -| `customer/*`, `customer/group/*` | **5 parents**, **20 addresses**, hierarchy depth **5** | | +| Intent | Cap | Counting subtlety | +| -------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------- | +| `topic/create`, `topic/upsert` | **30 topics** | counts the **whole subtree recursively** — 1 root + 29 descendants | +| `item/flow/stage/addItems` | **50 items** | raw array length; throws during conversion, so the op is skipped with a 500 | +| `order/*` | **500 cart items** | | +| `order/*` | **100 applied promotions**, **50 related orders** | | +| Any item or variant | **250 `topicIds`** | product variants share one **deduplicated union** across all variants | +| `flow/*` | **30 stages**, **8 actions per stage** | nested `onFailure` actions are **not** counted | +| `customer/*`, `customer/group/*` | **5 parents**, **20 addresses**, hierarchy depth **5** | | `item/publish` has a 50-id batch cap that you **cannot reach** — the converter always sends exactly one `itemId`. @@ -114,12 +114,12 @@ rather than an error. ### Strings, dates, identifiers -- **`ValidatedString` never trims.** Leading/trailing whitespace is stored *and counts toward every - length cap*. +- **`ValidatedString` never trims.** Leading/trailing whitespace is stored _and counts toward every + length cap_. - **`ValidatedString` coerces via `toString()`** — a number or boolean becomes its string form rather than being rejected. An explicit `null` raises a raw `TypeError`. - **Datetime is re-parsed by `new Date()` and re-emitted as UTC ISO.** Offsets are normalised away. -- **Topic `pathIdentifier` is sliced to 64 chars *before* slugification**, so a long topic name +- **Topic `pathIdentifier` is sliced to 64 chars _before_ slugification**, so a long topic name silently produces a different stored path. - **`KeyValuePair` coerces an empty-string value to `null`.** - **Tree-path collisions are silently rewritten** with a random suffix — one 5-char attempt, one @@ -150,53 +150,53 @@ Cases where two components behave oppositely from the same JSON. - **`item/updateComponent/item` skips ALL component content validation**: min/max counts, file size, MIME type and `required` are simply not executed. The identical payload via `item/updateComponent/sku`, `product/upsert`, `document/upsert` **is** validated and throws. Only reference existence and type are still checked. Enforcement depends on which intent you chose. -- **Order line items and payment objects are `.strict()`** — they reject *any* unknown key, while +- **Order line items and payment objects are `.strict()`** — they reject _any_ unknown key, while nearly every other schema silently strips them. The one place a typo errors instead of vanishing. ## Component capacity Ceilings on the `max` you may configure, and therefore on content. -| Component | Cap | -| --- | --- | -| `itemRelations` | **75** (quick-select folders: 100) | -| `images`, `files`, `videos`, `gridRelations` | **512** | -| `colors` | **100** | -| `numeric` `decimalPlaces` | 0–**64** | -| Configurable `min`/`max` bounds | max **1048576**, min **256** | -| Component nesting depth | **5**, following piece expansion | +| Component | Cap | +| -------------------------------------------- | ---------------------------------- | +| `itemRelations` | **75** (quick-select folders: 100) | +| `images`, `files`, `videos`, `gridRelations` | **512** | +| `colors` | **100** | +| `numeric` `decimalPlaces` | 0–**64** | +| Configurable `min`/`max` bounds | max **1048576**, min **256** | +| Component nesting depth | **5**, following piece expansion | ## String and number bounds -| Field | Bound | -| --- | --- | -| **Default for every unqualified string** | min 1, **max 256** | -| Shape / piece / flow identifier | **2–64**, charset `[A-Za-z0-9]` plus `; : + @ (` and space | -| `resourceIdentifier` | 1–**256**, charset `[A-Za-z0-9]` plus `. - _ / @` | -| `externalReference` | 1–256 | -| Item / catalogue item name | **512** | -| Product variant name | **1024** | -| SKU | **512** | -| Variant attribute key / value | **128** / **2048** | -| Component name / description | 256 / 1024 | -| `singleLine` text, meta value, tree path | **1048576** | -| Meta key | 256 | -| Image alt text / URL | 1024 / 10240 | -| Order `additionalInformation` | 10240 | -| Language code | 2–20 | -| Currency code | 10 | -| Topic display colour | 4–9, `/^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$/` | -| Uploaded filename (basename of the `{{upload}}` URL) | 3–512 | -| Every `id` field | exactly 24 lowercase hex, or `{{ ... }}` | -| Price | −1e9 … 1e9 | -| Percent (order tax & discount) | −1000 … 1000 | -| Order cart item quantity | 0 … 1e6 | -| Item tree position | 1 … 100000 | -| Latitude / longitude | ±90 / ±180 | -| Focal point | x, y each 0 … 1 | -| **`product/variant/price/modify` price** | **minimum 1** — you cannot set 0 this way | -| **`product/variant/stock/modify` quantity** | **positive integer** | -| Stock (elsewhere) | `ValidatedInteger`, **fractional input silently truncated** | +| Field | Bound | +| ---------------------------------------------------- | ----------------------------------------------------------- | +| **Default for every unqualified string** | min 1, **max 256** | +| Shape / piece / flow identifier | **2–64**, charset `[A-Za-z0-9]` plus `; : + @ (` and space | +| `resourceIdentifier` | 1–**256**, charset `[A-Za-z0-9]` plus `. - _ / @` | +| `externalReference` | 1–256 | +| Item / catalogue item name | **512** | +| Product variant name | **1024** | +| SKU | **512** | +| Variant attribute key / value | **128** / **2048** | +| Component name / description | 256 / 1024 | +| `singleLine` text, meta value, tree path | **1048576** | +| Meta key | 256 | +| Image alt text / URL | 1024 / 10240 | +| Order `additionalInformation` | 10240 | +| Language code | 2–20 | +| Currency code | 10 | +| Topic display colour | 4–9, `/^#([A-Fa-f0-9]{3} | [A-Fa-f0-9]{6})$/` | +| Uploaded filename (basename of the `{{upload}}` URL) | 3–512 | +| Every `id` field | exactly 24 lowercase hex, or `{{ ... }}` | +| Price | −1e9 … 1e9 | +| Percent (order tax & discount) | −1000 … 1000 | +| Order cart item quantity | 0 … 1e6 | +| Item tree position | 1 … 100000 | +| Latitude / longitude | ±90 / ±180 | +| Focal point | x, y each 0 … 1 | +| **`product/variant/price/modify` price** | **minimum 1** — you cannot set 0 this way | +| **`product/variant/stock/modify` quantity** | **positive integer** | +| Stock (elsewhere) | `ValidatedInteger`, **fractional input silently truncated** | ## Structural rules diff --git a/use-crystallize/skills/permissions/SKILL.md b/use-crystallize/skills/permissions/SKILL.md index 8d18de2..094932c 100644 --- a/use-crystallize/skills/permissions/SKILL.md +++ b/use-crystallize/skills/permissions/SKILL.md @@ -198,22 +198,22 @@ mutation CreateRole { ```graphql mutation UpdateRole($roleId: ID!) { - user { - role { - update( - id: $roleId - input: { - name: "Content Editor" - tenantPermissions: { - # Set CRUD permissions per resource - } + user { + role { + update( + id: $roleId + input: { + name: "Content Editor" + tenantPermissions: { + # Set CRUD permissions per resource + } + } + ) { + id + name + } } - ) { - id - name - } } - } } ```