From 65f20143c343789200f81b582ffcf76f13233b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 15:41:06 -0700 Subject: [PATCH 01/29] fix(skills): correct pricing examples, price list resolution and promotion mechanisms Cart, Discovery and mass operation examples used fields that do not exist, and the docs said the most specific price list wins when the lowest price does. Adds the cart price context, volume tiers and the promotion admin API. --- use-crystallize/skills/pricing/SKILL.md | 117 ++++++--- .../references/price-lists-and-markets.md | 120 +++++---- .../pricing/references/price-variants.md | 140 +++++++---- .../skills/pricing/references/promotions.md | 238 +++++++++++------- 4 files changed, 387 insertions(+), 228 deletions(-) diff --git a/use-crystallize/skills/pricing/SKILL.md b/use-crystallize/skills/pricing/SKILL.md index 927a084..c128d01 100644 --- a/use-crystallize/skills/pricing/SKILL.md +++ b/use-crystallize/skills/pricing/SKILL.md @@ -1,6 +1,6 @@ --- name: pricing -description: Design and implement pricing strategies in Crystallize using Price Variants, Price Lists, Promotions, and Markets. Use when recommending pricing structures, setting up multi-currency support, configuring B2B/B2C pricing, planning promotional campaigns, defining markets, or advising on pricing architecture for commerce projects. Also use when the user asks about currencies, multi-market setup, was/now pricing, strikethrough prices, wholesale pricing, coupon codes, discount campaigns, tiered pricing, cart discounts, tax-inclusive/exclusive pricing, or any question about how prices work in Crystallize — even if they don't explicitly mention "pricing". +description: Design and implement pricing strategies in Crystallize using Price Variants, Price Lists, Promotions, and Markets. Use when recommending pricing structures, setting up multi-currency support, configuring B2B/B2C pricing, planning promotional campaigns, defining markets, or advising on pricing architecture for commerce projects. Also use when the user asks about currencies, multi-market setup, was/now pricing, strikethrough prices, wholesale pricing, contract prices, coupon codes, discount campaigns, tiered or volume pricing, cart discounts, tax-inclusive/exclusive pricing, or any question about how prices work in Crystallize — even if they don't explicitly mention "pricing". metadata: author: Crystallize version: "2.0" @@ -33,7 +33,7 @@ Crystallize resolves prices through a layered hierarchy. Each layer can override **Resolution order:** When a customer checks out, the system evaluates: 1. Which price variant applies (based on context) -2. Whether a price list overrides that variant (based on market, customer group, time period) +2. Whether a price list overrides that variant (based on market, customer, customer group, time period). When several match, the lowest price wins 3. Whether any promotions apply (based on cart contents, coupon codes, triggers) ## Quick Start Decision Tree @@ -42,14 +42,15 @@ Crystallize resolves prices through a layered hierarchy. Each layer can override START: You need to set up pricing. Q1: Do you sell in a single currency with one price per product? - → Yes → Create 1 price variant (e.g. "default" in your currency) + → Yes → Use the built-in "default" price variant, in your currency → No → Continue Q2: Do you need different price *types* (retail vs wholesale, regular vs sale)? → Yes → Create a price variant per type (see Price Variant Patterns below) Q3: Do you sell in multiple currencies or regions? - → Yes → Create Markets + Price Lists (see Markets & Price Lists below) + → Multiple currencies → Create a price variant per currency: a price list cannot change the currency + → Regional adjustments → Create Markets + Price Lists (see Markets & Price Lists below) Q4: Do you need temporary discounts, coupon codes, or "buy X get Y" deals? → Yes → Set up Promotions (see Promotions below) @@ -78,18 +79,18 @@ Price variants are **global definitions** — they define the _types_ of prices ### The Default Price Variant -Every Crystallize tenant comes with a `default` price variant. It **must always exist** — it is the system's fallback and cannot be deleted. +Every Crystallize tenant comes with a `default` price variant. It **must always exist** — it cannot be deleted. -**Best practice: Rename it to match your primary use case.** +**Best practice: Rename it to match your primary use case.** Only the display **name** changes: the identifier stays `default` for good, because `updatePriceVariant` takes `name`, `currency` and `tierConfig` and no identifier. Set its `currency` too: it is created without one and answers the tenant's default currency. -| Business Type | Rename `default` to | Currency | Rationale | -| ----------------- | ------------------------------- | -------------------- | --------------------------------- | -| B2C single market | `retail` | Your main currency | The standard consumer price | -| B2C multi-market | `retail` (main market currency) | Main market currency | The primary market's retail price | -| B2B single market | `b2b` or `wholesale` | Your main currency | Your main B2B list price | -| B2B + B2C | `retail` (main market) | Main market currency | Retail is the most common base | +| Business Type | Name `default` | Currency | Rationale | +| ----------------- | ---------------------- | -------------------- | --------------------------------- | +| B2C single market | "Retail" | Your main currency | The standard consumer price | +| B2C multi-market | "Retail" (main market) | Main market currency | The primary market's retail price | +| B2B single market | "B2B" or "Wholesale" | Your main currency | Your main B2B list price | +| B2B + B2C | "Retail" (main market) | Main market currency | Retail is the most common base | -The default variant serves as the **fallback** everywhere — if no price list override or more specific variant applies, this is the price the system resolves to. So it should represent your most common, primary price type for your main market. +`default` is what a cart selects when `selectedVariantIdentifier` is not sent, and what the Catalogue API's `price` answers without an identifier. So it should represent your most common, primary price type for your main market. It is **not** a fallback for a missing price: see [Pricing a Cart](#pricing-a-cart). ### Naming Conventions @@ -104,6 +105,8 @@ Use clear, consistent names that encode **purpose** and **currency**: ### Recommended Patterns by Business Type +In the patterns below, `retail` stands for the primary price type. That is either the `default` variant named "Retail", in which case every API call still says `default`, or a `retail` variant you created alongside it. + #### Simple B2C (single market) ``` @@ -123,13 +126,13 @@ Price Variants: **When:** You show was/now or strikethrough pricing. **Retail** is the reference price — the "compare at" price displayed crossed out. **Sales** is the actual selling price. When no sale is active, the storefront falls back to the Retail price (Sales is left empty or equal to Retail). -**Key rule:** Retail is always set. Sales is only set when the product is on sale. The storefront checks: if Sales exists and is lower than Retail → show strikethrough. Otherwise → show Retail as the current price. +**Key rule:** Retail is always set. Sales is only set when the product is on sale. The storefront checks: if Sales exists and is lower than Retail → show strikethrough. Otherwise → show Retail as the current price. The cart needs the same rule spelled out in its context: see [Pricing a Cart](#pricing-a-cart). #### B2C Per-Market (the most typical B2C pattern) ``` Price Variants: - ├── retail (USD) — US retail (the default variant, renamed) + ├── retail (USD) — US retail (the default variant, named "Retail") ├── sales (USD) — US sales / marked-down price ├── retail-eur (EUR) — EU retail ├── sales-eur (EUR) — EU sales @@ -137,7 +140,7 @@ Price Variants: └── sales-gbp (GBP) — UK sales ``` -**When:** Multi-market B2C with was/now pricing per market. Each market gets a Retail + Sales pair. The default variant is renamed to `retail` for the primary market. Secondary markets use `retail-{currency}` / `sales-{currency}` suffixes. +**When:** Multi-market B2C with was/now pricing per market. Each market gets a Retail + Sales pair. The default variant, named "Retail", serves the primary market. Secondary markets use `retail-{currency}` / `sales-{currency}` suffixes. **Resolution per market:** @@ -173,7 +176,7 @@ Price Variants: ``` Price Variants: - ├── retail (USD) — US consumer price (default, renamed) + ├── retail (USD) — US consumer price (default, named "Retail") ├── sales (USD) — US consumer sale price ├── b2b (USD) — US wholesale price ├── retail-eur (EUR) — EU consumer price @@ -215,7 +218,7 @@ Price Variants: ### Common Mistakes 1. **Creating a variant per country** — Use Price Lists for regional adjustments instead -2. **Leaving "default" named as "default"** — Always rename it to reflect its purpose (e.g. `retail` for B2C, `b2b` for B2B). The name "default" tells nobody what the price represents. +2. **Leaving "default" named as "default"** — Always set its display name to reflect its purpose (e.g. "Retail" for B2C, "B2B" for B2B). The name "default" tells nobody what the price represents. 3. **Confusing retail and sales** — Retail is the reference/compare-at price (always set). Sales is the marked-down price (only set during a sale). The storefront falls back to Retail when Sales is empty. 4. **Using promotions when you need persistent sales pricing** — Promotions are for cart-level, time-limited campaigns. If you always show a was/now price on the product page, use a `sales` price variant instead. 5. **Inconsistent naming** — Pick a convention (`{purpose}-{currency}` or `{purpose}`) and stick with it across all variants @@ -277,7 +280,7 @@ Price Lists: ## Markets -Markets define selling contexts. They group together the rules for who gets which prices, promotions, and configurations. +Markets define selling contexts. They group together the rules for who gets which prices, promotions, and configurations. A market is only a name and an identifier: it holds no currency, tax or price of its own, and a price list cannot change the currency of the price variant it adjusts. ### What Markets Represent @@ -290,12 +293,9 @@ A market can be: ### Market Setup Recommendations -#### Simple: One Market +#### Simple: No Market -``` -Markets: - └── "default" — Your only selling context -``` +A tenant starts with no markets, and a single selling context needs none: a cart's `markets` defaults to `[]`, and a price list aimed at `EVERYONE` applies regardless. #### Multi-Region @@ -304,7 +304,7 @@ Markets: ├── "us" — United States ├── "eu" — European Union ├── "uk" — United Kingdom - └── "row" — Rest of World (fallback) + └── "row" — Rest of World (there is no automatic fallback: the storefront sends it) ``` #### B2B + B2C per Region @@ -325,25 +325,27 @@ Market ──→ Price Lists (which prices apply) ──→ Checkout context (resolved at purchase time) ``` -Markets are set in the **checkout context** — at cart/checkout time, the active market determines which price lists and promotions are evaluated. +Markets are set in the **checkout context** — at cart/checkout time, `context.price.markets` determines which price lists and promotions are evaluated. ## Promotions -Promotions apply at the **cart level** — they only become visible during checkout, not on product pages. This keeps your base pricing clean while enabling flexible campaigns. +Promotions apply at the **cart level** — they only become visible during checkout, not on product pages. This keeps your base pricing clean while enabling flexible campaigns. They are managed in the app under Special prices, or through the Shop API's `cart/admin` endpoint: see [references/promotions.md](references/promotions.md). ### Promotion Mechanisms -| Mechanism | Description | Example | -| -------------- | ----------------- | ------------------------- | -| **Percentage** | % off the price | "20% off all shoes" | -| **Fixed** | Flat amount off | "$10 off orders over $50" | -| **X for Y** | Buy X, pay for Y | "Buy 3, pay for 2" | -| **Cart** | Reduce cart total | "$20 off the entire cart" | +The app and the API use different names. "Fixed" means a different thing in each: + +| In the app | API `mechanism.type` | What `value` is | Example | +| -------------- | -------------------- | ----------------------------------------------- | --------------------------------------------- | +| **Percentage** | `Percentage` | % off the price | "20% off all shoes" | +| **Cart** | `Fixed` | Amount off, spread across the targeted lines | "$10 off orders over $50", "$20 off the cart" | +| **Fixed** | `DynamicFixed` | New unit price, applied when lower than current | "All T-shirts at $15" | +| **X for Y** | `XforY` | `0`: X and Y go in `triggers.xForY` | "Buy 3, pay for 2" | ### Promotion Components -1. **Mechanism** — How the discount is calculated (percentage, fixed, X-for-Y, cart) -2. **Period** (optional) — When the promotion is active (start/end dates, can recur) +1. **Mechanism** — How the discount is calculated (see above) +2. **Period** (optional) — When the promotion is active (one or more start/end windows) 3. **Trigger** (optional) — What activates it (e.g. "3 items of product X in cart") 4. **Target** (optional) — Which products are affected (if empty, applies to all) 5. **Limitations** — Cumulative rules, max usage, per-customer limits @@ -401,15 +403,48 @@ After discovery, structure your recommendation as: Always recommend starting with the minimum viable setup and adding layers as the business requires them. +## Pricing a Cart + +The Shop API `hydrate` mutation prices a cart from `input.context.price`. The storefront sends it on **every** hydrate: an omitted field goes back to its default, it does not keep the previous value. + +| `context.price` | Default | Effect | +| -------------------------------------- | --------- | ------------------------------------------------------------------------------------------ | +| `selectedVariantIdentifier` | `default` | The price variant that sets the price | +| `fallbackVariantIdentifiers` | none | Tried in order when the selected variant has no price for the SKU | +| `compareAtVariantIdentifier` | none | The "was" price, read before price lists. A gap to the selected price lands in `discounts` | +| `markets` | `[]` | Which market-targeted price lists and promotions can match | +| `customerGroups` | `[]` | The same for groups. Not read from the customer record | +| `voucherCodes` | `[]` | Coupon codes. `voucherCode` is deprecated | +| `currency` | `eur` | A label, and what `triggers.currencies` tests. It selects no variant and converts nothing | +| `decimals` | `0` | Rounds every amount returned. **Left at 0, 39.99 comes back as 40** | +| `pricesHaveTaxesIncludedInCrystallize` | `false` | Whether the stored price is gross, see below | +| `taxRate` | none | 0–1, overrides the product's tax group | +| `discountOnNetPrices` | `false` | Promotions and `minValue` work on net instead of gross | + +A SKU with no value on the selected variant or on any fallback silently takes its **first** price variant, possibly in another currency. Was/now in a cart: + +```graphql +context: { + price: { + selectedVariantIdentifier: "sales" + fallbackVariantIdentifiers: ["default"] + compareAtVariantIdentifier: "default" + currency: "USD" + decimals: 2 + markets: ["us-retail"] + } +} +``` + ## Tax & Pricing Prices in Crystallize are stored as raw numbers — the system does not enforce whether they are tax-inclusive or tax-exclusive. This is a business decision: -- **B2C (most of Europe, Australia):** Prices typically include VAT. Store gross prices in your variants. -- **B2B / US B2C:** Prices are typically tax-exclusive. Store net prices and calculate tax at checkout. +- **B2C (most of Europe, Australia):** Prices typically include VAT. Store gross prices in your variants, and hydrate with `pricesHaveTaxesIncludedInCrystallize: true`. +- **B2B / US B2C:** Prices are typically tax-exclusive. Store net prices and leave it at `false`, so that gross = price × (1 + rate). - **Mixed:** Use price variants or price lists to maintain both. For example, EU retail variants hold gross prices while US retail variants hold net prices. -Tax calculation itself happens at the checkout/order layer — configure tax rates in your checkout flow or use an external tax service. The pricing hierarchy (variants → price lists → promotions) resolves the price; tax is applied on top of (or extracted from) that resolved price. +The Shop API calculates the tax. The rate is the first of: `taxRate` on the cart item, `context.price.taxRate` (both 0–1), the percentage of the product's tax group. To use an external tax service, pass its rate as `taxRate`. The pricing hierarchy (variants → price lists → promotions) resolves the price; tax is applied on top of (or extracted from) that resolved price. ## Migration Paths @@ -422,7 +457,7 @@ Before: default (USD) After: retail (USD) + sales (USD) ``` -Rename `default` to `retail`. Create a new `sales` variant in the same currency. Only set `sales` on products that are currently on sale. +Name the `default` variant "Retail" (its identifier stays `default`). Create a new `sales` variant in the same currency. Only set `sales` on products that are currently on sale. ### Single currency → Multi-currency @@ -453,6 +488,6 @@ Keep your variant structure unchanged. Add Promotions for time-limited, cart-lev ## Further Reading -- [Price Variants Reference](references/price-variants.md) — Detailed technical reference for variant setup +- [Price Variants Reference](references/price-variants.md) — Detailed technical reference for variant setup, reading prices, and volume tiers - [Price Lists & Markets Reference](references/price-lists-and-markets.md) — Price list configuration and market architecture -- [Promotions Reference](references/promotions.md) — Promotion types, mechanisms, and limitations +- [Promotions Reference](references/promotions.md) — Promotion types, mechanisms, limitations, and the admin API diff --git a/use-crystallize/skills/pricing/references/price-lists-and-markets.md b/use-crystallize/skills/pricing/references/price-lists-and-markets.md index 475b4ee..d030936 100644 --- a/use-crystallize/skills/pricing/references/price-lists-and-markets.md +++ b/use-crystallize/skills/pricing/references/price-lists-and-markets.md @@ -6,7 +6,7 @@ Price lists and markets work together to localize and personalize pricing. Marke ### What Markets Are -A market is a named selling context. It groups together the pricing, promotion, and configuration rules that apply when a customer checks out. +A market is a named selling context. It groups together the pricing, promotion, and configuration rules that apply when a customer checks out. It holds a name, an identifier and an optional list of customers: no currency, no type, no tax. Markets are set at **checkout time** via the cart context — they are not assigned to products directly. @@ -15,25 +15,21 @@ Markets are set at **checkout time** via the cart context — they are not assig **In the Admin UI:** 1. Go to **Settings → Markets** -2. Click **Add market +** +2. Click **Add market** 3. Enter a **name** (e.g. "Norway B2B") and **identifier** (e.g. `norway-b2b`) 4. Click **Create market** -The identifier is used in the API and checkout context. Choose it carefully — it should be lowercase, hyphenated, and descriptive. +The identifier is used in the API and checkout context. Choose it carefully — it should be lowercase, hyphenated, and descriptive. It cannot be changed afterwards. ### Via PIM API +Markets exist only on the legacy PIM API: the Core API has no market mutation. + ```graphql mutation CreateMarket { market { create( - input: { - tenantId: "your-tenant-id" - identifier: "eu-retail" - name: "EU Retail" - customerIdentifiers: [] - type: B2C - } + input: { tenantId: "your-tenant-id", identifier: "eu-retail", name: "EU Retail", customerIdentifiers: [] } ) { identifier name @@ -42,6 +38,8 @@ mutation CreateMarket { } ``` +Listing customers in `customerIdentifiers` does not put their carts in that market. Price resolution reads the markets the caller passes, nothing else. + ### Market Architecture Patterns #### By Country @@ -89,27 +87,31 @@ At checkout time, the storefront sets the market in the cart context: ```graphql mutation HydrateCart { - cart { - hydrate(context: { markets: ["eu-retail"] }, input: { items: [{ sku: "TSHIRT-RED-L", quantity: 1 }] }) { - cart { - items { - variant { - sku - name - } - price { - gross - net - currency - } - } - total { - gross - net - currency - } + hydrate( + input: { + context: { + price: { markets: ["eu-retail"], selectedVariantIdentifier: "retail-eur", currency: "EUR", decimals: 2 } + } + items: [{ sku: "TSHIRT-RED-L", quantity: 1 }] + } + ) { + id + items { + variant { + sku + name + } + price { + gross + net + currency } } + total { + gross + net + currency + } } } ``` @@ -118,20 +120,21 @@ The market selection determines: 1. Which **price lists** are evaluated 2. Which **promotions** apply -3. Which **currency** is resolved + +It does not determine the currency. The storefront maps its market to a price variant and sends that in `selectedVariantIdentifier`, with the matching `currency`: see "Pricing a Cart" in the main pricing guide. ## Price Lists ### What Price Lists Do -Price lists override or adjust the base price (from price variants) for specific contexts. They answer: "Given this market / customer group / time period, what price should this customer see?" +Price lists override or adjust the base price (from price variants) for specific contexts. They answer: "Given this market / customer group / time period, what price should this customer see?" A modifier is a plain number applied to one price variant, so a list keeps that variant's currency. ### Creating Price Lists **In the Admin UI:** 1. Go to **Special Prices → Price Lists** -2. Click **Add new** +2. Click **Create price list +** 3. Configure: - **Name and identifier** — e.g. "EU Summer Sale", `eu-summer-sale` - **Products** — All products, or specific ones (drag-and-drop / bulk select) @@ -176,8 +179,8 @@ Every part of the input is an object, not a bare identifier: | `selectedProductVariants` | `{ type: ALL_SKUS \| SOME_SKUS, variants?: [{ sku, priceVariants: [{ identifier, modifier }] }] }` | | `targetAudience` | `{ type: EVERYONE \| SOME, marketIdentifiers?, customerGroupIdentifiers?, customerIdentifiers? }` | -`targetAudience.type` is required. With `SOME`, name the audience in one or more of the identifier lists. -There is no `ALL` selection type — it is `ALL_SKUS` or `SOME_SKUS`. A list for specific SKUs: +`targetAudience.type` is required. With `SOME`, name the audience in one or more of the identifier lists; with `EVERYONE`, name nobody or the call fails. +There is no `ALL` selection type — it is `ALL_SKUS` or `SOME_SKUS`. `ALL_SKUS` refuses `variants`. A list for specific SKUs: ```graphql selectedProductVariants: { @@ -186,17 +189,23 @@ selectedProductVariants: { } ``` +A SKU's `priceVariants` overrides the list's modifier for that SKU, and is optional: a SKU without it takes the list's. An override only works for a price variant that is also in the list's own `priceVariants`, or it is ignored. + +`createPriceList` checks that customers, customer groups, SKUs and price variants exist, but **not markets**: a misspelled market identifier is accepted and the list never matches. + The legacy PIM API (`priceList { create(input: { tenantId, ... }) }`) takes the same input shape plus `tenantId`. In a mass operation the intent is `pricelist/create` or `pricelist/upsert`, with the same fields at the top level of the operation. ### Adjustment Types -| Type | Description | Example | -| -------------- | ------------------------------ | --------------------- | -| **Percentage** | Adjust up or down by % | `-10%` = 10% discount | -| **Relative** | Add or subtract a fixed amount | `-5` = $5 off | -| **Absolute** | Set a specific price | `25.00` = exactly $25 | +| Type | `modifierType` | Description | Example | +| -------------- | -------------- | ------------------------------ | --------------------- | +| **Percentage** | `PERCENTAGE` | Adjust up or down by % | `-10%` = 10% discount | +| **Relative** | `RELATIVE` | Add or subtract a fixed amount | `-5` = $5 off | +| **Absolute** | `ABSOLUTE` | Set a specific price | `25.00` = exactly $25 | + +The app labels `ABSOLUTE` "Fixed". A result below 0 is clamped to 0. ### Price List Patterns @@ -210,9 +219,11 @@ Price Variant: "retail" (EUR) — Base price: €100 Price Lists: ├── "Nordic Retail" → Market: nordics → Adjust: -5% → Final: €95 ├── "Southern EU Retail" → Market: southern-eu → Adjust: +10% → Final: €110 - └── "UK Retail" → Market: uk → Adjust: absolute £89 → Final: £89 + └── "Baltics Retail" → Market: baltics → Adjust: absolute €89 → Final: €89 ``` +A market selling in pounds needs its own GBP price variant: `absolute 89` on this variant is €89. + #### Customer Group Tiering ``` @@ -283,7 +294,7 @@ So keep the tiers on the variant and make the contract a **`PERCENTAGE`** list. flat price and the tiers are gone — that is the only case where "price lists have no tiers" holds. **A list aimed at a customer reaches that customer's children.** With -`targetAudience: { type: SOME, customerIdentifiers: ["nordvik-uh"] }`, the list resolved for a person whose +`targetAudience: { type: SOME, customerIdentifiers: ["acme-university"] }`, the list resolved for a person whose `parents` include the institution (verified: a person identifier answered the institution's contract, with the list's identifier in `priceFor`), and for a department in between. One list per organisation is enough; you do not need customer groups, and you do not need the customer's own price variant. @@ -321,23 +332,30 @@ read them from the Catalogue API server-side. - **One `createPriceList` call carries a whole catalogue.** A list with 2,211 SKUs went in as a single `SOME_SKUS` call with a modifier per SKU; no batching needed. `updatePriceList` replaces the SKU - selection whole, so send the complete set every time. + selection whole, so send the complete set every time. To change part of a list, use the legacy PIM API's + `priceList { upsertSelectedProductVariants(tenantId, identifier, variants) removeSelectedProductVariants(tenantId, identifier, variants: [sku]) }`, + or the mass operation intents `pricelist/selectedProductVariants/upsert` and `.../remove`. - **`productVariants(skus:)` on the Catalogue API takes at most 150 SKUs** (`TOO_MANY_SKUS_PROVIDED`), and `priceFor` is slow — about 2 s per 150 SKUs, and 8 s for 2,211 SKUs read five at a time. Reading the list itself is much faster (3.5 s for 2,211): `priceList(identifier) { productVariants(language, first) { edges -{ node { sku priceVariant(identifier) { priceList(identifier) { modifier modifierType } } } } } }`. Its + { node { sku priceVariant(identifier) { priceList(identifier) { modifier modifierType } } } } } }`. Its `pageInfo.hasNextPage` stays `true` past the last SKU, so stop on an empty page. -- **`decimalPlaces` on a modifier does not round the result.** `decimalPlaces: 0` with −18.05 % on 698 NOK - answered 572.01. Round for display in the storefront, and set the cart's `context.price.decimals` per - currency. +- **`decimalPlaces` rounds the result, to 2 by default, but `0` is lost on most writes.** `createPriceList`, + `updatePriceList` and mass operations drop a `0` and fall back to 2: `decimalPlaces: 0` with −18.05 % on 698 + NOK answered 572.01. `decimalPlaces: 1` is honoured, and the legacy PIM API keeps a `0`. Only the list-level + entry counts, not one on a SKU. Otherwise round for display in the storefront, and set the cart's + `context.price.decimals` per currency. ### Resolution Priority -When multiple price lists match a context, Crystallize evaluates them in this order: +There is no priority and no notion of the most specific list. Every list that matches competes, and **the lowest resulting price wins**. A list matches when all of these hold: + +1. **Active period** — Now is between its `startDate` and `endDate`, where set +2. **Product scope** — `ALL_SKUS`, or the SKU is among its `variants` +3. **Price variant** — It has a modifier for the price variant being read +4. **Audience** — `EVERYONE`, or at least one of its markets, customers or customer groups was passed. These are OR, not AND: a list aimed at a market and a customer group matches on either -1. **Most specific target wins** — Individual customer > customer group > market > global -2. **Active period** — Only lists within their defined period are evaluated -3. **Product scope** — Product-specific lists override "all products" lists +So an `EVERYONE` campaign at −30 % beats a customer's −20 % contract, and a +10 % list only applies if no other matching list is lower. The base price itself does not compete: a single matching +10 % list does raise the price. ### Best Practices @@ -345,7 +363,7 @@ When multiple price lists match a context, Crystallize evaluates them in this or 2. **Limit overlap** — Avoid having many price lists targeting the same market+variant combination 3. **Use periods** — Even for "permanent" adjustments, set a far-future end date so they can be reviewed 4. **Bulk select products** — Use the Nerdy view on a folder to quickly add many variants to a list -5. **Test the checkout** — Always verify the resolved price in the cart to ensure list priority is correct +5. **Test the checkout** — Always verify the resolved price in the cart to ensure the expected list wins ### Anti-Patterns diff --git a/use-crystallize/skills/pricing/references/price-variants.md b/use-crystallize/skills/pricing/references/price-variants.md index f010fb8..b938e87 100644 --- a/use-crystallize/skills/pricing/references/price-variants.md +++ b/use-crystallize/skills/pricing/references/price-variants.md @@ -31,48 +31,34 @@ Product: "Classic T-Shirt" ### In the Admin UI 1. Go to **Settings → Price Variants** -2. Click **Add new variant** +2. Click **Add price variant +** 3. Set: - **Name** — Human-readable (e.g. "US Dollar Retail") - **Identifier** — Lowercase, hyphenated (e.g. `usd-retail`). Used in the API. Cannot be changed after creation. - **Currency** — ISO 4217 code (e.g. `USD`, `EUR`, `NOK`, `GBP`, `SEK`, `DKK`, `JPY`, `CHF`) -4. Save +4. Click **Create variant** -### Via PIM API +### Via Core API ```graphql mutation { - priceVariant { - create(input: { tenantId: "your-tenant-id", identifier: "eur-retail", name: "EUR Retail", currency: "EUR" }) { + createPriceVariant(input: { identifier: "eur-retail", name: "EUR Retail", currency: "EUR" }) { + ... on PriceVariant { identifier name currency } + ... on BasicError { + errorName + message + } } } ``` -### Via Mass Operations +`updatePriceVariant(identifier:, input:)` changes `name`, `currency` and `tierConfig`, never the identifier. `deletePriceVariant(identifier:)` refuses `default`. The legacy PIM API equivalent is `priceVariant { create(input: { tenantId, ... }) }`. -```json -{ - "version": "1.0.0", - "operations": [ - { - "intent": "priceVariant/upsert", - "identifier": "usd-retail", - "name": "USD Retail", - "currency": "USD" - }, - { - "intent": "priceVariant/upsert", - "identifier": "eur-retail", - "name": "EUR Retail", - "currency": "EUR" - } - ] -} -``` +Mass operations cannot create a price variant: there is no such intent. Create them first, then reference them from `priceVariants` in a product or price list operation. ## Setting Prices on Products @@ -87,6 +73,8 @@ Prices are set inside the `variants` array of a product upsert operation: "name": "Classic T-Shirt", "shapeIdentifier": "product", "resourceIdentifier": "TSHIRT-001", + "vatTypeId": "", + "tree": { "parentId": "" }, "variants": [ { "name": "Classic T-Shirt — Red L", @@ -105,26 +93,28 @@ Prices are set inside the `variants` array of a product upsert operation: ### Via Core API Mutation ```graphql -mutation UpdateVariantPrice { - product { - updateVariant( - productId: "product-id" - language: "en" - sku: "TSHIRT-RED-L" - input: { priceVariants: [{ identifier: "retail", price: 39.99 }, { identifier: "sales", price: 29.99 }] } - ) { - ... on Product { - id - } +mutation SetVariantPrice { + modifyProductVariantPrice(sku: "TSHIRT-RED-L", priceVariantIdentifier: "retail", price: 39.99) { + ... on ProductPriceVariant { + identifier + price + } + ... on BasicError { + errorName + message } } } ``` +It sets one price at a time and leaves the rest of the variant alone. `updateProductVariant` also takes `priceVariants`, but rewrites more of the variant: see [[mutation]] before using it for a price change. + ## Querying Prices ### Discovery API +`priceVariants` is a JSON hash keyed by identifier, not a list of objects: select it without sub-fields. Each price variant also generates its own field, `Price`, with a hyphenated identifier in camelCase (`usd-retail` gives `usdRetailPrice`, and `price_usdRetail` to filter and sort). + ```graphql query { browse { @@ -132,12 +122,9 @@ query { hits { name defaultVariant { - defaultPrice # The first/default price variant value - priceVariants { - identifier - price - currency - } + defaultPrice # the price variant whose identifier is `default` + salesPrice # null when `sales` has no value for this SKU + priceVariants # { "default": { "price": 39.99, "currency": "USD" }, ... } } } } @@ -154,11 +141,7 @@ query { hits { name defaultVariant { - priceVariants { - identifier - price - currency - } + retail: priceVariants(key: "default") # { "price": 39.99, "currency": "USD" } } } } @@ -166,6 +149,24 @@ query { } ``` +### Catalogue API + +Here `priceVariants` is a list, which is what the helpers below take. + +```graphql +query { + productVariants(skus: ["TSHIRT-RED-L"], language: "en") { + sku + priceVariants { + identifier + name + price + currency + } + } +} +``` + In your frontend, filter by identifier: ```typescript @@ -173,12 +174,53 @@ const retailPrice = variant.priceVariants.find((pv) => pv.identifier === "retail const salesPrice = variant.priceVariants.find((pv) => pv.identifier === "sales"); ``` +## Volume and Graduated Tiers + +A price on a product variant can carry a quantity ladder instead of a flat value. With tiers `1: 100` and `10: 90`: + +| `tierType` | 15 units | +| ----------- | ------------------------------------------------------------------------------ | +| `volume` | Every unit at the matched tier: 15 × 90 | +| `graduated` | Each unit at its own tier: 9 × 100 + 6 × 90, returned as an average unit price | + +```graphql +mutation SetTiers { + modifyProductVariantPrice( + sku: "TSHIRT-RED-L" + priceVariantIdentifier: "retail" + tierType: volume + tiers: [{ threshold: 1, price: 100 }, { threshold: 10, price: 90 }, { threshold: 50, price: 80 }] + ) { + ... on ProductPriceVariant { + identifier + tierType + } + ... on BasicError { + errorName + message + } + } +} +``` + +A ladder is refused unless the first `threshold` is 1, thresholds strictly increase, every price is above 0, and `tierType` is set. A `price` sent with it must equal the first tier's price. + +| Read from | How | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Catalogue | `priceVariant(identifier) { tierType tiers { threshold price } price(count: 10) priceFor(count: 10, ...) { price } }` | +| Discovery | `retailPrice(count: 10)`, `retailPriceTierType`, `retailPriceTiers { threshold price }`, `retailPriceFor(count: 10, marketIdentifiers: [...])` | +| Cart | Automatic from each line's quantity, per SKU. `price { tierType resolvedTier { threshold price } tiers { threshold price } }` on the line | + +`tierConfig` on the price variant (`policy: disabled | optional | required`, `allowFreeform`, `presets`) shapes the tier editor in the app. API writes to a product variant are not checked against it. + +In a mass operation, `tierType` and `tiers` go on `priceVariants[]` of the `product/*` and `product/variant/*` intents, with a few parsing caveats listed in [[mass-operations]]. `product/variant/price/modify` is flat only, with a minimum of 1. + ## Naming Convention Guide ### Rules 1. **Identifiers are permanent** — Choose carefully; they are used in API queries and cannot be renamed -2. **Use lowercase with hyphens** — `usd-retail`, not `USD_Retail` or `usdRetail` +2. **Use lowercase with hyphens** — `usd-retail`, not `USD_Retail` or `usdRetail`. The app only accepts lowercase letters, numbers, `-` and `_` 3. **Encode purpose and/or currency** — Make identifiers self-documenting 4. **Be consistent** — Pick one pattern and follow it across all variants @@ -207,7 +249,7 @@ Set up two variants following the convention from the main pricing guide: - `retail` — The reference price, always set. Displayed as the "was" / compare-at / strikethrough price when a sale is active. - `sales` — The marked-down selling price, only set when the product is on sale. This is the "now" price. -The storefront checks: if `sales` exists and is lower than `retail`, show strikethrough. Otherwise, show `retail` as the current price. +The storefront checks: if `sales` exists and is lower than `retail`, show strikethrough. Otherwise, show `retail` as the current price. In a cart, the same rule is `selectedVariantIdentifier`, `fallbackVariantIdentifiers` and `compareAtVariantIdentifier`: see "Pricing a Cart" in the main pricing guide. ```tsx function PriceDisplay({ priceVariants }) { diff --git a/use-crystallize/skills/pricing/references/promotions.md b/use-crystallize/skills/pricing/references/promotions.md index e421e3d..a24b72b 100644 --- a/use-crystallize/skills/pricing/references/promotions.md +++ b/use-crystallize/skills/pricing/references/promotions.md @@ -19,14 +19,16 @@ Every promotion is built from these parts: ### 1. Mechanism (required) -How the discount is calculated: +How the discount is calculated. The app and the API name these differently, and "Fixed" is a different mechanism in each: -| Mechanism | Description | Use Case | -| -------------- | ------------------------ | -------------------------------------------- | -| **Percentage** | % off the original price | "20% off all shoes" | -| **Fixed** | Flat amount off | "$10 off when you spend $50+" | -| **X for Y** | Buy X items, pay for Y | "Buy 3, pay for 2" (cheapest items are free) | -| **Cart** | Reduce the cart total | "$20 off the entire order" | +| In the app | API `mechanism.type` | `value` | Use Case | +| -------------- | -------------------- | ---------------------------------------- | --------------------------------------------------------- | +| **Percentage** | `Percentage` | % off the line price | "20% off all shoes" | +| **Cart** | `Fixed` | Amount off, spread across targeted lines | "$10 off when you spend $50+", "$20 off the entire order" | +| **Fixed** | `DynamicFixed` | New unit price, applied when lower | "All T-shirts at $15" | +| **X for Y** | `XforY` | `0`, with `triggers.xForY: [x, y]` | "Buy 3, pay for 2" (cheapest items are free) | + +`XforY` refuses `targets`: its `triggers` choose the items. A cart discount is simply `Fixed` with no `targets`. The amount of `Fixed` and `DynamicFixed` has no currency of its own, so on a multi-currency tenant restrict them with `triggers.currencies`, or 10 comes off in every currency. ### 2. Period (optional) @@ -34,24 +36,28 @@ When the promotion is active: - **Start date** — When the promotion begins - **End date** — When it expires -- **Multiple periods** — For recurring campaigns (e.g. every December) -- **No period** — Active immediately and indefinitely +- **Multiple periods** — For recurring campaigns (e.g. every December). There is no recurrence rule: list each window, each with both `start` and `end` +- **No period** — Active immediately and indefinitely. An empty `periods: []` is different: the promotion never runs ### 3. Trigger (optional) What conditions must be met for the promotion to activate: -- **Product-based** — "3 of product X in the cart" -- **Quantity-based** — "At least 5 items total" -- **Value-based** — "Cart total above $100" -- **Coupon code** — "Enter SUMMER20 at checkout" +- **Product-based** — "3 of product X in the cart" (`skus`, `productIds`, `paths`, `topics`) +- **Quantity-based** — "At least 5 items total" (`minQuantity`) +- **Value-based** — "Cart total above $100" (`minValue`, on gross unless `discountOnNetPrices`) +- **Coupon code** — "Enter SUMMER20 at checkout" (`codes`, exact match against `context.price.voucherCodes`) +- **Audience** — `markets`, `currencies`, `customers` (identifier or email), `customerGroups` (matched against `context.price.customerGroups`, not the customer record) +- **Exclusions** — `exclusions` takes the same lists, to carve out SKUs, markets, customers or groups - **No trigger** — Applies to every cart automatically +Different kinds of trigger combine with AND, and values inside one kind with OR. Inside the product lists it is OR too: `skus: ["A", "B"]` means A or B, and "A and B both in the cart" cannot be expressed. `minQuantity` and `minValue` count only the lines matching the product lists, or all lines when there are none. `paths` and `topics` match by prefix, so a folder path covers what is under it. + ### 4. Target (optional) Which product variants the promotion affects: -- **Specific products** — Only these SKUs are discounted +- **Specific products** — Only these are discounted: `skus`, `productIds`, `paths`, `topics`, with `exclusions` to carve some out - **All products** — Entire catalogue - **No target** — Same as all products @@ -61,14 +67,22 @@ Which product variants the promotion affects: Control how the promotion behaves: -| Limitation | Description | Example | -| ------------------------ | ------------------------------------------- | ---------------------------------------- | -| **Cumulative** | Can combine with other discounts? | Yes/No | -| **Combine with** | Specific promotions it can stack with | Only with "Free Shipping" | -| **Repeatable** | Applies per qualifying set or once per cart | "Buy 3 pay 2" repeats for every set of 3 | -| **Max usage** | Total times it can be applied to a cart | Max 1 application per cart | -| **Max per customer** | Times each customer can use it | 1 per customer lifetime | -| **Quantity per trigger** | Items discounted per trigger match | Discount 1 item per trigger | +| Limitation | API field | Description | Example | +| ------------------------ | --------------------- | ------------------------------------------- | ---------------------------------------- | +| **Cumulative** | `cumulativeDiscount` | Can combine with other discounts? | Yes/No | +| **Combine with** | `canBeCombinedWith` | Specific promotions it can stack with | Only with "Free Shipping" | +| **Repeatable** | `repeatable` | Applies per qualifying set or once per cart | "Buy 3 pay 2" repeats for every set of 3 | +| **Max usage** | `maxUsage` | Total uses across the whole tenant | The first 500 orders | +| **Max per customer** | `maxUsagePerCustomer` | Times each customer can use it | 1 per customer lifetime | +| **Quantity per trigger** | `quantityPerTrigger` | Items discounted per trigger match | Discount 1 item per trigger | + +What these do in a cart, beyond their names: + +- **Not cumulative** (the default): `Fixed`, `DynamicFixed` and `XforY` skip a line that already has a discount, and `Percentage` replaces that discount only if its own percentage is higher. A sale price counts: a line whose `compareAtVariantIdentifier` is above its selected price already carries a discount. +- **`canBeCombinedWith`** is read when the promotion is reached, in stored order: it is dropped unless every promotion accepted so far is in its list. It is one-way: a promotion reached later, with no list of its own, still joins. Omitted means no restriction. +- **`maxUsage` and `maxUsagePerCustomer`** count once per promotion when a cart is **placed**, not when it is paid, across all carts of the tenant. The app's "per cart" tooltip is wrong. A customer is the cart's `customer.identifier`, else its email. +- **`maxUsagePerCustomer` needs a customer**: an anonymous cart never gets that promotion. With both limits set, a customer still under their own limit keeps getting it after `maxUsage` is reached. +- **`quantityPerTrigger`** picks the cheapest lines first for `Percentage` and `DynamicFixed`, and the most expensive first for `Fixed`. ## Common Promotion Patterns @@ -95,20 +109,22 @@ Limitations: Max 1 per customer ### Buy X Get Y Free ``` -Mechanism: X for Y — Buy 3, pay for 2 +Mechanism: X for Y — Buy 3, pay for 2 (type XforY, value 0, triggers.xForY: [3, 2]) Period: None Trigger: 3 items of any product in cart -Target: Same as trigger (cheapest item is free) +Target: None, it cannot have one (cheapest item is free) Limitations: Repeatable (every 3 items), cumulative ``` ### Free Shipping Threshold +The cart has no shipping concept of its own. Shipping is an `externalItems` line you add with `type: shipping`, which a promotion can target by SKU: + ``` -Mechanism: Cart — Reduce shipping to $0 +Mechanism: Percentage — 100% off Period: None -Trigger: Cart total ≥ $75 -Target: Shipping line item +Trigger: Cart total ≥ $75, with the shipping SKU in exclusions.skus so it does not count +Target: The SKU of the shipping line Limitations: Cumulative (can combine with product discounts) ``` @@ -117,19 +133,21 @@ Limitations: Cumulative (can combine with product discounts) ``` Mechanism: Percentage — 10% off Period: None -Trigger: Customer is in "Members" group +Trigger: customerGroups: ["members"], which the storefront must send in context.price.customerGroups Target: All products Limitations: Cumulative, no max usage ``` ### Bundle Discount +There is no "A AND B" trigger. The closest is either product plus a minimum quantity, which two of A also satisfies: + ``` -Mechanism: Fixed — $15 off +Mechanism: Cart — $15 off (type Fixed, value 15) Period: Summer campaign (Jun 1 – Aug 31) -Trigger: Product A AND Product B in cart -Target: Cart total -Limitations: Max 1 per cart +Trigger: skus: [A, B] with minQuantity: 2 +Target: None, so the amount spreads over the whole cart +Limitations: None needed: a promotion applies once per cart unless repeatable ``` ## Promotions vs Price Lists vs Price Variants @@ -143,53 +161,94 @@ Limitations: Max 1 per cart | Coupon codes | ❌ | ❌ | ✅ | | Buy X Get Y | ❌ | ❌ | ✅ | | Per-customer limits | ❌ | ❌ | ✅ | -| Stacking rules | N/A | Priority-based | Cumulative / exclusive | +| Stacking rules | N/A | Lowest price wins | Cumulative / exclusive | + +## Managing Promotions Through the API + +Promotions live on the Shop API admin endpoint, `POST https://shop-api.crystallize.com/{tenant-identifier}/cart/admin`, with a token holding the `cart` and `cart:admin` scopes (see [[query]] for getting one). Mass operations cannot write them. + +```graphql +mutation SetPromotion { + setPromotion( + input: { + identifier: "welcome15" + name: "Welcome 15%" + mechanism: { type: Percentage, value: 15 } + periods: [{ start: "2026-06-01T00:00:00Z", end: "2026-08-31T23:59:59Z" }] + triggers: { codes: ["WELCOME15"], markets: ["eu-retail"] } + targets: { paths: ["/shop/shoes"] } + limitations: { maxUsagePerCustomer: 1 } + } + ) { + identifier + } +} +``` + +| Operation | Effect | +| ------------------------------- | ------------------------------------------------------------------------- | +| `setPromotion(input)` | Creates or replaces one promotion, by `identifier` | +| `setPromotions(input)` | Replaces the **whole** list: a promotion left out is deleted | +| `promotion(identifier)` | Reads one | +| `promotions` | Reads all | +| `promotionUsage` | `count` and `perCustomer`, both JSON hashes keyed by promotion identifier | +| `setPPI(url, refreshFrequency)` | Replaces the list from JSON at your URL, and again on each refresh | + +A tenant holds at most **100 promotions**, and each trigger or target list at most 5,000 entries. ## Applying Promotions in the Cart -Promotions are resolved during cart hydration in the Shop API. The cart context determines which market, customer, and coupon codes are active. +Promotions are resolved during cart hydration in the Shop API. The cart context determines which market, customer, and coupon codes are active. These run against `POST https://shop-api.crystallize.com/{tenant-identifier}/cart`, with a `cart` token. ### Cart Hydration with a Coupon Code ```graphql mutation HydrateCartWithCoupon { - cart { - hydrate( - input: { - context: { market: ["eu-retail"], voucherCode: "WELCOME15" } - items: [{ sku: "TSHIRT-RED-L", quantity: 2 }, { sku: "HOODIE-BLK-M", quantity: 1 }] + hydrate( + input: { + context: { price: { markets: ["eu-retail"], voucherCodes: ["WELCOME15"], currency: "EUR", decimals: 2 } } + items: [{ sku: "TSHIRT-RED-L", quantity: 2 }, { sku: "HOODIE-BLK-M", quantity: 1 }] + } + ) { + id + items { + variant { + sku + name } - ) { - cart { - items { - variant { - sku - name - } - quantity - price { - gross - net - currency - discount { - amount - percentage - } - } - } - total { - gross - net - currency - discount + quantity + price { + gross + net + currency + discounts { + amount + percent } } } + total { + gross + net + currency + discounts { + amount + percent + } + } + appliedPromotions { + identifier + name + mechanism { + type + value + } + } } } ``` -The response includes a `discount` field on each item and on the cart total when a promotion has been applied. If the coupon code is invalid or no promotion matches, the prices are returned without discounts. +The response includes a `discounts` list on each item and on the cart total when a discount has been applied, and `appliedPromotions` names what fired. If the coupon code is invalid or no promotion matches, the prices are returned without discounts and **without an error**. To tell the shopper, query `validateVoucher(voucher: "WELCOME15") { isValid promotions { identifier name } }` on the same endpoint. It only says that a promotion carries the code, not that this cart qualifies, so compare with `appliedPromotions` after hydrating. ### Cart Hydration with Automatic Promotions @@ -197,37 +256,42 @@ Promotions without triggers apply automatically — no coupon code needed: ```graphql mutation HydrateCart { - cart { - hydrate(input: { context: { markets: ["us-retail"] }, items: [{ sku: "SNEAKER-WHT-42", quantity: 3 }] }) { - cart { - items { - variant { - sku - } - quantity - price { - gross - net - currency - discount { - amount - percentage - } - } - } - total { - gross - net - currency - discount + hydrate( + input: { + context: { price: { markets: ["us-retail"], currency: "USD", decimals: 2 } } + items: [{ sku: "SNEAKER-WHT-42", quantity: 3 }] + } + ) { + id + items { + variant { + sku + } + quantity + price { + gross + net + currency + discounts { + amount + percent } } } + total { + gross + net + currency + discounts { + amount + percent + } + } } } ``` -If a "Buy 3, pay for 2" promotion targets sneakers and has no trigger restriction, the discount is applied automatically when the quantity condition is met. +If a "Buy 3, pay for 2" promotion is limited to sneakers through its trigger, the discount is applied automatically when the quantity condition is met. ## Best Practices From 19bb60dd98c94fc4c7be6c3959e6aa93013f8f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 15:43:22 -0700 Subject: [PATCH 02/29] fix(skills): make the query examples match the real schemas Most Core examples and several Shop and Discovery ones used fields, arguments and types that do not exist, so they failed validation. Also corrects Discovery auth, path wildcards, Shop order paging and the order sync. --- use-crystallize/skills/query/SKILL.md | 71 ++- .../skills/query/references/catalogue-api.md | 37 +- .../skills/query/references/core-api.md | 515 ++++++++++++------ .../skills/query/references/discovery-api.md | 126 ++++- .../references/shop-api-order-queries.md | 44 +- .../query/references/shop-api-queries.md | 120 +++- 6 files changed, 632 insertions(+), 281 deletions(-) diff --git a/use-crystallize/skills/query/SKILL.md b/use-crystallize/skills/query/SKILL.md index e07adf9..84d7279 100644 --- a/use-crystallize/skills/query/SKILL.md +++ b/use-crystallize/skills/query/SKILL.md @@ -21,7 +21,7 @@ Before writing queries, understand the context. Ask clarifying questions: A public Discovery endpoint serves **every price variant it exposes** to anyone who knows the field name (`nokPrice`, `wholesalePrice`, …), so confidential terms — negotiated B2B prices in particular — belong in a price list read from the Catalogue API server-side, not in a price variant. See [[pricing]]. -5. **What volume of results?** Pagination strategy matters — cursor-based is recommended for all APIs. +5. **What volume of results?** Pagination strategy matters — cursor-based is recommended wherever an API has it. Shop API lists use `limit`/`skip`. ## Choosing the Right API @@ -49,13 +49,15 @@ Crystallize provides four main APIs for querying data: ## API Endpoints -| API | Endpoint | Auth Required | Use For | -| ----------- | ------------------------------------------------- | ----------------------- | ------------------------------ | -| Core | `https://api.crystallize.com/@{tenant}` | Yes (access tokens) | Admin, customers, orders | -| Discovery | `https://api.crystallize.com/{tenant}/discovery` | Optional (configurable) | Storefront search/filter | -| Catalogue | `https://api.crystallize.com/{tenant}/catalogue` | Optional (configurable) | Storefront path-based reads | -| Shop /cart | `https://shop-api.crystallize.com/{tenant}/cart` | Yes (JWT) | Cart hydration, checkout flows | -| Shop /order | `https://shop-api.crystallize.com/{tenant}/order` | Yes (JWT) | Order queries by ID/customer | +| API | Endpoint | Auth Required | Use For | +| --------------------------- | ----------------------------------------------------------------- | ----------------------- | ------------------------------ | +| Core | `https://api.crystallize.com/@{tenant}` | Yes (access tokens) | Admin, customers, orders | +| Discovery | `https://api.crystallize.com/{tenant}/discovery` | Optional (configurable) | Storefront search/filter | +| Catalogue | `https://api.crystallize.com/{tenant}/catalogue` | Optional (configurable) | Storefront path-based reads | +| Shop /cart | `https://shop-api.crystallize.com/{tenant}/cart` | Yes (JWT) | Cart hydration, checkout flows | +| Shop /order | `https://shop-api.crystallize.com/{tenant}/order` | Yes (JWT) | Order queries by ID/customer | +| Shop /customer | `https://shop-api.crystallize.com/{tenant}/customer` | Yes (JWT) | Customer by identifier | +| Shop /subscription-contract | `https://shop-api.crystallize.com/{tenant}/subscription-contract` | Yes (JWT) | Contracts by ID/customer | ## Usage @@ -79,7 +81,11 @@ query GetItem { name components { componentId - content + content { + ... on RichTextComponentContent { + html + } + } } defaultVariant { sku @@ -87,34 +93,43 @@ query GetItem { stock } } + ... on BasicError { + errorName + message + } } } # Example: List orders filtered by customer query ListOrders { orders( - tenantId: "tenant-id" first: 20 - filter: { customerIdentifier: "customer@example.com" } + filter: { customer: { identifier: "customer@example.com" } } sort: { field: createdAt, direction: desc } ) { - edges { - node { - id - total { - gross - currency - } - customer { - identifier + ... on OrderConnection { + edges { + node { + id + total { + gross + currency + } + customer { + identifier + } } } } + ... on BasicError { + errorName + message + } } } ``` -See [Core API Queries Reference](references/core-api.md) for complete documentation. +The tenant comes from the URL, so no Core query takes a `tenantId`, and most root fields return a union. See [Core API Queries Reference](references/core-api.md) for complete documentation. ### Discovery API (Recommended for Storefronts) @@ -139,7 +154,7 @@ The Discovery API has three entry points: `search` for full-text queries across summary { totalHits hasMoreHits - endCursor + endToken } hits { name @@ -170,7 +185,7 @@ The Discovery API has three entry points: `search` for full-text queries across summary { totalHits hasMoreHits - endCursor + endToken facets } hits { @@ -214,7 +229,9 @@ query { cart(id: "cart-id") { id items { - sku + variant { + sku + } name quantity price { @@ -238,18 +255,18 @@ query { 4. **Request only needed fields** - GraphQL allows precise field selection to minimize payload 5. **Handle async updates** - Discovery API may have sub-second delay for recently published content 6. **Protect APIs in production** - Configure authentication for sensitive data -7. **Use Core API for complex filters** - Only Core API supports filtering orders by customer, SKU, payment provider +7. **Use Core API for complex filters** - Only Core API supports filtering orders by SKU, payment provider, stage or meta; the Shop API lists them by customer alone 8. **Detect the Discovery schema, don't hardcode it** - Filter/sort/facet fields and the ranking arguments are tenant-generated; introspect before building a query 9. **Keep Core out of the storefront** - Storefront reads are Discovery/Catalogue, and carts, orders, customers and subscription contracts are the Shop API 10. **Scope `customerIdentifier` on the server** - The Shop API token is per tenant, not per shopper: `orders(customerIdentifier:)` answers for any identifier it is given, so take it from the session and never from the client -11. **Read an order list through one store** - Orders edited in Core appear more than once in the Shop API's list; group by `coreId` and keep the newest copy (see the [Shop API Order Queries Reference](references/shop-api-order-queries.md)) +11. **Read an order list through one store** - Orders registered in Core and edited there appear more than once in the Shop API's list; group by `coreId` and keep the newest copy (see the [Shop API Order Queries Reference](references/shop-api-order-queries.md)) ## References - [Core API Queries Reference](references/core-api.md) - Items, customers, orders, shapes with advanced filtering - [Discovery API Reference](references/discovery-api.md) - Search, browse, autocomplete, filters, facets, sorting, fuzzy matching, pagination and profiling - [Catalogue API Reference](references/catalogue-api.md) - Path-based query documentation -- [Shop API Queries Reference](references/shop-api-queries.md) - Cart and checkout query documentation (`/cart` endpoint) +- [Shop API Queries Reference](references/shop-api-queries.md) - Tokens and scopes, cart and checkout queries (`/cart` endpoint), customers and subscription contracts - [Shop API Order Queries Reference](references/shop-api-order-queries.md) - Order queries by ID or customer (`/order` endpoint) ## Related skills diff --git a/use-crystallize/skills/query/references/catalogue-api.md b/use-crystallize/skills/query/references/catalogue-api.md index fdfd85f..d674208 100644 --- a/use-crystallize/skills/query/references/catalogue-api.md +++ b/use-crystallize/skills/query/references/catalogue-api.md @@ -18,6 +18,9 @@ By default, the API is accessible without authentication. You can configure acce 2. Choose your preferred access level 3. Save changes +A restricted Catalogue API takes the `X-Crystallize-Static-Auth-Token` header, or +`X-Crystallize-Access-Token-Id` and `X-Crystallize-Access-Token-Secret`, depending on the level chosen. + ## When to Use Catalogue API Use the Catalogue API when you need: @@ -42,6 +45,10 @@ For search, filtering, and faceting, use the Discovery API instead. } ``` +`catalogue` also takes `version` (`published` by default, or `draft`) and `pathResolutionMethods`. That one +defaults to `[canonical]`, so an alias, a historic path or a shortcut answers `null` unless you ask for +`alias`, `history`, `shortcut` or `any`. + ### Product Query with Variants ```graphql @@ -66,21 +73,34 @@ For search, filtering, and faceting, use the Discovery API instead. ### Folder with Children +`children` is deprecated. Read a folder through `subtree`, a cursor connection: + ```graphql { catalogue(language: "en", path: "/shop/plants") { name - ... on Folder { - children { - name - path - type + subtree(first: 20, sortField: treePosition, sort: asc) { + edges { + cursor + node { + name + path + type + } + } + pageInfo { + hasNextPage + endCursor + totalNodes } } } } ``` +`sortField` is `treePosition` (the default), `publishedAt` or `name`, and `type` narrows to `product`, +`document` or `folder`. + ### Document Query ```graphql @@ -139,8 +159,9 @@ frontend actually renders rather than all of them. `pantone` and `ral`. Every field is nullable — an entry only carries the notations that were authored, so a storefront reading `hex` needs a fallback for entries stored as Pantone only. -Colour **filtering** does not belong here or in Discovery; filter on the Selection or topic that -carries the colour name. See [[content-model]] for the modelling rule. +Colour **filtering** does not belong here. Discovery does index a Colors component's `hex`, `label` and `ral` +as filter fields, but filter on the Selection or topic that carries the colour name instead: near-identical +hexes do not group into one facet. See [[content-model]] for the modelling rule. ## cURL Example @@ -159,7 +180,7 @@ The Catalogue API does **not** support: - Full-text search - Filtering - Faceting -- Sorting across multiple items +- Sorting beyond the children of one item (`subtree`) Use the Discovery API for these capabilities. diff --git a/use-crystallize/skills/query/references/core-api.md b/use-crystallize/skills/query/references/core-api.md index 00a0793..d6bcc81 100644 --- a/use-crystallize/skills/query/references/core-api.md +++ b/use-crystallize/skills/query/references/core-api.md @@ -7,6 +7,7 @@ The Core API provides comprehensive read access to items, customers, orders, sha - [Base URL](#base-url) - [Authentication](#authentication) - [Core API vs Other APIs](#core-api-vs-other-apis) +- [Reading a Core Response](#reading-a-core-response) — Union results, component content, nulls, paging - [Item Queries](#item-queries) — Get by ID, list with pagination, get components, check identifiers - [Customer Queries](#customer-queries) — Get by identifier, list, customer groups - [Order Queries](#order-queries) — Get by ID, list with advanced filtering @@ -54,6 +55,26 @@ curl -X POST 'https://api.crystallize.com/@your-tenant' \ - Search and filtering - Public content access +## Reading a Core Response + +Every query below follows these rules. The first three are checked before anything runs, so breaking one +fails the whole request with a validation error. + +- **The tenant comes from the URL.** No query takes a `tenantId` argument. +- **The root fields below return a union.** Select the result with `... on ` and the failure with + `... on BasicError`; `items(...) { edges }` without a fragment does not validate. The list types are + `ItemConnection`, `CustomerConnection`, `CustomerGroupConnection`, `OrderConnection`, `PieceConnection`, + `FlowContentConnection`, `ArchivedItemVersionConnection`, `BulkTaskConnection` and `ImageConnection`. + `shapes` is the exception: it returns a plain `ShapeConnection`. +- **`content` and `config` are unions too.** Give `content` one fragment per component type you expect. The + names carry `Component` here (`RichTextComponentContent`), unlike the Catalogue API (`RichTextContent`). +- **A missing record is `null`, not an error.** `item`, `customer` and `order` answer `null` when nothing + matches, so a client has to handle `null` as well as the union members. +- **An empty page has `edges: null`, not `[]`.** Guard before mapping over it. +- **Keep the same `sort` on every page.** The cursor carries the sort field, and a different one is refused: + `Invalid pagination options: cannot provide a sort field and a cursor at the same time.` `first`/`after` + cannot be mixed with `last`/`before` either. + ## Item Queries ### Get Item by ID @@ -71,7 +92,15 @@ query GetItem { } components { componentId - content + type + content { + ... on SingleLineComponentContent { + text + } + ... on RichTextComponentContent { + html + } + } } defaultVariant { sku @@ -96,20 +125,18 @@ query GetItem { shapeIdentifier components { componentId - content + type } } ... on Folder { id name shapeIdentifier - children { - id - name - type + tree { + path } } - ... on ItemNotFoundError { + ... on BasicError { errorName message } @@ -117,9 +144,14 @@ query GetItem { } ``` +`item` and `items` read the `current` version unless told otherwise (`versionLabel`: `current`, `draft` or +`published`). `price` and `stock` read the `default` price variant and stock location: pass `identifier:`, or +select `priceVariants` and `stockLocations`. A Core `Folder` has no `children` field; list them with +`items(filter: { parentItemId: "folder-id" })`. + ### List Items with Pagination -> **`first` caps at 100.** Asking for more is refused — +> **`first` defaults to 25 and caps at 100.** Asking for more is refused — > `Invalid pagination options: Cannot list more than 100 items at a time.` — and it arrives as an > `INTERNAL_SERVER_ERROR` with the path pointing at whatever field you selected, which makes it look like > something else went wrong. Page with `after` instead. Verified on a live tenant: `first: 100` returned @@ -133,40 +165,51 @@ query ListItems { after: "cursor" filter: { shapeIdentifiers: ["product", "bundle"] - # path: { prefix: "/shop" } - # includeDescendants: true + # parentItemId: "folder-id" + # topicId: "topic-id" } - sort: { field: updatedAt, direction: desc } + sort: { field: createdAt, direction: desc } ) { - edges { - cursor - node { - id - name - type - shapeIdentifier - createdAt - updatedAt - ... on Product { - defaultVariant { - sku - price - stock + ... on ItemConnection { + edges { + cursor + node { + id + name + type + shapeIdentifier + createdAt + updatedAt + ... on Product { + defaultVariant { + sku + price + stock + } } } } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor + ... on BasicError { + errorName + message } - totalCount } } ``` +`sort.field` is `name` (the default), `createdAt` or `position`: `updatedAt` is not sortable here. `filter` also +takes `type`, `shapeIdentifier`, `name` (case-insensitive substring), `itemIds`, `externalReferences`, +`relatingToItemId`, `imageKeys`, `videoKeys` and `fileKeys`. `parentItemId` lists direct children, shortcuts +included. + ### Get Item Component Read a specific component from an item: @@ -178,7 +221,11 @@ query GetItemComponent { id component(id: "description") { componentId - content + content { + ... on RichTextComponentContent { + html + } + } } } } @@ -189,14 +236,22 @@ query GetItemComponent { ```graphql query CheckIdentifier { - isIdentifierAvailable(tenantId: "tenant-id", identifier: "new-product-slug") { + isIdentifierAvailable(desired: "new-shape", type: shape) { ... on IdentifierAvailability { - available + isAvailable + suggestion + } + ... on BasicError { + errorName + message } } } ``` +`type` is required: `flow`, `flowStage`, `priceVariant`, `role`, `shape`, `stockLocation`, `customer` or +`customerGroup`. `suggestion` is a free alternative when `isAvailable` is false. + ## Customer Queries ### Get Customer by Identifier @@ -205,20 +260,14 @@ query CheckIdentifier { query GetCustomer { customer(identifier: "customer@example.com") { ... on Customer { - id identifier - tenantId - ... on IndividualCustomer { - firstName - lastName - email - phone - } - ... on OrganizationCustomer { - name - taxId - organizationNumber - } + type + firstName + lastName + email + phone + companyName + taxNumber addresses { type firstName @@ -237,7 +286,7 @@ query GetCustomer { value } } - ... on CustomerNotFoundError { + ... on BasicError { errorName message } @@ -245,67 +294,89 @@ query GetCustomer { } ``` +There is one `Customer` type for people and companies: `type` is `individual` or `organization`. Customers have +no `id`. `customer` also answers on `externalReference: { key: "erp", value: "1234" }` instead of `identifier`. + ### List Customers ```graphql query ListCustomers { customers( - tenantId: "tenant-id" first: 20 filter: { - email: { contains: "@example.com" } - # customerType: individual + email: "jane@" + # type: individual } ) { - edges { - node { - id - identifier - ... on IndividualCustomer { + ... on CustomerConnection { + edges { + node { + identifier + type firstName lastName email - } - ... on OrganizationCustomer { - name + companyName } } + pageInfo { + hasNextPage + endCursor + } + totalCount } - pageInfo { - hasNextPage - endCursor + ... on BasicError { + errorName + message } - totalCount } } ``` +`email` is a case-insensitive **prefix** match, so a domain such as `@example.com` finds nothing. `identifier`, +`firstName`, `lastName`, `companyName` and `phone` are case-insensitive substring matches. `filter` also takes +`type`, `meta: { key, value }`, `parent: { identifier, type }` and `logicalOperator` (`and` by default, `or` to +match any condition). `sort.field` is `createdAt` (the default), `name`, `identifier` or `type`. + ### Get Customer Groups ```graphql query GetCustomerGroup { - customerGroup(id: "group-id") { + customerGroup(identifier: "group-identifier") { ... on CustomerGroup { - id + identifier name - customerIdentifiers + childCount + } + ... on BasicError { + errorName + message } } } query ListCustomerGroups { - customerGroups(tenantId: "tenant-id", first: 20) { - edges { - node { - id - name - customerIdentifiers + customerGroups(first: 20) { + ... on CustomerGroupConnection { + edges { + node { + identifier + name + childCount + } } } + ... on BasicError { + errorName + message + } } } ``` +A group does not list its members. Read them with +`customers(filter: { parent: { identifier: "group-identifier", type: customerGroup } })`. + ## Order Queries ### Get Order by ID @@ -315,15 +386,14 @@ query GetOrder { order(id: "order-id") { ... on Order { id + reference createdAt updatedAt customer { identifier - ... on IndividualCustomer { - firstName - lastName - email - } + firstName + lastName + email } cart { sku @@ -352,7 +422,8 @@ query GetOrder { value } } - ... on OrderNotFoundError { + ... on BasicError { + errorName message } } @@ -366,54 +437,66 @@ The Core API supports advanced order filtering by customer, SKU, payment provide ```graphql query ListOrders { orders( - tenantId: "tenant-id" first: 20 filter: { - customerIdentifier: "customer@example.com" + customer: { identifier: "customer@example.com" } # sku: "PROD-123" - # paymentProvider: "stripe" + # paymentProvider: stripe # meta: [{ key: "source", value: "mobile-app" }] } sort: { field: createdAt, direction: desc } ) { - edges { - cursor - node { - id - createdAt - total { - gross - currency - } - customer { - identifier - } - cart { - sku - name - quantity - price { + ... on OrderConnection { + edges { + cursor + node { + id + createdAt + total { gross + currency + } + customer { + identifier + } + cart { + sku + name + quantity + price { + gross + } } } } + pageInfo { + hasNextPage + endCursor + } + totalCount } - pageInfo { - hasNextPage - endCursor + ... on BasicError { + errorName + message } - totalCount } } ``` **Filter Options:** -- `customerIdentifier` - Filter by customer email/identifier +- `customer` - `{ identifier, firstName, lastName, email, companyName, address: { street, city, country } }` - `sku` - Filter orders containing specific SKU -- `paymentProvider` - Filter by payment method (stripe, klarna, etc.) +- `paymentProvider` - Enum: `cash`, `custom`, `klarna`, `paypal` or `stripe` +- `paymentStatus`, `types`, `reference` - Filter by payment status, order type or reference - `meta` - Filter by metadata key/value pairs -- `createdAt` - Date range filtering +- `createdAt`, `updatedAt` - Date range filtering: `{ min, max }` +- `totalPrice` - `{ min, max, type }`, where `type` is `net` (the default) or `gross` +- `pipelineId`, `pipelineIds`, `pipelineStageId`, `pipelineStageIds` - Filter by pipeline or stage +- `stockLocationIdentifier`, `appliedPromotionIdentifier`, `hasSubscription`, `subscriptionContractId`, + `relatedToOrderId` + +`sort.field` is `updatedAt` (the default) or `createdAt`, and `direction` defaults to `desc`. ## Shape and Piece Queries @@ -430,28 +513,48 @@ query GetShape { id name type - config + description } variantComponents { id name type - config + description } + resolvedConfiguration + } + ... on BasicError { + errorName + message } } } ``` +`config` on a component is the `ComponentConfig` union, so it needs one fragment per component type. +`resolvedConfiguration` is the whole definition as JSON with pieces expanded, which is usually what a script +wants. + ### List All Shapes +`shapes` is the one root field here with no union around it. + ```graphql query ListShapes { - shapes(tenantId: "tenant-id") { - identifier - name - type - itemCount + shapes(first: 100) { + edges { + node { + identifier + name + type + itemCount + } + } + pageInfo { + hasNextPage + endCursor + } + totalCount } } ``` @@ -468,9 +571,12 @@ query GetPiece { id name type - config } } + ... on BasicError { + errorName + message + } } } ``` @@ -479,18 +585,24 @@ query GetPiece { ```graphql query ListPieces { - pieces(tenantId: "tenant-id", first: 100) { - edges { - node { - identifier - name - components { - id + pieces(first: 100) { + ... on PieceConnection { + edges { + node { + identifier name - type + components { + id + name + type + } } } } + ... on BasicError { + errorName + message + } } } ``` @@ -501,16 +613,20 @@ query ListPieces { ```graphql query GetFlow { - flow(id: "flow-id") { + flow(identifier: "flow-identifier") { ... on Flow { - id + identifier name + type stages { - id + identifier name - position } } + ... on BasicError { + errorName + message + } } } ``` @@ -521,25 +637,42 @@ List items in a flow stage: ```graphql query GetFlowContent { - flowContent(flowId: "flow-id", stageId: "stage-id", language: "en", first: 20) { - edges { - node { - item { + flowContent(first: 20, filter: { stageIdentifier: "stage-identifier" }) { + ... on FlowContentConnection { + edges { + node { id - name - type - } - assignedTo { - id - email + stageIdentifier + createdAt + data { + ... on FlowItemContentData { + language + version + item { + id + name + type + } + } + } } - dueDate } + pageInfo { + hasNextPage + endCursor + } + } + ... on BasicError { + errorName + message } } } ``` +Flows and stages are addressed by `identifier`. `filter.item` takes `{ id, language, version }` to find where an +item sits, and `flowStage(identifier:)` reads one stage. + ## Archive Queries ### Get Archived Version @@ -560,26 +693,39 @@ query GetArchive { name } } + ... on BasicError { + errorName + message + } } } ``` +`archive` takes either `id` alone, or `itemId`, `language` and `number` together (plus an optional +`versionLabel`, `published` by default). Mixing the two is refused. + ### List Archived Versions ```graphql query ListArchives { - archives(itemId: "item-id", language: "en", first: 10, sort: { field: number, direction: desc }) { - edges { - node { - id - number - name - archivedAt - archivedBy { - email + archives(filters: { itemId: "item-id", language: "en" }, first: 10, sort: { field: number, direction: desc }) { + ... on ArchivedItemVersionConnection { + edges { + node { + id + number + name + archivedAt + archivedBy { + email + } } } } + ... on BasicError { + errorName + message + } } } ``` @@ -603,6 +749,10 @@ query GetBulkTask { email } } + ... on BasicError { + errorName + message + } } } ``` @@ -611,19 +761,27 @@ query GetBulkTask { ```graphql query ListBulkTasks { - bulkTasks(tenantId: "tenant-id", first: 20, filter: { status: running }) { - edges { - node { - id - type - status - createdAt + bulkTasks(first: 20, filter: { status: started }) { + ... on BulkTaskConnection { + edges { + node { + id + type + status + createdAt + } } } + ... on BasicError { + errorName + message + } } } ``` +`status` is `pending`, `started`, `complete` or `error`. `filter` also takes `type` and `actorId`. + ## File and Image Queries ### Get Image @@ -635,37 +793,55 @@ query GetImage { key url altText - caption + caption { + plainText + } variants { url width height } } + ... on BasicError { + errorName + message + } } } ``` +`image` and `images` take an optional `language` and fall back to the tenant's default language. + ### List Images ```graphql query ListImages { - images(tenantId: "tenant-id", first: 20, filter: { path: { prefix: "/products/" } }) { - edges { - node { - key - url - altText - createdAt + images(first: 20, filter: { filename: { value: "hero", condition: contains } }) { + ... on ImageConnection { + edges { + node { + key + url + altText + createdAt + } } } + ... on BasicError { + errorName + message + } } } ``` +`filename.condition` is `contains` (the default), `startsWith` or `endsWith`. `filter.topic` takes +`{ ids, includeDescendants, logicalOperator }`. + ## Error Handling -The Core API uses union return types. Always handle potential errors: +The Core API uses union return types. Always handle potential errors, and spread `BasicError`, the interface +every error type implements, to catch them all in one fragment: ```graphql query { @@ -674,11 +850,7 @@ query { id name } - ... on ItemNotFoundError { - errorName - message - } - ... on UnauthorizedError { + ... on BasicError { errorName message } @@ -689,11 +861,13 @@ query { Common error types: - `UnauthorizedError` - Invalid or missing credentials -- `ItemNotFoundError` - Item doesn't exist -- `CustomerNotFoundError` - Customer doesn't exist -- `OrderNotFoundError` - Order doesn't exist +- `InvalidIdError` - The id is malformed; `providedValue` holds what was sent +- `UnknownError` - Anything else; `errorName` carries the original error name - `BasicError` - Generic error +`ItemNotFoundError` and `OrderNotFoundError` belong to mutations. They are not members of the `item` and `order` +unions, so spreading them on a read fails validation: a read answers `null` instead. + ## TypeScript Types Use generated types for type safety: @@ -702,7 +876,8 @@ Use generated types for type safety: import { Query, GetItemQuery, GetCustomerQuery, ListOrdersQuery } from "@/generated/core"; ``` -Types are generated from the Core API schema via GraphQL Code Generator. +These come from your own GraphQL Code Generator run against the Core API schema: the import path is wherever +your config writes them, and nothing is published under it. ## Related Links diff --git a/use-crystallize/skills/query/references/discovery-api.md b/use-crystallize/skills/query/references/discovery-api.md index febfcde..d5c2bb0 100644 --- a/use-crystallize/skills/query/references/discovery-api.md +++ b/use-crystallize/skills/query/references/discovery-api.md @@ -14,11 +14,15 @@ belongs to the Core API). ## Authentication -By default, the Discovery API is open. If you configure restricted access for your Catalogue API, you -need to provide authentication: +By default, the Discovery API is open. Restrict it with the **Search & Discovery** entry under Settings → API +Access, which is separate from the Catalogue entry, then send the tenant's static token: -- Static token via header -- Access tokens for programmatic access +``` +X-Crystallize-Static-Auth-Token: YOUR_STATIC_TOKEN +``` + +That header is the only credential Discovery reads, whichever restricted level the entry is set to, so an +access token pair is not accepted. A missing or wrong token answers HTTP 401. > **Important**: Always secure your API with authentication in production environments. @@ -28,13 +32,15 @@ This is the single most important thing to know before writing a query. The Disc **derived from the tenant's shapes and index settings**, so it differs between tenants and changes when the tenant is re-indexed: -- Every shape becomes a type and a `browse` field — `product`, `category`, `brand`, … +- Every shape becomes a type and a `browse` field — `product`, `category`, `brand`, … A hyphen in an + identifier camelCases: the shape `my-shape` is `myShape`, with the input `MyShapeFilter` - Filter, facet and sort inputs (`TenantFilter`, `ProductFacet`, `TenantSort`, …) are generated from indexed component fields — `price_default`, `stock_oslo`, `specs_label`, `variants_topics`, … -- `TenantLanguage` is an enum of the tenant's languages +- A component with `discoverable: false` is left out, and a shape with none left is dropped +- `TenantLanguage` is an enum of the tenant's languages, under the same rule: `nb-no` is `nbNo` - Ranking inputs and their enums appear **only** on tenants served for ranking (see below) -**Introspect, do not assume.** An un-ignited tenant answers +**Introspect, do not assume.** An un-ignited tenant answers HTTP 404 with `{"success": false, "message": "There is no ignited Tenant for ."}` rather than serving a schema at all. A tenant with no shapes has no `browse` field. A tenant whose schema cannot be built answers HTTP 500 with `extensions.code: "TENANT_SCHEMA_UNAVAILABLE"`. Retrying will not help, so report @@ -71,7 +77,11 @@ pagination, options, rankBy, context, nearestTo, filters, facets, sorting ``` `Folder.children` and `Topic.items` take that same set, which is what makes nested category listings -filterable and rankable in one round trip. +filterable and rankable in one round trip. Their `filters` are a `TenantFilter`, and they sort by +`position: asc` unless told otherwise. + +A query nested deeper than 20 levels is refused, fragments included, and a batched request carries at most +10 operations. ### Basic Search @@ -147,8 +157,10 @@ Filters compose with `AND` and `OR`, each taking a list of nested filters. | `NumberFilter` | `exists`, `equals`, `not_equals`, `in`, `not_in`, `range: { gt, gte, lt, lte }` | | `DateFilter` | `exists`, `equals`, `not_equals`, `in`, `not_in`, `range: { gt, gte, lt, lte }` | | `BooleanFilter` | `exists`, `equals`, `not_equals` | +| `GeoFilter` | `exists`, `within: { box, circle, polygon }` — on Location components | -`type_in: [ItemType]` (`product`, `document`, `folder`) is the common way to narrow a `search`. +`type_in: [ItemType]` (`product`, `document`, `folder`) is the common way to narrow a `search`. It exists on +`TenantFilter` only: a shape's own filter has no use for it. ## Typo tolerance (fuzzy search) @@ -169,12 +181,13 @@ Search is **exact by default**. Opt into typo tolerance through `options.fuzzy`: | --------------- | ------- | ------------------------------------------------------------- | | `fuzziness` | `NONE` | Max single-character edits: `NONE`, `SINGLE`, `DOUBLE` | | `prefixLength` | `0` | Leading characters that must match exactly before edits apply | -| `maxExpensions` | `50` | Max term variations generated | -| `maxExpansions` | `50` | The same option, spelled correctly — both exist on the input | +| `maxExpensions` | `50` | Misspelled and inert: accepted, and the value is discarded | +| `maxExpansions` | `50` | Max term variations generated | -Two spelling traps, both confirmed by introspection: `FuzzySearchOptions` carries **`maxExpensions` and -`maxExpansions`**, and the enum type is **`Fuziness`**, not `Fuzziness` — a typed variable -(`$f: Fuzziness!`) fails schema validation. +Two spelling traps: `FuzzySearchOptions` carries **`maxExpensions`** on every tenant but the working +**`maxExpansions`** only on tenants served for ranking, and sending it anywhere else is a validation error. +And the enum type is **`Fuziness`**, not `Fuzziness` — a typed variable (`$f: Fuzziness!`) fails schema +validation. Raising `fuzziness` widens the candidate set and costs latency — prefer `SINGLE` before `DOUBLE`, and use `prefixLength` to keep short, common terms precise. `DOUBLE` is very loose on short tokens: on a @@ -214,14 +227,16 @@ Two things follow: - **The top-level `autocomplete` query answers with the whole index**, padding the tail with score-0 hits, whatever the term. Drop hits with `score: 0`, or add the name filter, or you will show a type-ahead list of everything you sell. -- **`name: { autocomplete: { term } }` is a filter on `StringFilterWithAutocomplete`** — only `name` has - it — and on its own every hit comes back scored `0`, in no useful order. Pass the same text as `term` - as well and sort by `score: desc`: the term scores the matches, the filter restricts the set. +- **`name: { autocomplete: { term } }` is a filter on `StringFilterWithAutocomplete`** — `name`, `sku` and + `variants_name` have it — and on its own every hit comes back scored `0`, in no useful order. Pass the + same text as `term` as well and sort by `score: desc`: the term scores the matches, the filter restricts + the set. -Autocomplete is prefix matching per token, so it does not fix a typo **inside** a word ("nikkon", -"hitli"), and it does not match inside a compound word ("kjernebor" does not come up for -"diamantkjernebor"). Full-text `search(term:, options: { fuzzy: … })` does both. The pattern that works: -prefix autocomplete first, and full-text fuzzy as the fallback when it finds nothing. +Autocomplete is prefix matching per token, so without fuzzy options (`options` beside the filter's `term` +takes `FuzzySearchOptions`) it does not fix a typo **inside** a word ("nikkon", "hitli"), and it does not +match inside a compound word ("kjernebor" does not come up for "diamantkjernebor"). Full-text +`search(term:, options: { fuzzy: … })` does both. The pattern that works: prefix autocomplete first, and +full-text fuzzy as the fallback when it finds nothing. ## Browse Queries @@ -237,7 +252,7 @@ component fields available directly. This is the recommended approach for storef summary { totalHits hasMoreHits - endCursor + endToken } hits { name @@ -282,9 +297,11 @@ component fields available directly. This is the recommended approach for storef } ``` -- Use `path: "/shop/*"` for direct children (wildcard) +- Use `path: "/shop/*"` for everything under `/shop`, at any depth. For direct children only, use + `Folder.children` as above, or filter on `parentId` - Use `path: "/shop/exact-item"` for a specific item -- `pathResolutionMethod` (`canonical`, `alias`, `history`, `shortcut`) controls how a path is resolved +- `pathResolutionMethod` (`canonical`, `alias`, `history`, `shortcut`) controls how a path is resolved; left + out, a path matches on all four - Use aliases to combine multiple browse queries in one request ### Combined Query with Aliases @@ -334,6 +351,9 @@ component fields available directly. This is the recommended approach for storef **Always add a deterministic secondary field** (such as `itemId`) so pagination stays stable across pages. Note that `sorting` does **not** compose predictably with ranking — see below. +`price_default` holds one value per variant. For one value per product, sort or filter on +`defaultVariantPrice_default`, `lowestVariantPrice_default` or `highestVariantPrice_default`. + ## Pagination ### Cursor-Based Pagination (Recommended) @@ -360,9 +380,10 @@ Use `paginationToken` (returned as `endToken` in summary) for efficient, consist **Flow:** -1. First request: omit `after` (or set to `null`) +1. First request: omit `after` 2. Use `summary.endToken` as the `after` value for the next page -3. Stop when `summary.hasMoreHits` is `false` +3. Stop when `summary.hasMoreHits` is `false` **or** `hits` is empty. The last page can still read + `hasMoreHits: true`, and the empty page after it has no `endToken` to send `pagination` accepts `limit`, `after`, `before` and `skip`. @@ -394,6 +415,51 @@ require `boundaries`. `summary.priceRange(priceIdentifier: "default", quantity: 1) { min max }` gives the price bounds of the current result set — useful for a range slider that matches the active filters. +## Variants on a Hit + +Listing `filters` match a product as soon as **different** variants satisfy different conditions. To check +conditions against one variant at a time, use the variant fields on the hit: + +| Field | Use for | +| ------------------------------------------- | ------------------------------------------------------------------------- | +| `variants(skus:, first:, filter:)` | The variants that satisfy `filter` on their own, in product order | +| `variant(selected: [{ attribute, value }])` | The first variant carrying every pair: product pages, add to cart | +| `variantGroups(by:, filter:, first:)` | One group per attribute value, so a listing card shows one colourway each | + +```graphql +{ + browse { + product(language: en) { + hits { + name + variantGroups(by: ["colour"], filter: { price_default: { range: { lte: 100 } } }, first: 4) { + matched + attributes { + attribute + value + } + representative { + sku + defaultPrice + } + values(of: "size") { + value + sku + inStock + } + } + } + } + } +} +``` + +`filter` is a `VariantFilterInput`: the variant fields of the listing filter, with exact operators only (no +`contains`, `phrase`, `regex` or `autocomplete`). `variantGroups` never drops a group or changes hits, facets +or pagination: groups with a matching variant come first, the others read `matched: false`. Attribute names +and values are case-sensitive. A key that only matches in another case changes nothing in the result and adds a +warning under `extensions.warnings`. + ## Ranking, personalization and similarity Ranking-enabled tenants additionally accept `rankBy`, `context` and `nearestTo`, and expose `rankScore` @@ -406,9 +472,11 @@ Two things to know from here: 1. **The arguments are absent from the schema until the tenant is served for ranking.** Referencing them on an ordinary tenant is a GraphQL validation error, not an unranked result. Detect the capability: `{ __type(name: "RankByInput") { name } }`. -2. **Ranked queries page differently.** When a rerank runs, cursor tokens fall back to offset - pagination — use `skip` + `limit`, and remember that `skip` offsets into a bounded rerank window - (`options.rerankWindow`, default 500, cap 2000). +2. **Ranked queries page differently.** When a rerank runs over a window, `after`/`before` move the window + rather than the page — use `skip` + `limit`, and remember that `skip` offsets into a bounded rerank + window (`options.rerankWindow`, default 500, cap 2000). Past the window a page is empty and + `hasMoreHits` is `false`, even when more documents matched. `sorting` picks which documents enter the + window; the rerank owns the order they come back in. ## Profiling diff --git a/use-crystallize/skills/query/references/shop-api-order-queries.md b/use-crystallize/skills/query/references/shop-api-order-queries.md index bf52899..deaa9f7 100644 --- a/use-crystallize/skills/query/references/shop-api-order-queries.md +++ b/use-crystallize/skills/query/references/shop-api-order-queries.md @@ -22,7 +22,7 @@ See the [Shop API Queries Reference](shop-api-queries.md) for token generation d ## Get a Single Order -Retrieve an order by its Shop API UUID. +Retrieve an order by its Shop API UUID. An unknown id answers a GraphQL error, not `null`. ```graphql query GetOrder($id: UUID!) { @@ -82,7 +82,8 @@ query GetOrder($id: UUID!) { amount } taxBreakdown { - taxRate + percent + base amount } } @@ -163,34 +164,39 @@ Variables: > `customerIdentifier` it is given. Take the identifier from the session and never from the client. The > same holds for `subscriptionContracts(customerIdentifier:)` on the `/subscription-contract` endpoint. -**`limit`/`skip` paged inconsistently** in the Tools Universe build: `limit: 10` returned 5 orders and -then 4 for a customer with 9. Ask for everything a customer has in one page (`limit: 100`) rather than -walking pages. +**`limit`/`skip` do not page by date.** `limit` defaults to 10 and the window is taken over the customer's +orders **oldest first**; only the orders inside it are then sorted newest first. So a default call for a +customer with 30 orders answers their ten oldest, not their ten latest. A page can also come back short +without being the last: `limit: 10` returned 5 orders and then 4 for a customer with 9 in the Tools Universe +build. Ask for everything a customer has in one page (`limit: 100`) rather than walking pages. For a company or a department there is no query by parent: read the people's orders and filter on your own `meta` (the ordering person, the cost centre). ## The Order Store and Core -Shop API orders and Core orders are the same orders in two stores, and they do **not** converge. Two -build findings decide how a storefront should read them. +Shop API orders and Core orders are the same orders in two stores, synced asynchronously in both +directions. How an order was **created** decides how that behaves, and so how a storefront should read it. -**Orders edited in Core appear more than once here.** Three `updateOrder` calls on one seeded order -left **three** orders in `orders(customerIdentifier:)`: new Shop ids, the same `coreId`, different -`updatedAt`. A storefront that sums an order list then counts the same money several times over — the Lab -Universe build saw a department budget of NOK 1,249,299 instead of 411,775. Two defences, use both: +**An order created with the Shop API stays one order.** It gets its `coreId` in about ten seconds and keeps +its Shop id for good: `addToStage` on the Shop API moves the Core stage too, and an edit, stage change or +delete in Core lands on that same order. -1. **Group by `coreId` and keep the most recently updated copy** whenever you read a list. +**An order registered in Core and edited there appears more than once here.** It has no Shop id, so every +change Core pushes arrives as a new order. Three `updateOrder` calls on one seeded order left **three** orders in +`orders(customerIdentifier:)`: new Shop ids, the same `coreId`, different `updatedAt`. A storefront that sums +an order list then counts the same money several times over — the Lab Universe build saw a department budget +of NOK 1,249,299 instead of 411,775. `deleteOrder` in Core removes only the latest copy. Two defences, use +both: + +1. **Group by `coreId` and keep the most recently updated copy** whenever you read a list. A null `coreId` is + not a group: those orders stay separate. 2. **Change orders through the Shop API** (`setMeta`, `addToStage`) when a storefront reads them, so no copy is ever made. -**The sync runs one way, Shop → Core.** An order created with the Shop API gets its `coreId` in about ten -seconds, and `addToStage` on the Shop API moves the Core stage too. Nothing comes back the other way: -orders registered in Core with `registerOrder` showed up in the Shop store only sometimes, carrying the -pipelines they had at creation time, and a later `updateOrderPipelineStage` or `deleteOrder` in Core never -reached the Shop store at all (watched for minutes). So a storefront that lists orders with the Shop API -must have them **created** there, and **moved between stages** there. Seed demo orders with the Shop API, -not with `registerOrder`. (Tools Universe.) +So a storefront that lists orders with the Shop API should have them **created** there, and **moved between +stages** there. Seed demo orders with the Shop API, not with `registerOrder`: orders registered in Core +showed up in the Shop store only sometimes. (Tools Universe.) Two smaller consequences: diff --git a/use-crystallize/skills/query/references/shop-api-queries.md b/use-crystallize/skills/query/references/shop-api-queries.md index 5e06a45..3ce0961 100644 --- a/use-crystallize/skills/query/references/shop-api-queries.md +++ b/use-crystallize/skills/query/references/shop-api-queries.md @@ -1,6 +1,8 @@ # Shop API Queries Reference -The Shop API handles cart and checkout operations at the edge for low-latency commerce experiences. +The Shop API handles cart and checkout operations at the edge for low-latency commerce experiences. It is one +GraphQL endpoint per concern, each with its own schema: `/cart`, `/order`, `/customer`, `/subscription-contract` +and `/lock`. ## Base URL @@ -29,15 +31,21 @@ curl -X POST 'https://shop-api.crystallize.com/YOUR_TENANT/auth/token' \ -d '{"scopes":["cart","cart:admin","order"],"expiresIn":18000}' ``` +`expiresIn` is in seconds and defaults to one day. The response is `{ "success": true, "token": "..." }`. + ### Token Scopes -| Scope | Description | -| ------------ | -------------------------------------------- | -| `cart` | Manage a single cart | -| `cart:admin` | Manage multiple carts | -| `order` | Create and manage orders (`/order` endpoint) | -| `usage` | Access usage/metrics API | -| `lock` | Acquire/release locks | +A token needs the scope named after the endpoint it calls. An `/admin` sub-path needs `:admin` **as well**. + +| Scope | Description | +| ----------------------- | --------------------------------------------------------- | +| `cart` | Read and manage carts (`/cart` endpoint) | +| `cart:admin` | Cart administration and promotions (`/cart/admin`) | +| `order` | Create and manage orders (`/order` endpoint) | +| `customer` | Read and manage customers (`/customer` endpoint) | +| `subscription-contract` | Read and manage contracts (`/subscription-contract`) | +| `lock` | Acquire/release locks | +| `booking:admin` | Booking administration (`/booking/admin`, with `booking`) | ### Using the Token @@ -49,6 +57,8 @@ Authorization: Bearer YOUR_JWT_TOKEN ## Cart Queries +An unknown `id` answers a GraphQL error, not `null`. + ### Retrieve Cart ```graphql @@ -61,11 +71,13 @@ query { createdAt updatedAt items { - sku + lineId name quantity - managed - origin + context + variant { + sku + } images { url } @@ -84,36 +96,86 @@ query { } ``` -### Cart as Order Intent +- The SKU sits on `variant`, not on the item. +- `items.price` is the **line** total (quantity × unit price). The unit price is `items.variant.price`. +- `items.context` is a `HashMap` holding `taxRate`, `origin` (`crystallize` or `external`) and `managed`, so it + takes no sub-selection. -Retrieve cart formatted for order creation: +### List Carts + +`carts` lists carts and wishlists. Like `orders`, it answers for whatever `customerIdentifier` it is given, so +take the identifier from the session. ```graphql query { - cartAsOrderIntent(id: "cart-id-here") { - customer { - firstName - lastName - email + carts(types: [wishlist], customerIdentifier: "customer@example.com", limit: 20) { + hits { + id + name + updatedAt } + summary { + totalHits + hasMoreHits + } + } +} +``` + +`limit` defaults to 20 and `skip` to 0. It also takes `states`, `name`, `nameContains`, `updatedAfter`, +`updatedBefore`, `createdAfter` and `createdBefore`. + +### Cart as Order Intent + +Retrieve a placed cart formatted for order creation: + +```graphql +query { + orderIntent(id: "cart-id-here", format: shop) { + order cart { - sku - name - quantity - price { + id + total { gross net } } - total { - gross - net + } +} +``` + +`order` is a `HashMap`, so it takes no sub-selection, and it can be pushed to the matching API without +transformation. `format` names that API and defaults to `legacy`: pass `shop` for the Shop API `create`, or +`core` or `coreWithOrderV2` for the Core API. A cart that is not in the `placed` state is refused. + +## Customer and Subscription Contract Queries + +Both follow the same pattern on their own endpoint, with their own scope. The JWT is per tenant, so take the +identifier from the session here too. + +```graphql +# POST https://shop-api.crystallize.com/{tenant-identifier}/customer +query { + customer(identifier: "customer@example.com") { + identifier + firstName + lastName + email + addresses { + type + street + city + postalCode + country } + meta } } ``` -This format can be pushed directly to the Order API without transformation. +`/subscription-contract` serves `subscriptionContract(id:)` and +`subscriptionContracts(customerIdentifier:, limit:, skip:)`, which answers an empty list when no +`customerIdentifier` is given. See [[subscriptions]]. ## Cart Concepts @@ -137,8 +199,10 @@ SKU items start as managed but become unmanaged if you override properties like ### Expiration -- You can set cart expiration time -- Carts without updates for 3+ months are automatically deleted +- You can set cart expiration time with `context.ttl` (30 days by default); every write pushes `expiresAt` back +- A cart in the `cart` state goes stale after `context.staleTtl` without a write (1 hour by default), and turns + `abandoned` within five minutes +- Carts are automatically deleted three months after `expiresAt`. Wishlists never expire ## Related Links From e49e8485065d0049085ace76441f4d47a6462bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 15:44:14 -0700 Subject: [PATCH 03/29] fix(skills): make the payments helpers work and stop payment writes undoing stage moves The helpers built invalid mutations and could not rewrite payments. Pipelines take ids, Crystallize does not retry webhooks, and Shop writes reset stages moved in the App. --- use-crystallize/skills/payments/SKILL.md | 313 +++++------------- .../references/crystallize-payments-lib.md | 262 +++++++++++++++ .../skills/payments/references/mollie.md | 12 +- .../skills/payments/references/montonio.md | 11 +- .../skills/payments/references/qliro.md | 2 +- .../skills/payments/references/quickpay.md | 2 +- .../skills/payments/references/two.md | 15 +- .../payments/references/vipps-mobilepay.md | 3 +- 8 files changed, 362 insertions(+), 258 deletions(-) create mode 100644 use-crystallize/skills/payments/references/crystallize-payments-lib.md diff --git a/use-crystallize/skills/payments/SKILL.md b/use-crystallize/skills/payments/SKILL.md index befb556..abc12fd 100644 --- a/use-crystallize/skills/payments/SKILL.md +++ b/use-crystallize/skills/payments/SKILL.md @@ -7,10 +7,9 @@ description: > capture, refund and cancellation from fulfilment pipelines. Use when adding a payment provider or method to a Crystallize storefront, building or fixing checkout payment or a payment webhook, stopping duplicate or unpaid orders, a cart changed after paying (two tabs, back button), capturing on shipment or refunding. Trigger on - "payment", "payment gateway", "webhook", "capture", "refund", "place cart", "lock the cart", "duplicate order", - "paymentStatus", "addPayments", "setPayments", "createFromCart", "createShopOrderManager", "Stripe", "Adyen", - "Klarna", "Qliro", "Dintero", "Vipps", "MobilePay", "Mollie", "Montonio", "QuickPay", "Two", "Razorpay", - "B2B invoice". + "payment", "payment gateway", "payment webhook", "capture", "refund", "place cart", "lock the cart", + "duplicate order", "paymentStatus", "addPayments", "setPayments", "createFromCart", "Stripe", "Adyen", "Klarna", + "Qliro", "Dintero", "Vipps", "MobilePay", "Mollie", "Montonio", "QuickPay", "Two", "Razorpay", "B2B invoice". metadata: author: Crystallize version: "2.0" @@ -75,21 +74,35 @@ Crystallize closes it with `place`: so an order can only ever come from a frozen cart. - **Place first, then create the provider session — never the other way round.** Charge the `total` that `place` returns: `place` re-prices the cart from the catalogue one last time, so it can differ from the - last `hydrate`. Never take an amount from the browser. + last `hydrate`, and a SKU the catalogue no longer serves in the cart's language is dropped without an error (a + booking line makes `place` fail instead). Never take an amount from the browser. +- **Hydrate with `context.price.decimals`** (4 suits most shops). The default is 0, and the Shop API then answers + rounded amounts: `place` says 20 for a cart worth 19.99, and you charge 20 for an order of 19.99. `total.currency` + is `context.price.currency` exactly as you sent it (`eur` when you send none), whatever the price variant's + currency is called: send an ISO 4217 code. - **Everything the order needs goes on the cart before `place`:** customer and addresses (`setCustomer`), shipping as an external item so it is part of the total, and checkout choices that shape the order, such as a pickup point or a B2B company (cart `meta`). A payment method or bank preselection does not change the amount: keep it on the cart, or pass it when you create the session (and put it in the session's idempotency key) so the shopper can switch method on the same placed cart. + A later `hydrate` replaces `customer` and `meta` with what it is given: send them again, or set them after it. +- **Cart writes land just after they answer.** `hydrate`, `setCustomer`, `setMeta` and the item mutations save a + moment after their response, so a `place` sent straight after can miss the write, or fail with "The cart was + changed by another request while it was being placed"; two writes in a row can overwrite each other. Read the cart + until it shows the write, then place. When `place` fails, read the cart again: `placed` means another request + placed it; otherwise place it again. - **Writes to a placed cart fail silently.** `addSkuItem`, `addExternalItem`, `setCustomer`, `setMeta` - and item changes answer with a cart that shows your change — but nothing is saved. Read - the cart's `state` before editing it. + and item changes answer with a cart that shows your change — but nothing is saved. `hydrate` and the booking + mutations do refuse ("A placed cart cannot be hydrated; its contents are frozen."). Read the cart's `state` before + editing it. - **Back from the payment page = a new cart.** If the shopper wants to change anything, `hydrate` a new cart without an id (copy the items over) and swap your cookie. The old placed cart keeps its own session; if that session is paid later, it pays for exactly the old cart — still consistent. Expire or cancel that old session where the provider allows it (each reference says how). - On a placed cart, `isStale` turns `true` after about an hour and means nothing: placed prices never change. Carts (placed included) are deleted about three months after they expire. +- A cart still in the `cart` state turns `abandoned` once stale (`context.staleTtl`, 1 hour by default). That closes + nothing: an abandoned cart can still be hydrated, edited and placed. Only `ordered` is final. ```ts // app/api/checkout/pay/route.ts — the storefront's "Pay" button @@ -100,7 +113,7 @@ export async function POST(req: Request) { const cart = (await carts.fetch(cartId, { state: true, ...PLACED_CART })) as unknown as PlacedCart & { state: "cart" | "placed" | "ordered" | "abandoned"; }; - if (cart.state !== "cart" && cart.state !== "placed") return Response.json({ error: "closed" }, { status: 409 }); + if (cart.state === "ordered") return Response.json({ error: "closed" }, { status: 409 }); const placed = cart.state === "placed" ? cart : ((await carts.place(cartId, PLACED_CART)) as unknown as PlacedCart); // → the provider reference's create function: amount = placed.total.gross, reference = placed.id. It reuses // the cart's existing session, and answers "already paid" (→ the return page) when that session completed. @@ -166,229 +179,22 @@ and reading Klarna's HPP session are optional. The reference says so; the page s ## `lib/crystallize-payments.ts` -The provider references import these helpers. Everything goes through `@crystallize/js-api-client` (7.5 or later): -`createCartManager` for the Shop API `/cart`, `createShopOrderManager` for `/order` (`createFromCart`, -`addPayments`, `setPayments`) and `createShopCustomerManager` for `/customer`. The client fetches one Shop API -token for all of them. - -```ts -// lib/crystallize-payments.ts -import { - createCartManager, - createClient, - createShopCustomerManager, - createShopOrderManager, -} from "@crystallize/js-api-client"; - -export const api = createClient( - { - tenantIdentifier: process.env.CRYSTALLIZE_TENANT_IDENTIFIER!, - accessTokenId: process.env.CRYSTALLIZE_ACCESS_TOKEN_ID!, - accessTokenSecret: process.env.CRYSTALLIZE_ACCESS_TOKEN_SECRET!, - }, - { shopApiToken: { scopes: ["cart", "order", "customer"] } }, // one token for every endpoint used here -); -export const carts = createCartManager(api); -export const orders = createShopOrderManager(api); -const customers = createShopCustomerManager(api); - -// What a provider session needs from the placed cart. `price` is the line total, `variant.price` the unit. -// `type` tells product lines from external ones (`shipping`, `fee`, `promotion`, …). -const ADDRESS = { - type: true, - firstName: true, - lastName: true, - street: true, - street2: true, - streetNumber: true, - postalCode: true, - city: true, - state: true, - country: true, - phone: true, - email: true, -}; -const CUSTOMER = { - identifier: true, - isGuest: true, - type: true, - email: true, - firstName: true, - lastName: true, - phone: true, - companyName: true, - taxNumber: true, - addresses: ADDRESS, -}; -export const PLACED_CART = { - total: { gross: true, net: true, taxAmount: true, currency: true }, - items: { - lineId: true, - type: true, - name: true, - quantity: true, - variant: { sku: true, price: { gross: true, net: true, taxPercent: true } }, - price: { gross: true, net: true, taxAmount: true, taxPercent: true }, - }, - customer: CUSTOMER, - meta: true, -}; -type Address = { type: "delivery" | "billing" | "other" } & Partial< - Record, string | null> ->; -type CartCustomer = { - identifier?: string | null; - isGuest?: boolean; - type?: "individual" | "organization" | null; - email?: string | null; - firstName?: string | null; - lastName?: string | null; - phone?: string | null; - companyName?: string | null; - taxNumber?: string | null; - addresses?: Address[] | null; -}; -export type PlacedCart = { - id: string; - total: { gross: number; net: number; taxAmount: number; currency: string }; - items: { - lineId: string | null; - type: "standard" | "shipping" | "fee" | "promotion" | "service" | "digital" | string | null; - name: string; - quantity: number; - variant: { sku: string | null; price: { gross: number; net: number; taxPercent: number } } | null; - price: { gross: number; net: number; taxAmount: number; taxPercent: number }; - }[]; - customer: { - identifier?: string; - type?: "individual" | "organization"; - email?: string; - firstName?: string; - lastName?: string; - phone?: string; - companyName?: string; - taxNumber?: string; - addresses?: Address[]; - } | null; - meta: Record | null; -}; - -export type PaymentStatus = "paid" | "partiallyPaid" | "partiallyRefunded" | "refunded" | "unpaid"; -export type Payment = { - provider: string; // lower-case provider name: "stripe", "klarna", "two", … - method?: string; // what the shopper used: card, vipps, invoice, bank, … - transactionId: string; // the provider's id for this payment or refund - amount: number; // MAJOR units, like the cart total - createdAt?: string; - meta?: { key: string; value: string }[]; -}; - -/** Thrown when the provider should retry: answer 5xx. */ -export class RetryLater extends Error {} - -type CartState = { id: string; state: "cart" | "placed" | "ordered" | "abandoned"; customer: CartCustomer | null }; -export const readCart = async (id: string) => - (await carts.fetch(id, { state: true, customer: CUSTOMER })) as unknown as CartState | null; - -type OrderRead = { - id: string; - coreId: string | null; - payments: - | { - provider: string; - method: string | null; - transactionId: string | null; - amount: number | null; - createdAt: string | null; - meta: Record | null; // written as [{ key, value }], read back as an object - }[] - | null; -}; -const ORDER = { - coreId: true, - payments: { provider: true, method: true, transactionId: true, amount: true, createdAt: true, meta: true }, -}; -/** null while the order is not readable yet: createFromCart and payment writes persist just after answering. */ -export const readOrder = (id: string) => orders.fetch(id, ORDER).catch(() => null); - -/** The order id is the cart id. Creates it once; a later, different payment is recorded and flagged. */ -export async function createOrderOnce( - cartId: string, - paymentStatus: PaymentStatus, - payment: Payment, - pipelines?: { identifier: string; stage?: string }[], // e.g. [{ identifier: "fulfilment", stage: "new" }] -) { - const cart = await readCart(cartId); - if (cart?.state === "ordered") return addRecord(cartId, payment, true); - if (cart?.state !== "placed") throw new Error(`cart ${cartId} is ${cart?.state ?? "missing"}`); // alert a human - await ensureCustomer(cart.customer); - await orders.createFromCart(cartId, { type: "standard", paymentStatus, payments: [payment], pipelines }); -} - -const toInput = (p: NonNullable[number]): Payment => ({ - provider: p.provider, - method: p.method ?? undefined, - transactionId: p.transactionId ?? "", - amount: p.amount ?? 0, - createdAt: p.createdAt ?? undefined, - meta: Object.entries(p.meta ?? {}).map(([key, value]) => ({ key, value: String(value) })), -}); - -/** Refunds: append a record unless this transactionId is already on the order. */ -export const recordPayment = (orderId: string, payment: Payment) => addRecord(orderId, payment, false); - -async function addRecord(orderId: string, payment: Payment, isAnotherCharge: boolean) { - const order = await readOrder(orderId); - if (!order) throw new RetryLater(`order ${orderId} not readable yet`); - if (order.payments?.some((p) => p.transactionId === payment.transactionId)) return; // a redelivery - const flagged = isAnotherCharge && payment.meta?.find((m) => m.key === "type")?.value !== "refund"; - if (flagged) console.error(`[payments] second payment ${payment.transactionId} for order ${orderId}: refund it`); - const record = flagged - ? { ...payment, meta: [...(payment.meta ?? []), { key: "attention", value: "duplicate-payment" }] } - : payment; - await orders.addPayments(orderId, [record]); -} - -/** Capture, cancel: change one record and write the whole list back (setPayments replaces all). */ -export async function updatePayment(orderId: string, transactionId: string, change: (p: Payment) => Payment) { - const order = await readOrder(orderId); - if (!order?.payments) throw new RetryLater(`order ${orderId} not readable yet`); - const payments = order.payments.map(toInput).map((p) => (p.transactionId === transactionId ? change(p) : p)); - await orders.setPayments(orderId, payments); -} - -export const withMeta = (p: Payment, values: Record, amount = p.amount): Payment => ({ - ...p, - amount, - meta: [ - ...(p.meta ?? []).filter((m) => !(m.key in values)), - ...Object.entries(values).map(([key, value]) => ({ key, value })), - ], -}); - -/** The Shop API rejects null where a field is optional: send only what the cart has. */ -const defined = (o: T) => - Object.fromEntries(Object.entries(o).filter(([, v]) => v !== null && v !== undefined)) as { - [K in keyof T]?: NonNullable; - }; - -/** The docs' "create the customer in Crystallize if it does not exist yet". Guests are skipped. */ -async function ensureCustomer(customer: CartCustomer | null) { - if (!customer?.identifier || customer.isGuest) return; - const exists = await customers.fetch(customer.identifier).then( - () => true, - () => false, // fetch rejects when the customer does not exist - ); - if (exists) return; // never overwrite a known customer with checkout data - const { isGuest, addresses, ...fields } = customer; - await customers.upsert({ - ...defined(fields), - identifier: customer.identifier, - type: customer.type ?? (customer.companyName ? "organization" : "individual"), - addresses: addresses?.map((address) => ({ ...defined(address), type: address.type })), - }); -} -``` +The provider references import these helpers, so write this file first: the code is in +[references/crystallize-payments-lib.md](references/crystallize-payments-lib.md). It needs +`@crystallize/js-api-client` 7.5 or later. + +| Export | What it does | +| --------------------------- | --------------------------------------------------------------------------- | +| `api`, `carts`, `orders` | The client and the Shop API `/cart` and `/order` managers, on one token | +| `PLACED_CART`, `PlacedCart` | What a provider session needs from the placed cart: selection and type | +| `Payment`, `PaymentStatus` | The payment record and the order's `paymentStatus` | +| `RetryLater` | Thrown when the provider should retry: answer 5xx | +| `readCart`, `readOrder` | The cart's state and customer; the order's payments, `null` until readable | +| `createOrderOnce` | Customer, then `createFromCart` once; a later, different payment is flagged | +| `recordPayment` | Refunds: appends a record unless its `transactionId` is already there | +| `updatePayment`, `withMeta` | Capture, cancel: changes one record and writes the whole list back | +| `syncStages` | Copies Core's stages onto the Shop order: run it before a Shop write | +| `defined` | Drops `null` and `undefined`, which the Shop API and the client both refuse | A provider webhook route then reads: @@ -482,9 +288,10 @@ for every provider — `provider` is a free string. Use it for all of them: Crystallize stores it as a custom payment whose properties are `provider`, `transactionId`, `amount`, `method`, `createdAt` and every `meta` key — so never use those five names as meta keys. Keep meta -small: `state`, `type` (`refund` on refund records), `cartId`, and the few provider ids needed later. -The Shop API returns `meta` as an object (`{ state: "authorized" }`); `toInput` above turns it back into -the `[{ key, value }]` list the mutations take. +small: `state`, `type` (`refund` on refund records), `cartId`, and the few provider ids needed later +(the helpers add `gateway`, see [below](#the-shop-order-and-the-core-order)). +The Shop API returns `meta` as an object (`{ state: "authorized" }`) and `createdAt` as epoch milliseconds in a +string; `toInput` in the helpers turns them back into what the mutations take. ### paymentStatus @@ -507,16 +314,34 @@ Do **not** patch `paymentStatus` or payments through the Core API's `updateOrder Core write is pushed back to the Shop order as a whole and can overwrite payments you have just set through the Shop API. Keep every write to a checkout order on the Shop API `/order` endpoint. +### The Shop order and the Core order + +A checkout order lives in two copies, and a write to one can damage the other. `updatePayment` and `recordPayment` +deal with both traps below; before any other Shop write to the order (`setMeta`, `setCustomer`), call `syncStages`. + +- **A Shop write moves the Core stages.** Every `/order` mutation sends the whole Shop order to Core, and Core makes + the order's pipelines match its `pipelines` list: a stage that differs is moved back, a pipeline that is missing is + removed. That list only knows what the Shop API was told (`pipelines` at `createFromCart`, `addToStage`). Stage + moves made in the App or through the Core API do not reach it, nor do the pipelines Core picks for an order created + without `pipelines`. A bare `setPayments` after the order moved to "Shipped" therefore puts it back in its first + stage, or takes it out of the pipeline. The helpers read the stages from Core and copy them over with `addToStage` + before each payment write. +- **A Core write replaces the Shop order.** After `updateOrder` or an edit in the App's order form, the Shop order's + payments read `provider: "custom"`, and writing them back would put that on the Core record too. The helpers keep + the real name in `meta` as `gateway` and restore it. + ## Capture on shipment, from fulfilment pipelines Payment is the end of checkout and the start of the order's life. Put orders in a [fulfilment pipeline](https://crystallize.com/docs/commerce/order-management/fulfilment-pipelines) at creation (the last argument of `createOrderOnce`, passed to `createFromCart` as `pipelines`), and -let the stage drive the provider: +let the stage drive the provider. In `pipelines`, `identifier` is the pipeline **id** and `stage` the stage **id**, +both required: a name such as `fulfilment` passes the Shop API, and the order then never reaches Core. Without +`pipelines`, Core uses the pipelines the tenant set up for new orders. -1. Crystallize → Settings → Webhooks: concern **Order**, event **pipeline stage change**, POST to +1. Crystallize → Settings → Webhooks: concern **Orders**, event **Pipeline stage change**, POST to `/api/crystallize/order-stage`, **no GraphQL query**. The body is then - `{ orderId, pipelineId, stageId, tenantId, webhookId }` — `orderId` is the **Core** order id. + `{ orderId, pipelineId, stageId, tenantId, userId, webhookId }` — `orderId` is the **Core** order id. 2. Verify `X-Crystallize-Signature` with the tenant's signature secret. 3. Read the order's payment records from Core, take the `cartId` (= Shop order id), `provider` and `transactionId`, call the provider's capture (or cancel) with an idempotency key, then @@ -566,7 +391,7 @@ export async function POST(req: Request) { return new Response("ok"); } catch (error) { console.error(error); - return new Response("retry", { status: 500 }); // Crystallize retries failed webhooks + return new Response("failed", { status: 500 }); // logged as a failed invocation, not retried: see below } } ``` @@ -577,6 +402,12 @@ provider's own webhook, never before the capture has succeeded. Check how long each provider keeps an authorization alive (in its reference): made-to-order goods often ship after it expires. +**Crystallize does not redeliver a failed webhook.** A non-2xx answer, or none within 10 seconds, stays a failed +invocation until someone presses **Retry** on it (Settings → Webhooks, or `retryWebhookInvocation` on the Core API). +Unlike a provider's webhook, a 5xx here retries nothing: subscribe a second webhook to concern **Webhooks**, event +**Invocation failed**, to alert someone, or have a scheduled job re-check orders in the stage whose record is still +`authorized`. Retrying is safe: `capture` is idempotent in every reference. + ## Choosing a provider Crystallize works with any gateway; these have a reference here: @@ -616,6 +447,11 @@ provider's recurring API. - Calling a provider's old API family (eCom v2, Payments v1, `charges.data`) — each reference names the current one. - Patching `paymentStatus` or payments through Core `updateOrder` on a Shop API order. +- Pipeline or stage names in `pipelines` or `addToStage`: both take ids. +- A bare `setPayments` or `addPayments` after a stage move in the App: Core moves the order back. +- Counting on Crystallize to redeliver a stage webhook that failed. +- An explicit `undefined` in a client input (`method: undefined`, `pipelines: undefined`): the client prints it into + the query and the whole mutation fails. Leave the key out. - Long polling a provider inside one request instead of handling its webhook. ## References @@ -624,6 +460,7 @@ Each reference opens with the provider at a glance, then: credentials and setup, from the placed cart, the client, the webhook, capture/refund/cancel, the mapping, provider specifics, going further, and common mistakes. +- [references/crystallize-payments-lib.md](references/crystallize-payments-lib.md): the helpers every reference imports - [references/stripe.md](references/stripe.md) - [references/adyen.md](references/adyen.md) - [references/klarna.md](references/klarna.md) diff --git a/use-crystallize/skills/payments/references/crystallize-payments-lib.md b/use-crystallize/skills/payments/references/crystallize-payments-lib.md new file mode 100644 index 0000000..bb12a71 --- /dev/null +++ b/use-crystallize/skills/payments/references/crystallize-payments-lib.md @@ -0,0 +1,262 @@ +# `lib/crystallize-payments.ts` + +The provider references import these helpers, and [SKILL.md](../SKILL.md) explains why they are shaped this way. +Everything goes through `@crystallize/js-api-client` (7.5 or later): `createCartManager` for the Shop API `/cart`, +`createShopOrderManager` for `/order` (`createFromCart`, `addPayments`, `setPayments`) and +`createShopCustomerManager` for `/customer`. The client fetches one Shop API token for all of them. + +```ts +// lib/crystallize-payments.ts +import { + createCartManager, + createClient, + createShopCustomerManager, + createShopOrderManager, +} from "@crystallize/js-api-client"; + +export const api = createClient( + { + tenantIdentifier: process.env.CRYSTALLIZE_TENANT_IDENTIFIER!, + accessTokenId: process.env.CRYSTALLIZE_ACCESS_TOKEN_ID!, + accessTokenSecret: process.env.CRYSTALLIZE_ACCESS_TOKEN_SECRET!, + }, + { shopApiToken: { scopes: ["cart", "order", "customer"] } }, // one token for every endpoint used here +); +export const carts = createCartManager(api); +export const orders = createShopOrderManager(api); +const customers = createShopCustomerManager(api); + +// What a provider session needs from the placed cart. `price` is the line total, `variant.price` the unit. +// `type` tells product lines from external ones (`shipping`, `fee`, `promotion`, …). +const ADDRESS = { + type: true, + firstName: true, + lastName: true, + street: true, + street2: true, + streetNumber: true, + postalCode: true, + city: true, + state: true, + country: true, + phone: true, + email: true, +}; +const CUSTOMER = { + identifier: true, + isGuest: true, + type: true, + email: true, + firstName: true, + lastName: true, + phone: true, + companyName: true, + taxNumber: true, + addresses: ADDRESS, +}; +export const PLACED_CART = { + total: { gross: true, net: true, taxAmount: true, currency: true }, + items: { + lineId: true, + type: true, + name: true, + quantity: true, + variant: { sku: true, price: { gross: true, net: true, taxPercent: true } }, + price: { gross: true, net: true, taxAmount: true, taxPercent: true }, + }, + customer: CUSTOMER, + meta: true, +}; +type Address = { type: "delivery" | "billing" | "other" } & Partial< + Record, string | null> +>; +type CartCustomer = { + identifier?: string | null; + isGuest?: boolean; + type?: "individual" | "organization" | null; + email?: string | null; + firstName?: string | null; + lastName?: string | null; + phone?: string | null; + companyName?: string | null; + taxNumber?: string | null; + addresses?: Address[] | null; +}; +export type PlacedCart = { + id: string; + total: { gross: number; net: number; taxAmount: number; currency: string }; + items: { + lineId: string | null; + type: "standard" | "shipping" | "fee" | "promotion" | "service" | "digital" | string | null; + name: string; + quantity: number; + variant: { sku: string | null; price: { gross: number; net: number; taxPercent: number } } | null; + price: { gross: number; net: number; taxAmount: number; taxPercent: number }; + }[]; + customer: { + identifier?: string; + type?: "individual" | "organization"; + email?: string; + firstName?: string; + lastName?: string; + phone?: string; + companyName?: string; + taxNumber?: string; + addresses?: Address[]; + } | null; + meta: Record | null; +}; + +export type PaymentStatus = "paid" | "partiallyPaid" | "partiallyRefunded" | "refunded" | "unpaid"; +export type Payment = { + provider: string; // lower-case provider name: "stripe", "klarna", "two", … + method?: string; // what the shopper used: card, vipps, invoice, bank, … + transactionId: string; // the provider's id for this payment or refund + amount: number; // MAJOR units, like the cart total + createdAt?: string; + meta?: { key: string; value: string }[]; +}; + +/** Thrown when the provider should retry: answer 5xx. */ +export class RetryLater extends Error {} + +type CartState = { id: string; state: "cart" | "placed" | "ordered" | "abandoned"; customer: CartCustomer | null }; +export const readCart = async (id: string) => + (await carts.fetch(id, { state: true, customer: CUSTOMER })) as unknown as CartState | null; + +type OrderRead = { + id: string; + coreId: string | null; + pipelines: { identifier: string | null; stage: string | null }[] | null; + payments: + | { + provider: string; + method: string | null; + transactionId: string | null; + amount: number | null; + createdAt: string | null; + meta: Record | null; // written as [{ key, value }], read back as an object + }[] + | null; +}; +const ORDER = { + coreId: true, + pipelines: { identifier: true, stage: true }, + payments: { provider: true, method: true, transactionId: true, amount: true, createdAt: true, meta: true }, +}; +/** null while the order is not readable yet: createFromCart and payment writes persist just after answering. */ +export const readOrder = (id: string) => orders.fetch(id, ORDER).catch(() => null); + +/** + * Every Shop write sends the order's `pipelines` to Core, and Core moves the order to match. Stage moves made in the + * App or the Core API do not reach that list, so a payment write would undo them: copy Core's stages over first. + */ +export async function syncStages(order: OrderRead) { + if (!order.coreId) return; // Core has not answered yet: nothing can have moved + const { order: core } = await api.nextPimApi<{ + order: { pipelines?: { pipelineId: string; stageId: string }[] | null } | null; + }>(`query($id: ID!) { order(id: $id) { ... on Order { pipelines { pipelineId stageId } } } }`, { + id: order.coreId, + }); + for (const { pipelineId, stageId } of core?.pipelines ?? []) { + const known = (o: OrderRead | null) => + o?.pipelines?.some((p) => p.identifier === pipelineId && p.stage === stageId); + if (known(order)) continue; + await orders.addToStage(order.id, pipelineId, stageId); + for (let i = 0; !known(await readOrder(order.id)); i++) { + if (i === 9) throw new RetryLater(`order ${order.id} does not show stage ${stageId} yet`); + await new Promise((r) => setTimeout(r, 300)); // addToStage persists just after answering + } + } +} + +/** The order id is the cart id. Creates it once; a later, different payment is recorded and flagged. */ +export async function createOrderOnce( + cartId: string, + paymentStatus: PaymentStatus, + payment: Payment, + pipelines?: { identifier: string; stage: string }[], // the pipeline id and the stage id, not their names +) { + const cart = await readCart(cartId); + if (cart?.state === "ordered") return addRecord(cartId, payment, true); + if (cart?.state !== "placed") throw new Error(`cart ${cartId} is ${cart?.state ?? "missing"}`); // alert a human + await ensureCustomer(cart.customer); + const payments = [toRecord(payment)]; + await orders.createFromCart(cartId, { type: "standard", paymentStatus, payments, ...(pipelines && { pipelines }) }); +} + +/** What the mutations take. `gateway` keeps the name: a Core-side edit brings `provider` back as "custom". */ +const toRecord = (p: Payment) => defined(withMeta(p, { gateway: p.provider })); + +// `createdAt` is written as a date but read back as epoch milliseconds in a string, which the mutations refuse. +const toDate = (s: string) => new Date(/^\d+$/.test(s) ? Number(s) : s).toISOString(); + +const toInput = (p: NonNullable[number]): Payment => ({ + provider: p.meta?.gateway ?? p.provider, + method: p.method ?? undefined, + transactionId: p.transactionId ?? "", + amount: p.amount ?? 0, + createdAt: p.createdAt ? toDate(p.createdAt) : undefined, + meta: Object.entries(p.meta ?? {}).map(([key, value]) => ({ key, value: String(value) })), +}); + +/** Refunds: append a record unless this transactionId is already on the order. */ +export const recordPayment = (orderId: string, payment: Payment) => addRecord(orderId, payment, false); + +async function addRecord(orderId: string, payment: Payment, isAnotherCharge: boolean) { + const order = await readOrder(orderId); + if (!order) throw new RetryLater(`order ${orderId} not readable yet`); + if (order.payments?.some((p) => p.transactionId === payment.transactionId)) return; // a redelivery + const flagged = isAnotherCharge && payment.meta?.find((m) => m.key === "type")?.value !== "refund"; + if (flagged) console.error(`[payments] second payment ${payment.transactionId} for order ${orderId}: refund it`); + const record = flagged + ? { ...payment, meta: [...(payment.meta ?? []), { key: "attention", value: "duplicate-payment" }] } + : payment; + await syncStages(order); + await orders.addPayments(orderId, [toRecord(record)]); +} + +/** Capture, cancel: change one record and write the whole list back (setPayments replaces all). */ +export async function updatePayment(orderId: string, transactionId: string, change: (p: Payment) => Payment) { + const order = await readOrder(orderId); + if (!order?.payments) throw new RetryLater(`order ${orderId} not readable yet`); + await syncStages(order); + const payments = order.payments.map(toInput).map((p) => (p.transactionId === transactionId ? change(p) : p)); + await orders.setPayments(orderId, payments.map(toRecord)); +} + +export const withMeta = (p: Payment, values: Record, amount = p.amount): Payment => ({ + ...p, + amount, + meta: [ + ...(p.meta ?? []).filter((m) => !(m.key in values)), + ...Object.entries(values).map(([key, value]) => ({ key, value })), + ], +}); + +/** + * The Shop API rejects null where a field is optional, and the client prints `undefined` into the query, which fails + * the whole mutation: send only what you have. + */ +export const defined = (o: T) => + Object.fromEntries(Object.entries(o).filter(([, v]) => v !== null && v !== undefined)) as { + [K in keyof T]?: NonNullable; + }; + +/** The docs' "create the customer in Crystallize if it does not exist yet". Guests are skipped. */ +async function ensureCustomer(customer: CartCustomer | null) { + if (!customer?.identifier || customer.isGuest) return; + const exists = await customers.fetch(customer.identifier).then( + () => true, + () => false, // fetch rejects when the customer does not exist + ); + if (exists) return; // never overwrite a known customer with checkout data + const { isGuest, addresses, ...fields } = customer; + await customers.upsert({ + ...defined(fields), + identifier: customer.identifier, + type: customer.type ?? (customer.companyName ? "organization" : "individual"), + ...(addresses && { addresses: addresses.map((address) => ({ ...defined(address), type: address.type })) }), + }); +} +``` diff --git a/use-crystallize/skills/payments/references/mollie.md b/use-crystallize/skills/payments/references/mollie.md index 6f9093a..addab12 100644 --- a/use-crystallize/skills/payments/references/mollie.md +++ b/use-crystallize/skills/payments/references/mollie.md @@ -46,9 +46,9 @@ The crystallize.com page names one credential, the **apiKey**: 3. **Payment methods**: activate at least one, or checkout has nothing to offer. The crystallize.com page says Settings → Website profiles → Payment methods; today's Web app has it under Organization settings → Profiles → your web store → the method → Activate ([troubleshooting][troubleshoot]). -4. **Currency**: Mollie accepts ISO 4217 codes only. A Crystallize price variant's currency can be named anything (€, - Euro, EURO, EUR): name it `EUR`, since `placed.total.currency` goes to Mollie unchanged. Address countries are ISO - 3166-1 alpha-2 (`NL`). +4. **Currency**: Mollie accepts ISO 4217 codes only. `placed.total.currency` is the `context.price.currency` the + storefront hydrated the cart with (`eur` when it sends none), not the name of the price variant's currency: send + an ISO code, never `€` or `Euro`; `paymentBody` upper-cases it. Address countries are ISO 3166-1 alpha-2 (`NL`). ```bash MOLLIE_API_KEY=test_... # live_... in production @@ -135,7 +135,7 @@ export async function createMolliePayment(placed: PlacedCart): Promise { @@ -173,7 +173,7 @@ const clean = (o: Record) => // Required for Klarna, Billie, in3, Riverty, Billink and vouchers; Mollie recommends them on every payment. function mollieLines(placed: PlacedCart) { - const cur = placed.total.currency; + const cur = placed.total.currency.toUpperCase(); let sum = 0; const lines: Record[] = placed.items.map((item) => { const total = minor(item.price.gross, cur); // line total, VAT and discounts included @@ -462,7 +462,7 @@ refunded. timestamp) → `400`; trusting a replayed response's `status`; counting on the key after an hour. - `vatRate` as a number, a `vatAmount` that is not `totalAmount × rate / (100 + rate)`, lines that do not sum to `amount`, or negative `physical` / `shipping_fee` lines. -- A price variant currency named `€` or `Euro`, or country names instead of ISO alpha-2 codes. +- A cart `context.price.currency` of `€` or `Euro`, or country names instead of ISO alpha-2 codes. - Expecting Klarna or Billie to stop at `authorized` without `captureMode: 'manual'`, or calling the Orders API. - A subscription `startDate` with a time in it (it is `YYYY-MM-DD`), or before the first payment is paid. diff --git a/use-crystallize/skills/payments/references/montonio.md b/use-crystallize/skills/payments/references/montonio.md index d60fec6..2060372 100644 --- a/use-crystallize/skills/payments/references/montonio.md +++ b/use-crystallize/skills/payments/references/montonio.md @@ -102,12 +102,13 @@ export async function createMontonioOrder(placed: PlacedCart, { origin, locale, const c = placed.customer; const a = c?.addresses?.find((x) => x.type === "billing") ?? c?.addresses?.[0]; const grandTotal = round2(placed.total.gross); + const currency = placed.total.currency.toUpperCase(); // EUR or PLN const method = m.montonioMethod || "paymentInitiation"; const order = await montonio<{ uuid: string; paymentUrl: string }>("/orders", { merchantReference: placed.id, // unique per store: a second POST replaces the unpaid order, never adds one returnUrl: `${origin}/checkout/montonio/return?cart=${placed.id}`, // + &order-token=, paid or cancelled notificationUrl: `${process.env.PUBLIC_URL}/api/payments/montonio/webhook`, - currency: placed.total.currency, // EUR or PLN + currency, grandTotal, locale, // de, en, et, fi, lt, lv, pl, ru billingAddress: { @@ -128,7 +129,7 @@ export async function createMontonioOrder(placed: PlacedCart, { origin, locale, payment: { method, // paymentInitiation, cardPayments, applePay, googlePay, mobilePay, blik, bnpl, hirePurchase amount: grandTotal, // must equal grandTotal - currency: placed.total.currency, + currency, methodOptions: method === "paymentInitiation" ? { @@ -342,8 +343,10 @@ export async function POST(req: Request) { const cart = (await carts.fetch(cartId, { state: true, ...PLACED_CART })) as unknown as PlacedCart & { state: string; }; - if (cart.state !== "cart") return Response.json({ error: "placed" }, { status: 409 }); // the write would be lost - const banks = await listBanks(choice.bankCountry, cart.total.currency); + if (cart.state === "placed" || cart.state === "ordered") { + return Response.json({ error: "placed" }, { status: 409 }); // the write would be lost + } + const banks = await listBanks(choice.bankCountry, cart.total.currency.toUpperCase()); const bank = banks.find((b) => b.code === choice.bank)?.code ?? ""; const point = (await listPickupPoints(choice.carrier, choice.shipTo)).find((p) => p.id === choice.pickupPoint); const meta = { diff --git a/use-crystallize/skills/payments/references/qliro.md b/use-crystallize/skills/payments/references/qliro.md index 8d2f00b..86809ec 100644 --- a/use-crystallize/skills/payments/references/qliro.md +++ b/use-crystallize/skills/payments/references/qliro.md @@ -299,7 +299,7 @@ export async function onOrderManagementStatus(cartId: string, push: OmPush) { if (["Created", "InProcess", "OnHold"].includes(tx.Status)) return; // another push follows const id = String(tx.OrderId); const order = await readOrder(cartId); - if (!order?.payments?.some((p) => p.provider === "qliro" && p.transactionId === id)) { + if (!order?.payments?.some((p) => p.transactionId === id)) { throw new RetryLater(`order ${cartId} has no Qliro payment ${id} yet`); } if (tx.Status !== "Success") { diff --git a/use-crystallize/skills/payments/references/quickpay.md b/use-crystallize/skills/payments/references/quickpay.md index cc6c46b..f8cb6c4 100644 --- a/use-crystallize/skills/payments/references/quickpay.md +++ b/use-crystallize/skills/payments/references/quickpay.md @@ -94,7 +94,7 @@ export async function createQuickPayPayment(placed: PlacedCart, origin: string, if (payment?.accepted) return { url: returnUrl }; // already paid: just wait for the webhook payment ??= await quickpay("/payments", { method: "POST", - body: { order_id: orderId, currency: placed.total.currency, variables: { cartId: placed.id } }, + body: { order_id: orderId, currency: placed.total.currency.toUpperCase(), variables: { cartId: placed.id } }, }).catch(async (error) => (await findPayment(orderId)) ?? Promise.reject(error)); // lost the race: reuse const link = await quickpay<{ url: string }>(`/payments/${payment.id}/link`, { method: "PUT", diff --git a/use-crystallize/skills/payments/references/two.md b/use-crystallize/skills/payments/references/two.md index 4baa3db..91d06ce 100644 --- a/use-crystallize/skills/payments/references/two.md +++ b/use-crystallize/skills/payments/references/two.md @@ -63,7 +63,7 @@ placed cart. ```ts // lib/two.ts import { createHmac, timingSafeEqual } from "node:crypto"; -import { carts, createOrderOnce, PLACED_CART, readCart, recordPayment } from "@/lib/crystallize-payments"; +import { carts, createOrderOnce, defined, PLACED_CART, readCart, recordPayment } from "@/lib/crystallize-payments"; import { updatePayment, withMeta, type Payment, type PlacedCart } from "@/lib/crystallize-payments"; export async function two(path: string, init: RequestInit = {}): Promise { @@ -366,20 +366,21 @@ export async function chooseCompany(cartId: string, lookupId: string, person: Re const cart = (await carts.fetch(cartId, { state: true, ...PLACED_CART })) as unknown as PlacedCart & { state: string; }; - if (cart.state !== "cart") throw new Error(`cart ${cartId} is ${cart.state}`); // writes to a placed cart are lost + if (cart.state === "placed" || cart.state === "ordered") { + throw new Error(`cart ${cartId} is ${cart.state}`); // writes to a placed cart are lost + } const co = await two(`/companies/v2/company/${encodeURIComponent(lookupId)}`); const a = co.addresses?.find((x) => x.type === "BUSINESS_ADDRESS"); // registered address = billing address - const [street, postalCode, city, country] = [a?.street_address, a?.postal_code, a?.city, a?.country].map( - (v) => v ?? undefined, - ); + const [street, postalCode, city, country] = [a?.street_address, a?.postal_code, a?.city, a?.country]; await carts.setCustomer(cartId, { ...person, // firstName, lastName, email, phone: Two requires all four isGuest: false, // true skips the Core customer identifier: person.email, // or your signed-in customer's identifier type: "organization", companyName: co.name, - taxNumber: co.national_identifier?.id, // the organisation number - addresses: [{ type: "billing", street, postalCode, city, country }], // replaces all addresses: add delivery too + ...defined({ taxNumber: co.national_identifier?.id }), // the organisation number; no explicit undefined + // replaces all addresses: add delivery too + addresses: [{ type: "billing" as const, ...defined({ street, postalCode, city, country }) }], }); const { tax_subtotals: _, ...money } = amounts(cart); // the current cart: a pre-check, not a charge const buyer = { company: { company_canonical_id: co.canonical_id }, representative: rep(person) }; diff --git a/use-crystallize/skills/payments/references/vipps-mobilepay.md b/use-crystallize/skills/payments/references/vipps-mobilepay.md index b07b316..41cf905 100644 --- a/use-crystallize/skills/payments/references/vipps-mobilepay.md +++ b/use-crystallize/skills/payments/references/vipps-mobilepay.md @@ -131,7 +131,8 @@ export async function createVippsPayment( const existing = await getPayment(reference); if (existing) return existing; // the caller decides by its state const body = { - amount: { currency: placed.total.currency, value: Math.round(placed.total.gross * 100) }, // must match the MSN + // the currency must match the MSN + amount: { currency: placed.total.currency.toUpperCase(), value: Math.round(placed.total.gross * 100) }, paymentMethod: { type: "WALLET" }, // or "CARD" reference, userFlow, From 5bc38adf53c4137087ba7e033a1b291cceb1f33c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 15:44:29 -0700 Subject: [PATCH 04/29] fix(skills): correct mutation calls and cart, order and publish behaviour Replace cart mutations that do not exist, fix arguments and defaults, and correct what deleting, unpublishing, publishing and Core order edits do. Add the Shop API token flow and the /customer endpoint. --- use-crystallize/skills/mutation/SKILL.md | 75 ++++--- .../skills/mutation/references/core-api.md | 105 +++++---- .../skills/mutation/references/media.md | 61 ++++-- .../mutation/references/shop-api-mutations.md | 206 +++++++++++------- .../references/shop-api-order-mutations.md | 176 ++++++++------- 5 files changed, 359 insertions(+), 264 deletions(-) diff --git a/use-crystallize/skills/mutation/SKILL.md b/use-crystallize/skills/mutation/SKILL.md index 2d99ab8..8ecaa43 100644 --- a/use-crystallize/skills/mutation/SKILL.md +++ b/use-crystallize/skills/mutation/SKILL.md @@ -1,6 +1,6 @@ --- name: mutation -description: Mutate data in Crystallize - create products, documents, and folders, update item components, manage product variants and SKUs, publish/unpublish items, handle customers and orders, manage carts and checkout flows, convert cart to order, create orders from checkout or directly (POS, imports), record payments, track fulfillment pipeline stages, upload images and media, import data, delete items, and perform bulk operations. Use this skill when creating or updating any content in Crystallize, adding products to the catalogue, modifying descriptions or fields, setting up checkout, placing orders, managing stock, recording payments, tracking order fulfillment, or writing data through the Core API or Shop API. +description: Mutate data in Crystallize - create products, documents, and folders, update item components, manage product variants and SKUs, publish/unpublish items, handle customers and orders, manage carts and checkout flows, convert cart to order, create orders from checkout or directly (POS, imports), record payments, track fulfillment pipeline stages, upload images and media, and delete items. Use this skill when creating or updating any content in Crystallize, adding products to the catalogue, modifying descriptions or fields, setting up checkout, placing orders, managing stock, recording payments, tracking order fulfillment, or writing data through the Core API or Shop API. For bulk imports and tenant migrations use mass-operations; for provider checkout flows use payments. metadata: author: Crystallize version: "2.0" @@ -34,6 +34,7 @@ What do you need to do? │ └─ Delete an item → Core API: deleteItem │ ├─ Manage customers +│ ├─ From a storefront → Shop API /customer: customer (create or update), addAddress │ ├─ Create a person or a company → Core API: createCustomer (type: individual | organization) │ └─ Update customer → Core API: updateCustomer(identifier:) │ @@ -42,22 +43,22 @@ What do you need to do? │ ├─ Create order directly (POS, import) → Shop API /order: create │ ├─ Add/update payments → Shop API /order: addPayments / setPayments │ ├─ Track order through pipeline → Shop API /order: addToStage -│ └─ Update order metadata → Shop API /order: setMeta (not Core order.update — it duplicates) +│ └─ Update order metadata → Shop API /order: setMeta (not Core updateOrder — see below) │ ├─ Cart & checkout (storefront) │ ├─ Create/hydrate a cart → Shop API: hydrate -│ ├─ Modify cart items → Shop API: addItems / removeItems / setCartItem -│ ├─ Set customer & addresses → Shop API: setCustomer / setAddresses -│ └─ Convert cart to order → Shop API: cartAsOrderIntent +│ ├─ Modify cart items → Shop API: addSkuItem / addCartItem / removeCartItem +│ ├─ Set customer & addresses → Shop API: setCustomer (addresses live on the customer) +│ └─ Convert cart to order → Shop API: place, then /order createFromCart │ ├─ Media (images, video) │ ├─ Import from a supplier URL → Core API: copyRemoteAsset (images only) │ ├─ Upload your own bytes, or any video → Core API: generatePresignedUploadRequest → registerImage │ ├─ Alt text, topics, hotspots → Core API: updateImage (per language) -│ └─ Replace an image → upload a new one; registerImageRevision does not reach published items +│ └─ Replace an image → registerImageRevision, then republish the items that use it │ └─ Bulk operations - └─ Use mass operation JSON via the content-model skill's output format + └─ Use a mass operation: see the mass-operations skill ``` ## API Selection @@ -71,15 +72,16 @@ What do you need to do? **Core is not a storefront API.** It is heavily rate limited and not meant for storefront traffic: carts, orders, customers and subscription contracts belong on the Shop API, which is edge-distributed and syncs to Core asynchronously. Keep Core for the back office — imports, seeding, scheduled jobs and admin tools, behind your own server. -**Write an order in one store.** The sync runs one way, Shop → Core, and editing an order in Core adds **another copy** of it to the Shop store: three `updateOrder` calls left three Shop orders with the same `coreId`, so a storefront that sums an order list counts the money several times. If a storefront reads orders, create and change them on the Shop API (`create`, `createFromCart`, `setMeta`, `addToStage`). Details in [Shop API Order Mutations](references/shop-api-order-mutations.md). +**Write an order in one store.** A Core write comes back to the Shop store. On an order born in Core, each edit adds **another copy** of it there: three `updateOrder` calls left three Shop orders with the same `coreId`, so a storefront that sums an order list counts the money several times. On an order born on the Shop API, a Core edit overwrites the Shop order whole. If a storefront reads orders, create and change them on the Shop API (`create`, `createFromCart`, `setMeta`, `addToStage`). Details in [Shop API Order Mutations](references/shop-api-order-mutations.md). ## API Endpoints & Authentication -| API | Endpoint | Auth Required | Use For | -| ----------- | ------------------------------------------------- | ------------- | ------------------------- | -| Core | `https://api.crystallize.com/@{tenant}` | Yes | Items, shapes, customers | -| Shop /cart | `https://shop-api.crystallize.com/{tenant}/cart` | Yes (JWT) | Cart management, checkout | -| Shop /order | `https://shop-api.crystallize.com/{tenant}/order` | Yes (JWT) | Order CRUD, payments | +| API | Endpoint | Auth Required | Use For | +| -------------- | ---------------------------------------------------- | ------------- | ------------------------- | +| Core | `https://api.crystallize.com/@{tenant}` | Yes | Items, shapes, customers | +| Shop /cart | `https://shop-api.crystallize.com/{tenant}/cart` | Yes (JWT) | Cart management, checkout | +| Shop /order | `https://shop-api.crystallize.com/{tenant}/order` | Yes (JWT) | Order CRUD, payments | +| Shop /customer | `https://shop-api.crystallize.com/{tenant}/customer` | Yes (JWT) | Storefront customers | ### Core API @@ -106,14 +108,26 @@ POST https://shop-api.crystallize.com/{tenant-identifier}/cart Authorization: Bearer YOUR_JWT_TOKEN ``` -No `@` prefix for the Shop API endpoint. +The `@` is optional on the Shop API. Get the JWT from your access token, with one scope per endpoint you call: + +```bash +curl -X POST 'https://shop-api.crystallize.com/your-tenant/auth/token' \ + -H 'Content-Type: application/json' \ + -H 'X-Crystallize-Access-Token-Id: YOUR_TOKEN_ID' \ + -H 'X-Crystallize-Access-Token-Secret: YOUR_TOKEN_SECRET' \ + -d '{"scopes": ["cart", "order", "customer"], "expiresIn": 86400}' +``` + +It answers `{ "success": true, "token": "..." }`. `scopes` is required and `expiresIn` is in seconds (one day by default). + +**Call the Shop API from your server.** It allows browser calls only from Crystallize origins and localhost, so a browser call that works in development fails on your own domain. ## Common Workflow Patterns ### Create a product end-to-end -1. **Create the product** with shape and parent folder -2. **Set variants** with SKU, pricing, stock, and images +1. **Create the product** with shape, parent folder, VAT type and every required component +2. **Set variants** with SKU, pricing, stock, and images, in the same `createProduct` call: at least one is required 3. **Update components** (description, specs, media) 4. **Publish** the item @@ -139,7 +153,7 @@ See [Shop API Cart Mutations](references/shop-api-mutations.md) for steps 1-4, a ### Bulk import / mass operations -For creating many items at once, use the mass operations JSON format produced by the [content-model skill](../content-model/SKILL.md). Mass operations follow a 4-phase ordering: +For creating many items at once, use a mass operation: see the [mass-operations skill](../mass-operations/SKILL.md), and the [content-model skill](../content-model/SKILL.md) for shapes and pieces. Mass operations follow a 4-phase ordering: 1. Pieces (dependencies first) 2. Shapes @@ -152,21 +166,21 @@ The Core API uses union return types. Always include error fragments in your mut ```graphql mutation { - product { - create(input: { ... }) { - ... on Product { - id - name - } - ... on BasicError { - errorName - message - } + createProduct(language: "en", input: { ... }) { + ... on Product { + id + name + } + ... on BasicError { + errorName + message } } } ``` +The Shop API has no unions: a failure comes back in the standard GraphQL `errors` list. + Common error types: `BasicError`, `UnauthorizedError`, `ItemNotFoundError`, `OrderDoesNotBelongToTenantError`. ## Using the JS API Client @@ -189,8 +203,8 @@ await api.shopCartApi(cartMutation, variables); // /cart await api.shopOrderApi(orderMutation, variables); // /order (js-api-client 7.5+) ``` -For checkout, prefer the helpers: `createCartManager` (`hydrate`, `place`) and `createShopOrderManager` -(`createFromCart`, `addPayments`, `setPayments`, `addToStage`). +For checkout, prefer the helpers: `createCartManager` (`hydrate`, `place`), `createShopOrderManager` +(`createFromCart`, `addPayments`, `setPayments`, `addToStage`) and `createShopCustomerManager` (`upsert`, `addAddress`). ## Output Format @@ -213,4 +227,5 @@ If the user is working in a JS/TS project, prefer generating code using `@crysta [[query]] covers reads across the same APIs. For bulk writes that would otherwise hit rate limits, use [[mass-operations]]. Vector ranking has its own Core API mutations — `upsertVocabulary`, `setItemTaste` -and `igniteDiscoApi` — documented in [[vector-ranking]]. +and `igniteDiscoApi` — documented in [[vector-ranking]]. Taking payment is in [[payments]], bookings in +[[bookable-resources]] and subscription contracts in [[subscriptions]]. diff --git a/use-crystallize/skills/mutation/references/core-api.md b/use-crystallize/skills/mutation/references/core-api.md index 121b3ea..2f2dd24 100644 --- a/use-crystallize/skills/mutation/references/core-api.md +++ b/use-crystallize/skills/mutation/references/core-api.md @@ -9,7 +9,7 @@ Two things decide the shape of every call on this page: - **Mutations are top level.** `createProduct`, `publishItem`, `updateComponent` — there is no `product { … }` or `item { … }` wrapper. That wrapper belongs to the legacy PIM API, which is a different endpoint with a different schema. -- **The tenant is in the URL** (`https://api.crystallize.com/@/core`), so no input takes a `tenantId`. +- **The tenant is in the URL** (`https://api.crystallize.com/@`), so no input takes a `tenantId`. The PIM API does, which is the quickest way to tell a Core example from a PIM one. ## Table of Contents @@ -80,7 +80,8 @@ would only repeat itself. Add it everywhere in real code. ### Create Product `variants` and `vatTypeId` are required — a product cannot exist without at least one SKU and a VAT type. Read -the VAT types from the PIM API (see [below](#only-in-the-legacy-pim-api)) and keep the id in your config. +the VAT types from the PIM API (see [below](#only-in-the-legacy-pim-api)) and keep the id in your config. Exactly +one variant must have `isDefault: true`, or the call fails. ```graphql mutation CreateProduct($input: CreateProductInput!, $language: String!) { @@ -178,6 +179,10 @@ mutation CreateFolder($input: CreateFolderInput!, $language: String!) { Publishing makes the item visible on the storefront. Creating an item does NOT publish it. +**Publishing runs in the background.** The answer means the request was accepted, not that the published +version exists yet, even though `PublishInfo.versionId` already names it. A step that depends on the published +version has to read it first: `item(id:, language:, versionLabel: published)`. + ```graphql mutation PublishItem { publishItem(id: "", language: "en", includeDescendants: false) { @@ -197,15 +202,20 @@ mutation PublishItem { `disableComponentValidation: true` publishes an item whose shape validation would otherwise block it — an empty numeric piece is the usual reason. -`publishItems(ids: [ID!]!, language: String!)` exists for batches, but it answers with a `PublishItemsRequest` -whose return is not proof that the items are published yet. Prefer `publishItem` per item when the next step -depends on the published version. +`publishItems(ids: [ID!]!, language: String!)` exists for batches of up to 50 ids (more answers +`TooManyItemsProvidedError`). It takes the same two flags and answers with a `PublishItemsRequest`, +`{ language, success { itemId }, failure { itemId, error } }`, so read `failure`: one item failing validation +does not fail the call. `unpublishItems` is the same. ### Unpublish Item +**Unpublishing always takes the children with it.** `includeDescendants` defaults to `true` here, unlike on +`publishItem`, and `false` is ignored: everything under the item goes off the storefront. Move a child out first +to keep it live. + ```graphql mutation UnpublishItem { - unpublishItem(id: "", language: "en", includeDescendants: false) { + unpublishItem(id: "", language: "en") { ... on PublishInfo { id } @@ -233,8 +243,10 @@ mutation DeleteItem { } ``` -The argument is `itemId`, and the answer is a count, not the item. Deleting a folder with children fails until -the children are moved or deleted. +The argument is `itemId`, and the answer is a count, not the item. + +**Deleting a folder deletes everything under it.** There is no check and no prompt: the children go in the +background, after the answer, and `removed` counts only the item you named. Move what you want to keep first. ### Move Item in Tree @@ -317,15 +329,19 @@ mutation UpdateComponent($itemId: ID!, $language: String!, $component: Component Arguments worth knowing: -| Argument | Meaning | -| -------------------------- | -------------------------------------------------------------------------- | -| `itemId` | The item to edit | -| `sku` | Edit a **variant's** component instead — pass the SKU rather than `itemId` | -| `language` | Required; components are per language unless the shape says otherwise | -| `disableContentValidation` | Write content the shape would otherwise reject | +| Argument | Meaning | +| -------------------------- | --------------------------------------------------------------------------- | +| `itemId` | The item to edit | +| `sku` | Edit a **variant's** component instead — pass the SKU rather than `itemId` | +| `language` | Required; components are per language unless the shape says otherwise | +| `disableContentValidation` | Defaults to **`true`**: pass `false` to have the shape validate the content | + +With the default, `updateComponent` stores content the shape would reject and the problem only surfaces on +publish. `ComponentContentValidationFailedError` needs `disableContentValidation: false`. `ComponentInput` is `{ componentId, }`. The content key names the component type, and the -examples below differ only in that key. `removeComponent(itemId:, language:, componentId:)` clears one. +examples below differ only in that key. `removeComponent(itemId:, language:, componentId:)` clears one; it also +takes `sku` for a variant component, and `itemId` stays required. ### Rich Text @@ -431,14 +447,14 @@ The other content keys on `ComponentInput` follow the same pattern: `datetime`, Variants are purchasable SKUs on a product. There is no `setVariants` on Core: variants are added, updated and deleted one at a time, and stock and price have their own mutations. -| Task | Mutation | -| --------------------- | ----------------------------------------------------------------------------------------- | -| Add a variant | `addProductVariant(productId:, language:, input: CreateProductVariantInput!)` | -| Change one variant | `updateProductVariant(sku:, language:, input: UpdateSingleProductVariantInput!)` | -| Delete one | `deleteProductVariant(sku:)` — refuses the default with `CannotDeleteDefaultVariantError` | -| Replace the whole set | `updateProduct(id:, language:, input: { variants: [...] })` | -| Stock | `modifyProductVariantStock(sku:, stockLocationIdentifier:, operation:, quantity:)` | -| Price | `modifyProductVariantPrice(sku:, priceVariantIdentifier:, price:, tiers:, tierType:)` | +| Task | Mutation | +| --------------------- | ----------------------------------------------------------------------------------------------------- | +| Add a variant | `addProductVariant(productId:, language:, input: CreateProductVariantInput!)` | +| Change one variant | `updateProductVariant(sku:, language:, input: UpdateSingleProductVariantInput!)`, no `isDefault` here | +| Delete one | `deleteProductVariant(sku:)` — refuses the default with `CannotDeleteDefaultVariantError` | +| Replace the whole set | `updateProduct(id:, language:, input: { variants: [...] })` | +| Stock | `modifyProductVariantStock(sku:, stockLocationIdentifier:, operation:, quantity:)` | +| Price | `modifyProductVariantPrice(sku:, priceVariantIdentifier:, price:, tiers:, tierType:)` | ### Add a Variant @@ -513,7 +529,8 @@ Use consistent attribute names across products so the filters line up. ## Customer Mutations One mutation creates both kinds of customer: `type` is `individual` or `organization`. There is no -`createIndividual` or `createOrganization` on Core. +`createIndividual` or `createOrganization` on Core. From a storefront, use the Shop API `/customer` endpoint +instead, see [Customer Creation](shop-api-mutations.md#customer-creation). ### Create a Customer @@ -603,13 +620,14 @@ call; orders are deleted separately. As with items, a list you send replaces the ## Order Mutations -> **If a storefront lists these orders, change them on the Shop API instead.** Every `updateOrder` -> in Core adds **another copy** of the order to the Shop store: three updates on one order left three -> Shop orders with new ids, the same `coreId` and different `updatedAt`, and a storefront summing that -> list counted the money three times. `updateOrderPipelineStage` and `deleteOrder` in Core, by contrast, -> never reach the Shop store at all. Use Shop `/order` `setMeta` and `addToStage` for anything a -> storefront reads, and keep Core order writes for back-office work on orders nobody lists from the edge. -> See [Shop API Order Mutations](shop-api-order-mutations.md). +> **If a storefront lists these orders, change them on the Shop API instead.** On an order born in Core +> (`registerOrder`), every `updateOrder` or `updateOrderPipelineStage` adds **another copy** of the order to +> the Shop store: three updates on one order left three Shop orders with new ids, the same `coreId` and +> different `updatedAt`, and a storefront summing that list counted the money three times. On an order born +> on the Shop API, the same calls rewrite the Shop order with Core's whole version, over whatever the Shop +> wrote last. Use Shop `/order` `setMeta` and `addToStage` for anything a storefront reads, and keep Core +> order writes for back-office work on orders nobody lists from the edge. +> See [Shop API Order Mutations](shop-api-order-mutations.md#the-order-store-and-core). ### Update Order @@ -639,7 +657,8 @@ mutation UpdateOrder($id: ID!, $input: UpdateOrderInput!) { `deleteOrderMetadata(id:, key:)` removes one. `registerOrder(input: RegisterOrderInput!)` writes an order that did not come from a cart — a renewal invoice, a -POS sale, an import. Before using it, read what it does to an order a storefront lists, in +POS sale, an import. It answers `{ id, createdAt }` before the order is stored, so a call right behind it can +get `OrderNotFoundError`. Before using it, read what it does to an order a storefront lists, in [shop-api-order-mutations.md](shop-api-order-mutations.md). --- @@ -653,13 +672,13 @@ variants. Six mutations cover it: | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `copyRemoteAsset(sourceUrl:, type: image, targetFilename:, requestHeaders:)` | Crystallize fetches the URL. A **bulk task**: it returns `targetKey` before the file lands, and it registers the image for you | | `generatePresignedUploadRequest(filename:, contentType:, type: MEDIA)` | A target to POST your own bytes to — the only path for video | -| `registerImage(imageKey:)` | Registers an uploaded key. Not needed after `copyRemoteAsset` | -| `registerImageRevision(imageKey:, revisionKey:)` | Repoints the library entry — it does **not** reach published items | +| `registerImage(imageKey:)` | Registers an uploaded key. Takes nothing else. Not needed after `copyRemoteAsset` | +| `registerImageRevision(imageKey:, revisionKey:)` | Repoints the library entry — Discovery follows only once the items using it are indexed again | | `updateImage(key:, language:, input:)` | `altText`, `caption`, `focalPoint`, `meta`, `topicIds`, `showcase` — per language | | `deleteImage(key:, force:)` | Refuses while any item version references it; `force: true` overrides | An import that follows these six in the obvious order still breaks in four different ways — keys whose -file never arrives, renditions that are not ready, revisions that never reach the storefront, and +file never arrives, renditions that are not ready, revisions the storefront does not show yet, and deletes that refuse. **See [Media & Images](media.md)** for the working pipeline, what to verify after an import, and how to replace an image later. @@ -706,7 +725,7 @@ Discovery's vector ranking is authored entirely on the Core API. Four calls, in | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `upsertVocabulary(input: UpsertVocabularyInput!)` | **Full replace**, not a patch — omitted dimensions are dropped | | `setItemTaste(input: SetItemTasteInput!)` | One item, one language, one vocabulary. Writes the **draft** | -| `publishItem(id: ID!, language: String!)` | The indexer reads the published version — skipping this fails silently. See the note on `publishItems` below | +| `publishItem(id: ID!, language: String!)` | The indexer reads the published version — skipping this fails silently. See the note on publishing below | | `igniteDiscoApi(stacks: opensearch)` | Async; poll `bulkTask(id:)` until `complete`, then allow propagation. `stacks: opensearch` is required for vectors to be built | ```graphql @@ -751,16 +770,16 @@ mutation Index { } ``` -All three results are unions whose error members implement `BasicError`, so a single fragment covers -every failure and `errorName` identifies it. `setItemTaste` and `igniteDiscoApi` can both return +`setItemTaste` and `igniteDiscoApi` return unions whose error members implement `BasicError`, so a single +fragment covers every failure and `errorName` identifies it. `upsertVocabulary` returns the `Vocabulary` +directly and fails as a GraphQL error. `setItemTaste` and `igniteDiscoApi` can both return `ExperimentalFeaturesNotAvailableError`, which means vectors are not enabled for the tenant. Read back with `vocabulary(name:)` and `item(id:, language:) { taste { vocabulary entries { key weight } } }`. -Prefer `publishItem` per item over `publishItems` here. `publishItem` returns the published version -(`PublishInfo`) or an error for that item; `publishItems` returns a `PublishItemsRequest`, and its return -is not proof that the items are published yet — index right after it and the index may read the old -versions. +`publishItem` and `publishItems` both run in the background, so neither return is proof that the items are +published yet — index right after them and the index may read the old versions. Wait for +`item(id:, language:, versionLabel: published)` to show the new content first. **Re-run `igniteDiscoApi` after every change to vocabularies or taste entries** — an unindexed change has no effect and raises no error. Run it **last**, once shapes and published content exist. A task @@ -796,7 +815,7 @@ The Core API uses union return types. Always handle potential errors: ```graphql mutation UpdateComponent($itemId: ID!, $language: String!, $component: ComponentInput!) { - updateComponent(itemId: $itemId, language: $language, component: $component) { + updateComponent(itemId: $itemId, language: $language, component: $component, disableContentValidation: false) { __typename ... on UpdatedComponent { updatedComponentPath diff --git a/use-crystallize/skills/mutation/references/media.md b/use-crystallize/skills/mutation/references/media.md index c187c8d..ce54407 100644 --- a/use-crystallize/skills/mutation/references/media.md +++ b/use-crystallize/skills/mutation/references/media.md @@ -37,18 +37,24 @@ mutation Copy($sourceUrl: String!, $filename: String) { is there for sources that will not serve a bare request. **It is a bulk task, so the key comes back before the file does.** `targetKey` is handed to you -immediately; the copy happens afterwards. Three consequences, all found the hard way: +immediately; the copy happens afterwards. Four consequences, all found the hard way: + +- **A failed copy only shows on the task.** `bulkTask(id:) { ... on BulkTask { status info } }` ends in + `error`, with the reason in `info.error`. The source has to answer 2xx with an uncompressed body (a + `content-encoding` other than `identity` is refused), a `content-length` within the upload limit, and a + `content-type` of exactly `image/jpeg`, `jpg`, `png`, `webp`, `tiff`, `tif` or `avif`, which is how a + CDN that serves a browser can still fail here. - **Do not call `registerImage` afterwards.** `copyRemoteAsset` already registers the image, and the second call fails. (Tools Universe.) - **`updateImage` on a fresh key answers `ImageNotFoundError` for a while** — WebP sources seemed slowest. Retry a "not found" after a delay, or wait for **every** copy task rather than the last few you happen to hold (`bulkTask(id:)` until it leaves `pending`/`started`). (Tools Universe.) -- **A key can be registered whose file never arrives, with no error anywhere.** Two builds, two +- **A key can be registered whose file never arrives, with no error on the mutation.** Two builds, two suppliers: 91 WebP sources (ZF, Brembo) got a key and a library entry while the file behind it 404s (Car Parts Universe), and every image from one fashion brand did the same because that CDN serves a normal browser but not Crystallize's fetcher (Fashion Universe). The library shows broken tiles, items - show broken images, and nothing reports it. + show broken images, and nothing reports it unless you read the tasks. So after the copy tasks finish, **HEAD-check the keys** — all of them if the import is small, a sample if it is not. For the ones that 404: try `requestHeaders` first if you suspect the CDN is refusing the @@ -107,18 +113,24 @@ mutation Register($imageKey: String!) { } ``` -Unlike the copy path, this one **does** need `registerImage`. +Unlike the copy path, this one **does** need `registerImage`, right after the POST: it makes the key usable +at once. The upload also registers itself in the background, so a late call can fail because the image +already exists, which is harmless. In JS, `createBinaryFileManager(client).uploadImage(path)` does all three +steps, and needs `tenantId` in the client config. ### Video The upload path is the only way in for video, since `copyRemoteAsset` is images only: download the file, `generatePresignedUploadRequest(contentType: "video/mp4" | "video/quicktime", type: MEDIA)`, POST the form, then write `{ key, title }` into a `videos` component or a variant's videos. There is no -`registerVideo` step — the video is registered on first use, and transcoded to HLS and DASH +`registerVideo` step — the upload registers the video, and it is transcoded to HLS and DASH (`playlists`) within about a minute. MP4 and MOV upload as they are; an HLS-only source has to be remuxed first (`ffmpeg -i x.m3u8 -c copy`). A `videos` component's `max` is enforced on create (`Cannot provide more than 2 videos for component hero-video`). (Fashion Universe.) +Files work the same way: upload with `type: STATIC`, then write `{ key, title }` into a `files` component. +There is no register step for them either. + ## Renditions arrive later, and the storefront has to cope `Image.variants` is the generated ladder that [[responsive-images]] builds a `srcset` from, and it is @@ -135,13 +147,17 @@ empty list it indexed. `registerImageRevision(imageKey:, revisionKey:)` points the library entry at a new file — Core `image(key)` returns the new `url` and `size`, and the ladder rebuilds in about 25 seconds. -**It does not reach items that are already published.** Discovery kept serving the original file and its -renditions for every item using the image, even after rewriting the image component and republishing. -(Tools Universe, replacing 98 letterboxed manufacturer photos.) +`revisionKey` is a file you have just uploaded (`generatePresignedUploadRequest` → POST). Call it right after +the POST and skip `registerImage`: once the upload has registered itself as an image, the revision is refused. + +**It reaches the read APIs at different times.** The Catalogue API serves the latest revision at once. +Discovery stores the file with each indexed document, so every item using the image keeps the original file +and renditions until that item is indexed again, and a revision indexes nothing by itself. -So to replace an image in practice: upload the new file as a **new** image -(`generatePresignedUploadRequest` → POST → `registerImage`) and write the new key onto the items. Treat -`registerImageRevision` as a library-level correction, not a way to change what shoppers see. +So to replace an image in practice: register the revision, wait for the ladder, then **republish the items +that use it** (or re-run `igniteDiscoApi`). Uploading the new file as a **new** image +(`generatePresignedUploadRequest` → POST → `registerImage`) and writing the new key onto the items works too, +and needs the same publish. ## Metadata, topics and showcases @@ -165,8 +181,9 @@ the required `language`: an image's topics and alt text are **per language**, so language leaves the others untagged. **Showcases are hotspots on the image — the whole "shop the look" feature, and in no skill until now.** -Core takes `showcase: [{ hotspot: { x, y }, itemIds, skus, meta }]`, several per image, on -`registerImage`'s image input and on `updateImage`. Discovery exposes them on the image as +Core takes `showcase: [{ hotspot: { x, y }, itemIds, skus, meta }]`, several per image, on `ImageInput` +(the `images` component and variant images) and on `updateImage`, and `skus` is required, so send `[]` for +a showcase of items only. `registerImage` takes only the key. Discovery exposes them on the image as **`showcases`** — plural, type `Showcase`, with `hotspot` as a `Hash` `{ x, y }` plus `items` (the related documents), `variants` and `meta`. Verified against a live tenant; the naming difference between input (`showcase`) and output (`showcases`) is easy to trip over. (Fashion Universe.) @@ -195,12 +212,12 @@ yourself before forcing. (Car Parts Universe.) ## Failure modes -| Symptom | Cause | Fix | -| ----------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------- | -| Broken tiles in the library, broken images on items | `copyRemoteAsset` gave a key but the copy never landed | HEAD-check the keys; re-import those with `requestHeaders`, or upload | -| `ImageNotFoundError` from `updateImage` right after a copy | The key exists before the file does | Wait for every copy task, and retry "not found" | -| `registerImage` fails on a copied key | `copyRemoteAsset` already registered it | Don't register a copied image | -| `variants: []` in Discovery, `width: null` in Core | Renditions are still queued | Fall back to `url`; publish and re-index once they exist | -| A replaced image still shows the old file on the storefront | `registerImageRevision` does not reach published items | Upload a new image and write its key onto the items | -| `AssetInUseError` on an image nothing current uses | Old item versions still reference it | `deleteImage(force: true)` after checking current versions | -| `copyRemoteAsset` rejects a video URL | `RemoteAssetType` is `image` only | Use the presigned upload path | +| Symptom | Cause | Fix | +| ----------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| Broken tiles in the library, broken images on items | `copyRemoteAsset` gave a key but the copy never landed | Read the task's `info.error`, HEAD-check the keys; re-import those with `requestHeaders`, or upload | +| `ImageNotFoundError` from `updateImage` right after a copy | The key exists before the file does | Wait for every copy task, and retry "not found" | +| `registerImage` fails on a copied key | `copyRemoteAsset` already registered it | Don't register a copied image | +| `variants: []` in Discovery, `width: null` in Core | Renditions are still queued | Fall back to `url`; publish and re-index once they exist | +| A replaced image still shows the old file on the storefront | Discovery keeps the file it indexed | Republish the items that use the image | +| `AssetInUseError` on an image nothing current uses | Old item versions still reference it | `deleteImage(force: true)` after checking current versions | +| `copyRemoteAsset` rejects a video URL | `RemoteAssetType` is `image` only | Use the presigned upload path | diff --git a/use-crystallize/skills/mutation/references/shop-api-mutations.md b/use-crystallize/skills/mutation/references/shop-api-mutations.md index 053596e..bc8fd03 100644 --- a/use-crystallize/skills/mutation/references/shop-api-mutations.md +++ b/use-crystallize/skills/mutation/references/shop-api-mutations.md @@ -10,16 +10,23 @@ Carts follow a state machine that controls what operations are allowed: ``` cart → placed → ordered - ↘ - abandoned + ↕ ↗ +abandoned ``` -| State | Description | Mutable? | -| ----------- | ----------------------------------------------- | -------- | -| `cart` | Active cart, items and prices can be changed | Yes | -| `placed` | Locked for payment — no modifications allowed | No | -| `ordered` | Linked to an order via `orderId` | No | -| `abandoned` | Explicitly abandoned (e.g., user left checkout) | No | +| State | Description | Mutable? | +| ----------- | --------------------------------------------- | ----------------------- | +| `cart` | Active cart, items and prices can be changed | Yes | +| `placed` | Locked for payment — no modifications allowed | No | +| `ordered` | Linked to an order via `orderId` | No | +| `abandoned` | Explicitly abandoned, or left to go stale | Yes: a write revives it | + +`abandon` works only from `cart`, so a placed cart cannot be abandoned: its only way forward is `ordered`. +An abandoned cart goes back to `cart` on the next `hydrate` or write, and can be placed directly. + +**A write on a placed or ordered cart can look like it worked.** `hydrate` refuses a placed cart and serves +an ordered one read-only, but `addSkuItem`, `setCustomer`, `setMeta` and the other item mutations answer with +the changed cart while nothing is stored. Check `state` before you write. Query the cart state with: @@ -28,13 +35,16 @@ query { cart(id: "cart-id") { id state # cart | placed | ordered | abandoned - isStale # true if prices may have changed since last hydration - isExpired # true if cart has expired + isStale # true once staleAt has passed: no write for context.staleTtl + isExpired # true once expiresAt has passed: no write for context.ttl orderId # set when an order is created from the cart } } ``` +Stale is about time, not prices. Every write pushes `staleAt` forward by `context.staleTtl` (1 hour by +default), and a stale cart turns `abandoned` within about five minutes. Wishlists never go stale or expire. + ## Cart Hydration The primary mutation for creating and updating carts. Hydration: @@ -44,6 +54,14 @@ The primary mutation for creating and updating carts. Hydration: 3. Calculates prices, taxes, and totals 4. Returns a fully constructed cart +**Hydrating an existing `id` replaces the cart, it does not merge.** `items`, `externalItems`, `customer`, +`meta` and `context` are all taken from this input, so anything you leave out is dropped, the customer and +the price context included. Resend the whole cart, or use the targeted mutations below. An `id` that does +not exist creates a new cart with a new id. + +**A SKU the catalogue does not return is dropped with no error**: an unknown SKU, an unpublished product, +or a language it is not published in. Compare the `items` you get back with the ones you sent. + ### Basic Hydration ```graphql @@ -56,9 +74,11 @@ mutation { isStale isExpired items { - sku name quantity + variant { + sku + } price { gross net @@ -74,7 +94,7 @@ mutation { ### Hydration with Customer and Context -The hydrate input accepts customer info inline — this is the recommended way to associate a customer during checkout. It also accepts item `type` and `group` for categorizing line items. +The hydrate input accepts customer info inline — this is the recommended way to associate a customer during checkout. `isGuest` is required on `CustomerInput`. It also accepts item `type` and `group` for categorizing line items. ```graphql mutation { @@ -171,6 +191,7 @@ mutation { | ---------- | ----------------- | -------- | ---------------------------------------------------------------------------------------------------------- | | `sku` | `String!` | Yes | Product variant SKU | | `quantity` | `PositiveInt` | No | Quantity (default: 1) | +| `lineId` | `String` | No | Selects an existing line, needed only when several lines share a SKU. Read it from `items { lineId }` | | `type` | `CartItemType` | No | `standard`, `subscription`, `shipping`, `fee`, `promotion`, `refund`, `service`, `digital`, `bonus`, `tax` | | `group` | `String` | No | Free-form group label (e.g., "Outdoor", "Plants") | | `taxRate` | `Rate` | No | Override tax rate for this item | @@ -178,19 +199,24 @@ mutation { ### Context Options -| Field | Description | -| -------------------------------------- | -------------------------------------------------- | -| `language` | Locale for product names (default: "en") | -| `selectedVariantIdentifier` | Price variant to use as active price | -| `compareAtVariantIdentifier` | Price variant for discount comparison | -| `decimals` | Decimal precision (default: 0, recommended: 4) | -| `pricesHaveTaxesIncludedInCrystallize` | Tax handling: `true` for B2C, `false` for B2B | -| `taxRate` | Override tax rate | -| `markets` | Array of markets for price lists | -| `currency` | Display currency (must match price variant config) | -| `customerGroup` | Customer group for pricing | -| `fallbackVariantIdentifiers` | Fallback price variants if selected is missing | -| `voucherCode` | Apply voucher code | +`context` holds `language`, `ttl`, `staleTtl` and a nested `price` object: + +| Field | Description | +| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `language` | Locale for product names (default: "en") | +| `ttl` | Seconds until the cart expires, reset by each write (default: 30 days) | +| `staleTtl` | Seconds without a write before the cart goes stale (default: 1 hour) | +| `price.selectedVariantIdentifier` | Price variant to use as active price (default: `default`) | +| `price.compareAtVariantIdentifier` | Price variant for discount comparison | +| `price.fallbackVariantIdentifiers` | Tried in order when the selected variant has no price. If none has one, the SKU's first price variant is used, so a wrong identifier prices the line instead of failing | +| `price.decimals` | Decimal precision (default: 0, recommended: 4) | +| `price.pricesHaveTaxesIncludedInCrystallize` | Default `false`. Tax handling: `true` for B2C, `false` for B2B | +| `price.discountOnNetPrices` | Apply discounts on the net price (default: `false`) | +| `price.taxRate` | Override tax rate, between 0 and 1 | +| `price.markets` | Array of markets for price lists | +| `price.currency` | Label stamped on the prices (default: `eur`). It converts nothing and nothing checks it against the price variant, so pass that variant's currency | +| `price.customerGroups` | Customer groups for pricing (`customerGroup` is deprecated) | +| `price.voucherCodes` | Apply voucher codes (`voucherCode` is deprecated) | ### Hydration with External Items @@ -217,9 +243,10 @@ mutation { ) { id items { - sku name - origin + variant { + sku + } } } } @@ -229,7 +256,7 @@ mutation { ### Add SKU Item -Add a single SKU item to an existing cart. If the item already exists and is managed, only the quantity is updated. +Add a single SKU item to an existing cart. If the SKU is already in the cart, `quantity` is **added** to that line. It does not take a `booking`: use `bookSkuItem`, see [[bookable-resources]]. ```graphql mutation { @@ -248,7 +275,7 @@ mutation { ### Add External Item -Add a fully custom item not in Crystallize (e.g., shipping, service fees). +Add a fully custom item not in Crystallize (e.g., shipping, service fees). `name` and `variant` are required, and `variant.price` is the unit price. If the SKU is already an external line, `quantity` is added to it. ```graphql mutation { @@ -258,8 +285,8 @@ mutation { sku: "shipping-standard" name: "Standard Shipping" quantity: 1 - price: { gross: 9.99, net: 7.99 } type: shipping + variant: { price: { gross: 9.99, net: 7.99 }, product: { id: "shipping-product", path: "/shipping" } } } ) { id @@ -279,9 +306,11 @@ mutation { ### Remove Items +`quantity` defaults to 1, so leaving it out takes **one unit off** the line. The line goes when it reaches 0: pass its full quantity to remove it. + ```graphql mutation { - removeCartItem(id: "cart-id", sku: "product-to-remove") { + removeCartItem(id: "cart-id", sku: "product-to-remove", quantity: 3) { id items { variant { @@ -295,13 +324,17 @@ mutation { ### Update Item Quantity +`addCartItem` adds to a line already in the cart and `removeCartItem` takes from it, both by a delta. A SKU that is not in the cart is ignored: use `addSkuItem` for that. + ```graphql mutation { - setCartItem(id: "cart-id", sku: "product-sku", quantity: 5) { + addCartItem(id: "cart-id", sku: "product-sku", quantity: 2) { id items { - sku quantity + variant { + sku + } } } } @@ -309,15 +342,17 @@ mutation { ### Change Item Pricing -Override managed pricing (makes item unmanaged): +Override managed pricing (makes item unmanaged). `price` is the unit price, and `input` also takes `quantity`, which is how to set an absolute quantity. A SKU that is not in the cart is ignored. ```graphql mutation { - changeCartItemPricing(id: "cart-id", sku: "product-sku", price: { gross: 49.99, net: 39.99 }) { + changeCartItemPricing(id: "cart-id", input: { sku: "product-sku", price: { gross: 49.99, net: 39.99 } }) { id items { - sku - managed + context # HashMap: managed, origin, taxRate + variant { + sku + } price { gross net @@ -327,15 +362,21 @@ mutation { } ``` +An unmanaged line keeps its price through later hydrations, only its quantity moves. + +`setCartItem(id:, input:)` is the full form: it takes the same `CartItemInput` as `addExternalItem`, so `name` and `variant` are required, then adds or replaces the line and flags it unmanaged. + ## Customer Information ### Set Customer on Cart +The argument is `input` here, and `customer` on the `/order` endpoint's `setCustomer`. `isGuest` is required, and the customer you send replaces the one on the cart. + ```graphql mutation { setCustomer( id: "cart-id" - customer: { firstName: "John", lastName: "Doe", email: "john@example.com", phone: "+1234567890" } + input: { isGuest: false, firstName: "John", lastName: "Doe", email: "john@example.com", phone: "+1234567890" } ) { id customer { @@ -349,52 +390,62 @@ mutation { ### Set Addresses +Addresses live on the customer, so they go in `customer.addresses` with a `type` of `billing`, `delivery` or `other`: + ```graphql mutation { - setAddresses( + setCustomer( id: "cart-id" - billing: { street: "123 Main St", city: "New York", postalCode: "10001", country: "US" } - delivery: { street: "456 Oak Ave", city: "Brooklyn", postalCode: "11201", country: "US" } + input: { + isGuest: false + firstName: "John" + addresses: [ + { type: billing, street: "123 Main St", city: "New York", postalCode: "10001", country: "US" } + { type: delivery, street: "456 Oak Ave", city: "Brooklyn", postalCode: "11201", country: "US" } + ] + } ) { id } } ``` -## Cart to Order Intent +### Set Cart Metadata -Get cart formatted for order creation: +`merge` defaults to `true`, and `false` replaces all the meta. ```graphql mutation { - cartAsOrderIntent(id: "cart-id") { - customer { - firstName - lastName - email - } + setMeta(id: "cart-id", meta: [{ key: "campaign", value: "summer" }], merge: true) { + id + meta + } +} +``` + +## Cart to Order Intent + +`createFromCart` is the normal way to get an order from a cart. Use `orderIntent`, a **query** on `/cart`, when you create the order yourself: it works on a placed cart only, and you close the cart with `fulfill` afterwards. + +```graphql +query { + orderIntent(id: "cart-id", format: shop) { + order # HashMap: the order input cart { - sku - name - quantity - price { - gross - net - } - } - total { - gross - net + id + state } } } ``` +`format` is `legacy` (the default), `core`, `coreWithOrderV2` or `shop`. Use `shop` for the `/order` `create` mutation. + ## Cart Lifecycle ### Place Cart -Placing a cart locks it and makes it **immutable**. This should happen when the user enters checkout (e.g., before collecting payment). After placing, items, quantities, and prices cannot be changed. +Placing a cart locks it and makes it **immutable**. This should happen when the user enters checkout (e.g., before collecting payment). After placing, items, quantities, and prices cannot be changed. `place` hydrates the cart one last time first, so charge the `total` it returns, not the one you showed earlier. ```graphql mutation { @@ -418,7 +469,7 @@ mutation { ### Fulfill Cart -**Not needed after `createFromCart`.** [`createFromCart`](shop-api-order-mutations.md) on the `/order` endpoint already moves the cart to `ordered` and sets `orderId`, and the order id is the cart id. Use `fulfill` to link a cart to an order created some other way: +**Not needed after `createFromCart`.** [`createFromCart`](shop-api-order-mutations.md) on the `/order` endpoint already moves the cart to `ordered` and sets `orderId`, and the order id is the cart id. Use `fulfill` to link a placed cart to an order created some other way: ```graphql mutation { @@ -432,7 +483,7 @@ mutation { ### Abandon Cart -Explicitly mark a cart as abandoned (e.g., user left checkout, session timeout): +Explicitly mark a cart as abandoned (e.g., user left checkout, session timeout). This fails on a placed or ordered cart. ```graphql mutation { @@ -445,7 +496,7 @@ mutation { ### Mark as Wishlist -Convert a cart to a non-expiring wishlist: +Convert a cart in state `cart` to a non-expiring wishlist: ```graphql mutation { @@ -459,22 +510,15 @@ mutation { ### Set Cart Expiration -```graphql -mutation { - setCartExpiration(id: "cart-id", expiresAt: "2024-12-31T23:59:59Z") { - id - expiresAt - } -} -``` +There is no expiration mutation. Pass `context.ttl` and `context.staleTtl`, in seconds, to `hydrate`. ### Remove Cart +`remove` returns a Boolean, so it takes no selection: + ```graphql mutation { - remove(id: "cart-id") { - id - } + remove(id: "cart-id") } ``` @@ -483,9 +527,9 @@ mutation { The full checkout flow spans the Core API, `/cart` endpoint, and `/order` endpoint: ``` -0. (Optional) Create Customer → Core API — createCustomer +0. (Optional) Create Customer → POST /customer — customer(input:) 1. Hydrate Cart → POST /cart — Create cart with items, customer, context -2. (Optional edits) → POST /cart — addSkuItem, setCustomer, setAddresses, etc. +2. (Optional edits) → POST /cart — addSkuItem, setCustomer, etc. 3. Place Cart → POST /cart — Lock cart for payment 4. Create Order → POST /order — createFromCart (⚠ different endpoint!) ``` @@ -495,9 +539,10 @@ separate fulfill step. ### Customer Creation -Customers can be created **before** checkout via the [Core API](core-api.md) or **inline** during hydration: +Customers can be created **before** checkout, on the Shop API or the [Core API](core-api.md), or **inline** during hydration: -- **Core API** (persistent customer records): Use `createCustomer` with `type: individual` or `type: organization`. The customer's `identifier` (typically email) can then be passed to `hydrate`. +- **Shop API `/customer`** (from a storefront): `customer(input: { identifier, ... })` creates or updates the customer, and `addAddress`, `setAddress`, `removeAddress` and `setMeta` edit it. The token needs the `customer` scope. +- **Core API** (persistent customer records, from a back office): Use `createCustomer` with `type: individual` or `type: organization`. The customer's `identifier` (typically email) can then be passed to `hydrate`. - **Inline during hydrate** (recommended for checkout): Pass `customer: { identifier, firstName, lastName, isGuest }` directly in the hydrate input. This associates the customer with the cart without requiring a separate API call. For guest checkout, set `isGuest: true` in the hydrate customer input. @@ -568,7 +613,8 @@ mutation { 6. **Use item groups** — Group items logically ("Outdoor", "Plants") for organized order views 7. **Handle managed state** — Know when items become unmanaged after price overrides 8. **External items for non-catalog items** — Shipping, fees, discounts via `addExternalItem` -9. **Auto-cleanup** — Carts expire after 3 months of inactivity +9. **Auto-cleanup** — Carts expire 30 days after the last write by default (`context.ttl`) and are deleted three months after that +10. **Don't write in parallel** — Most cart writes land just after the response, and on the same cart the last one wins. Wait for each answer and work from the cart it returns ## Related Links diff --git a/use-crystallize/skills/mutation/references/shop-api-order-mutations.md b/use-crystallize/skills/mutation/references/shop-api-order-mutations.md index 8297833..8d7c629 100644 --- a/use-crystallize/skills/mutation/references/shop-api-order-mutations.md +++ b/use-crystallize/skills/mutation/references/shop-api-order-mutations.md @@ -6,7 +6,9 @@ See [SKILL.md](../SKILL.md) for endpoint URLs and authentication headers. This f ## Create Order from Cart -Convert a placed cart into an order. The cart must be in "placed" state first (via `place` mutation on the `/cart` endpoint). +Convert a placed cart into an order. The cart must be in "placed" state first (via `place` mutation on the `/cart` endpoint). Any other state fails, so a second call on the same cart fails too: the first one left it `ordered`. + +**The order is saved just after the answer.** An `addPayments` or `addToStage` sent right behind `createFromCart` can miss it, and an immediate read can lag. Pass `paymentStatus`, `payments` and `pipelines` in this input instead of chaining calls. ```graphql mutation CreateOrderFromCart($id: UUID!, $input: OrderFromCartInput) { @@ -54,15 +56,15 @@ Variables: ### OrderFromCartInput -| Field | Type | Description | -| ------------------------- | ---------------------- | -------------------------------- | -| `type` | `OrderType` enum | Order type (default: `standard`) | -| `paymentStatus` | `OrderPaymentStatus` | Payment status | -| `payments` | `[OrderPaymentInput]` | Payment records | -| `pipelines` | `[OrderPipelineInput]` | Pipeline stage assignments | -| `stockLocationIdentifier` | `String` | Stock location for inventory | -| `relatedOrderIds` | `[String]` | Related order IDs | -| `additionalInformation` | `String` | Free-text additional info | +| Field | Type | Description | +| ------------------------- | ---------------------- | -------------------------------------------- | +| `type` | `OrderType` enum | Order type. No default: pass `standard` | +| `paymentStatus` | `OrderPaymentStatus` | Payment status. Set here or never, see below | +| `payments` | `[OrderPaymentInput]` | Payment records | +| `pipelines` | `[OrderPipelineInput]` | Pipeline stage assignments | +| `stockLocationIdentifier` | `String` | Stock location for inventory | +| `relatedOrderIds` | `[String]` | Related **Core** order ids (`coreId`) | +| `additionalInformation` | `String` | Free-text additional info | ## Create Order Directly @@ -157,20 +159,20 @@ Variables: ### OrderInput -| Field | Type | Required | Description | -| ------------------------- | ---------------------- | -------- | ----------------------------------------- | -| `items` | `[OrderItemInput]` | No | Order line items | -| `customer` | `CustomerInput` | No | Customer information | -| `context` | `OrderContextInput` | No | Price/language context | -| `type` | `OrderType` | No | Order type (default: `standard`) | -| `paymentStatus` | `OrderPaymentStatus` | No | Payment status | -| `payments` | `[OrderPaymentInput]` | No | Payment records | -| `pipelines` | `[OrderPipelineInput]` | No | Pipeline stage assignments | -| `stockLocationIdentifier` | `String` | No | Stock location for inventory deduction | -| `relatedOrderIds` | `[String]` | No | Related order IDs (returns, replacements) | -| `additionalInformation` | `String` | No | Free-text additional info | -| `meta` | `[KeyValueInput]` | No | Arbitrary key-value metadata | -| `createdAt` | `DateTime` | No | Override creation timestamp | +| Field | Type | Required | Description | +| ------------------------- | ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------- | +| `items` | `[OrderItemInput]` | No | Order line items | +| `customer` | `CustomerInput` | No | Customer information | +| `context` | `OrderContextInput` | No | `{ language, price: { currency, decimals, discountOnNetPrices } }`. `currency` defaults to `eur`, so set it | +| `type` | `OrderType` | No | Order type. No default: pass `standard` | +| `paymentStatus` | `OrderPaymentStatus` | No | Payment status. Set here or never, see below | +| `payments` | `[OrderPaymentInput]` | No | Payment records | +| `pipelines` | `[OrderPipelineInput]` | No | Pipeline stage assignments | +| `stockLocationIdentifier` | `String` | No | Stock location for inventory deduction | +| `relatedOrderIds` | `[String]` | No | Related **Core** order ids (`coreId`), for returns and replacements. A Shop id fails the sync to Core | +| `additionalInformation` | `String` | No | Free-text additional info | +| `meta` | `[KeyValueInput]` | No | Arbitrary key-value metadata | +| `createdAt` | `DateTime` | No | Override creation timestamp | ### OrderItemInput @@ -182,6 +184,8 @@ Variables: | `price` | `PriceInput!` | Yes | Unit price (gross + net) | | `productId` | `String` | No | Crystallize product ID | | `imageUrl` | `String` | No | Item image URL | +| `group` | `String` | No | Free-form group label | +| `type` | `CartItemType` | No | Line type, see the enum below | | `promotions` | `[Float]` | No | Discount amounts on item total | | `subscriptionContractId` | `String` | No | Subscription contract ID | | `subscription` | `OrderItemSubscriptionInput` | No | Subscription details | @@ -197,23 +201,22 @@ Variables: ### CustomerInput -| Field | Type | Required | Description | -| -------------------- | ----------------- | -------- | ------------------------------ | -| `isGuest` | `Boolean!` | Yes | Whether customer is guest | -| `identifier` | `String` | No | Unique customer ID | -| `firstName` | `String` | No | First name | -| `lastName` | `String` | No | Last name | -| `middleName` | `String` | No | Middle name | -| `email` | `String` | No | Email address | -| `phone` | `String` | No | Phone number | -| `birthDate` | `DateTime` | No | Date of birth | -| `companyName` | `String` | No | Company name | -| `taxNumber` | `String` | No | Tax/VAT number | -| `type` | `CustomerType` | No | `individual` or `organization` | -| `externalReference` | `String` | No | External system reference | -| `externalReferences` | `HashMap` | No | Multiple external refs | -| `addresses` | `[AddressInput]` | No | Customer addresses | -| `meta` | `[KeyValueInput]` | No | Customer metadata | +| Field | Type | Required | Description | +| -------------------- | ----------------- | -------- | ---------------------------------------- | +| `isGuest` | `Boolean!` | Yes | Whether customer is guest | +| `identifier` | `String` | No | Unique customer ID | +| `firstName` | `String` | No | First name | +| `lastName` | `String` | No | Last name | +| `middleName` | `String` | No | Middle name | +| `email` | `String` | No | Email address | +| `phone` | `String` | No | Phone number | +| `birthDate` | `DateTime` | No | Date of birth | +| `companyName` | `String` | No | Company name | +| `taxNumber` | `String` | No | Tax/VAT number | +| `type` | `CustomerType` | No | `individual` (default) or `organization` | +| `externalReferences` | `[KeyValueInput]` | No | External system references | +| `addresses` | `[AddressInput]` | No | Customer addresses | +| `meta` | `[KeyValueInput]` | No | Customer metadata | ### AddressInput @@ -247,10 +250,12 @@ Variables: ### OrderPipelineInput -| Field | Type | Required | Description | -| ------------ | --------- | -------- | --------------------- | -| `identifier` | `String!` | Yes | Pipeline identifier | -| `stage` | `String` | No | Stage within pipeline | +| Field | Type | Required | Description | +| ------------ | --------- | -------- | ------------------------------------------------------------- | +| `identifier` | `String!` | Yes | The pipeline **id**, not its name | +| `stage` | `String` | Yes | The stage **id**. The schema allows null, validation does not | + +These are ids, see [Pipeline Management](#pipeline-management). When `pipelines` is left out, the Core order lands in the pipelines the tenant set up for new orders. ## Add Payments to Order @@ -293,6 +298,8 @@ Variables: Replace all payment records on an order. +Neither mutation changes `paymentStatus`, and the `/order` endpoint has no mutation for it: it is set once, by `create` or `createFromCart`. See [[payments]] for tracking captures and refunds afterwards. + ```graphql mutation SetPayments($id: UUID!, $payments: [OrderPaymentInput!]!) { setPayments(id: $id, payments: $payments) { @@ -333,11 +340,11 @@ Variables: } ``` -When `merge` is `true`, new keys are added and existing keys are updated. When `false` (default), all existing metadata is replaced. +When `merge` is `true` (default), new keys are added and existing keys are updated. When `false`, all existing metadata is replaced. ## Set Order Customer -Update the customer information on an existing order. +Update the customer information on an existing order. It replaces the customer, and `isGuest` is required. ```graphql mutation SetOrderCustomer($id: UUID!, $customer: CustomerInput!) { @@ -355,32 +362,15 @@ mutation SetOrderCustomer($id: UUID!, $customer: CustomerInput!) { ## Create Order from Subscription Contract -Create a new order from an existing subscription contract, using its current phase and period. - -```graphql -mutation CreateFromSubscription($subscriptionContractId: UUID!) { - createFromSubscriptionContract(subscriptionContractId: $subscriptionContractId) { - id - type - items { - name - sku - quantity - subscription { - name - start - end - } - } - } -} -``` +`createFromSubscriptionContract` is in the schema but not implemented: every call errors. Read the `orderIntent` query on the `/subscription-contract` endpoint and pass the result to `create`. See [[subscriptions]]. ## Pipeline Management +`pipeline` and `stage` take the pipeline **id** and the stage **id**, not their names, here and in `pipelines` on `create` and `createFromCart`. Read them from the PIM API (`pipeline { getMany(tenantId:) { edges { node { id name stages { id name } } } } }`). **The Shop API does not check them:** the mutation succeeds and stores what you sent, and a name there stops the order from syncing to Core. + ### Add Order to Pipeline Stage -Add an order to a pipeline stage while keeping it in any existing stages. +Put an order in a pipeline stage. An order holds one stage per pipeline, so calling this again on the same pipeline moves it, and its other pipelines are untouched. ```graphql mutation AddToStage($id: UUID!, $pipeline: String!, $stage: String!) { @@ -399,8 +389,8 @@ Variables: ```json { "id": "order-uuid", - "pipeline": "fulfillment", - "stage": "shipped" + "pipeline": "", + "stage": "" } ``` @@ -522,7 +512,10 @@ mutation { # Step 2: Set customer info (on /cart endpoint) mutation { - setCustomer(id: "cart-id", customer: { firstName: "John", lastName: "Doe", email: "john@example.com" }) { + setCustomer( + id: "cart-id" + input: { isGuest: false, firstName: "John", lastName: "Doe", email: "john@example.com" } + ) { id } } @@ -551,39 +544,44 @@ mutation { `createFromCart` also moves the cart to `ordered` and sets its `orderId`. The order id is the cart id, so there is nothing left to link: do not follow it with `fulfill`. -## The Order Store Syncs One Way +## The Order Store and Core -Shop API orders and Core orders are two stores, and only one direction is reliable. +Shop API orders and Core orders are the same orders in two stores, synced asynchronously in both +directions. Where an order was born decides what a Core write does to it. -**Shop → Core works.** An order created here gets its `coreId` in about ten seconds, and `addToStage` +**Shop → Core.** An order created here gets its `coreId` in about ten seconds, and `addToStage` moves the Core stage with it. (`coreId` can still read `null` in the answer of a Shop `create` even after the order has reached Core, so don't use it as a sync flag.) -**Core → Shop does not.** Orders registered in Core with `registerOrder` appeared in the Shop store only -sometimes, carrying the pipelines they had at creation, and a later `updateOrderPipelineStage` or -`deleteOrder` in Core never reached the Shop store at all (watched for minutes). **Seed demo orders with -the Shop API, not with `registerOrder`**, if a storefront is going to list them. +| Born in | Core `updateOrder`, `updateOrderPipelineStage` | Core `deleteOrder` | +| ------------------------------------- | ------------------------------------------------------ | ------------------------------ | +| Shop API (`create`, `createFromCart`) | Rewrites the same Shop order with Core's whole version | Removes it from the Shop store | +| Core (`registerOrder`) | **Adds another copy** to the Shop store | Removes only the latest copy | + +**Seed demo orders with the Shop API, not with `registerOrder`**, if a storefront is going to list them: +orders registered in Core showed up in the Shop store only sometimes. -**Worse: editing an order in Core adds another copy of it to the Shop store.** Three `updateOrder` -calls on one seeded order left three orders in `orders(customerIdentifier:)` — new Shop ids, the same -`coreId`, different `updatedAt`. A storefront that sums that list counts the same money several times -over; the Lab Universe build read a department budget of NOK 1,249,299 instead of 411,775. So: +**Editing an order born in Core adds another copy of it to the Shop store.** Such an order has no Shop id +to come back to, so every change arrives as a new order. Three `updateOrder` calls on one seeded order left +three orders in `orders(customerIdentifier:)` — new Shop ids, the same `coreId`, different `updatedAt`. A +storefront that sums that list counts the same money several times over; the Lab Universe build read a +department budget of NOK 1,249,299 instead of 411,775. So: - **Change orders through the Shop API** (`setMeta`, `addToStage`, `setPayments`) whenever a storefront - reads them. -- If something must be edited in Core anyway, have the reader **group by `coreId` and keep the most - recently updated copy**. + reads them. On an order born here, a Core edit also overwrites whatever the Shop wrote just before it. +- If an order born in Core must be edited there anyway, have the reader **group by `coreId` and keep the + most recently updated copy**. -**There is no delete on the Shop API.** Cancelled or mistaken orders stay in the list. Archive them with -`setMeta` (`archived: true`) and filter them out when reading; deleting the Core order does not remove the -Shop one. +**There is no delete on the Shop API.** Cancelled or mistaken orders stay in the list until `deleteOrder` +on the Core order removes them. Otherwise archive them with `setMeta` (`archived: true`) and filter them +out when reading. (Both findings come from the Tools Universe and Lab Universe builds.) ## Best Practices 1. **Use the correct endpoint** — Cart operations on `/cart`, order operations on `/order` -2. **Include `order` scope in JWT** — Token must have `order` scope for `/order` endpoint +2. **Include `order` scope in JWT** — Token must have `order` scope for `/order` endpoint: `"scopes": ["cart", "order"]` when you request it 3. **Place cart before creating order** — `createFromCart` requires the cart to be in "placed" state 4. **Always check for errors** — Validate `result?.errors` in responses 5. **Use `meta` for custom data** — Store fulfillment status, tracking numbers, external references From 3066112f5f2a551a735304e6fa0e7ac08ea93939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 15:45:08 -0700 Subject: [PATCH 05/29] fix(skills): correct content-model defaults, limits and component config Component defaults, Item Relation and validation limits, choice config and localization behaviour now match the API. SKILL.md is split into references. --- use-crystallize/skills/content-model/SKILL.md | 754 ++---------------- .../content-model/references/components.md | 274 ++++--- .../references/content-modelling.md | 148 +++- .../references/create-shape-api.md | 115 ++- .../references/design-patterns.md | 46 +- .../references/mass-operation-example.md | 114 +++ .../references/page-builder-pattern.md | 127 ++- .../skills/content-model/references/pieces.md | 50 +- .../references/shape-consolidation.md | 265 ++++++ .../skills/content-model/references/shapes.md | 36 +- .../content-model/references/taxonomies.md | 30 +- 11 files changed, 1013 insertions(+), 946 deletions(-) create mode 100644 use-crystallize/skills/content-model/references/mass-operation-example.md create mode 100644 use-crystallize/skills/content-model/references/shape-consolidation.md diff --git a/use-crystallize/skills/content-model/SKILL.md b/use-crystallize/skills/content-model/SKILL.md index 697cdf1..6c6aa89 100644 --- a/use-crystallize/skills/content-model/SKILL.md +++ b/use-crystallize/skills/content-model/SKILL.md @@ -1,6 +1,6 @@ --- name: content-model -description: Design content models in Crystallize using Shapes, Pieces, Components, Topic Maps, and Grids. Create product structures, define document types, build taxonomies, organize catalogue items, design relationships between items, implement classification bridges, configure item relations with shape restrictions, model brand and product colours with the Colors component (hex, RGB, HSL, CMYK, Pantone, RAL, colour presets, swatches), and architect scalable data models. Use when modeling content, creating shapes, defining components, building taxonomies, designing relationship patterns, implementing semantic bridges, configuring product variants, choosing between a Colors component and a Selection for colour, or structuring catalogue hierarchies. +description: Design content models in Crystallize using Shapes, Pieces, Components, and Grids, and decide when a classification is a Topic Map, a Selection or a document. Create product structures, define document types, design relationships between items, implement classification bridges, configure item relations with shape restrictions, model brand and product colours with the Colors component (hex, RGB, HSL, CMYK, Pantone, RAL, colour presets, swatches), and architect scalable data models. Use when modeling content, creating shapes or pieces, defining components, designing relationship patterns, implementing semantic bridges, configuring variant components, building a page builder, setting component validation, localization or Discovery flags, or choosing between a Colors component and a Selection for colour. For the topic tree itself use taxonomy, and for folder hierarchies use information-architecture. metadata: author: Crystallize version: "3.0" @@ -28,7 +28,7 @@ Use the answers to choose shape types, decide on classification patterns (topics The output of this skill is a **mass operations JSON file** — a structured document that creates all shapes and pieces in the correct order. -After generating the mass operations JSON, **always validate it** using the `build-mass-operation` MCP tool. This tool validates against the official `@crystallize/schema` and returns either valid JSON or detailed error feedback. Fix any validation errors before presenting the result. +After generating the mass operations JSON, **always validate it** using the `build-mass-operation` MCP tool. This tool validates against the official `@crystallize/schema` and returns either valid JSON or detailed error feedback. Fix any validation errors before presenting the result. It checks structure only: the rules marked in the [Validation Checklist](#validation-checklist) fail later, when the operation runs. The mass operations file follows the **4-phase ordering** described in the [Mass Operations section](#mass-operations--4-phase-ordering) below. @@ -61,9 +61,9 @@ The mass operations file follows the **4-phase ordering** described in the [Mass ### Built-in vs Custom Fields -Every item has built-in fields: `name`, `id`, `language`, `tree`, `topics`, `createdAt`, `updatedAt`. +Every item has built-in fields: `name`, `id`, `language`, `tree`, `topics`, `externalReference`, `createdAt`, `updatedAt`. -Product variants have additional built-ins: `sku`, `images`, `videos`, `price`, `stock`, `attributes`. +Product variants have additional built-ins: `sku`, `images`, `videos`, `price`, `stock`, `attributes`, `externalReference`. **Components are the additional fields you define** beyond these built-ins. For product shapes, use `variantComponents` in the shape definition to add custom components directly to product variants (e.g., custom certifications, variant-level specs). @@ -73,7 +73,7 @@ Product variants have additional built-ins: `sku`, `images`, `videos`, `price`, **IMPORTANT:** Do NOT add components for price or stock - these are native properties managed through the product variant's built-in fields. Only create custom price/stock components for specific edge cases (e.g., tiered pricing models, subscription pricing, price modifiers for configurators). -**Product Identification:** The built-in `sku` field on product variants is the primary product identifier — use it for GTIN, EAN, ISBN, or any single identifier. Do NOT add a separate `gtin` or `ean` single-line component on the variant. If a product needs **multiple identifiers** (e.g., both ISBN-10 and ISBN-13, or a GTIN plus a legacy system ID), add an **identifiers chunk** with named fields to the shape: +**Product Identification:** The built-in `sku` field on product variants is the primary product identifier — use it for GTIN, EAN, ISBN, or any single identifier. Do NOT add a separate `gtin` or `ean` single-line component on the variant. If a product needs **multiple identifiers** (e.g., both ISBN-10 and ISBN-13, or a GTIN plus a legacy system ID), add a non-repeatable **identifiers chunk** with named fields to the shape: ``` Product Shape: Book @@ -90,7 +90,7 @@ Product Shape: Consumer Electronics └── legacy-id (Single Line) ``` -Each identifier gets its own named component — not a generic key/value pattern. This keeps the data typed, queryable, and self-documenting. Also useful during migrations to preserve old system identifiers alongside the new SKU. +Each identifier gets its own named component — not a generic key/value pattern. This keeps the data typed, queryable, and self-documenting. Also useful during migrations to preserve old system identifiers alongside the new SKU. For a single external system ID, use the built-in `externalReference` on the item or variant. See [Shapes Reference](references/shapes.md) and [Content Modelling Guide](references/content-modelling.md) for details. @@ -117,7 +117,7 @@ See [Shapes Reference](references/shapes.md) and [Content Modelling Guide](refer ### "I need controlled options" - **Selection** → Dropdown/checkboxes with predefined options. Every option needs `key` and `value`; also set `label` (optional, but good practice — the App UI displays it; default: same text as `value`) -- **Switch** → Boolean flag +- **Switch** → Boolean flag (type: `boolean`) - **Datetime** → Date/timestamp (type: `datetime`) - **Location** → Geographic coordinates @@ -127,7 +127,7 @@ See [Shapes Reference](references/shapes.md) and [Content Modelling Guide](refer - **Use chunk when:** Fields are specific to one location and won't be reused elsewhere - **Repeatable chunk:** For lists (ingredients, specifications, USP items, CTA buttons) - Examples: Width/Height/Depth (repeating for multiple dimensions), Street/City/Zip (repeating addresses) - - **Non-repeatable chunk:** For one-time field groups (metadata, settings, single address) + - **Non-repeatable chunk:** For one-time field groups (metadata, settings, single address). **Set `repeatable: false` explicitly** — a chunk that omits `repeatable` is repeatable. - Examples: SEO fields (title/description/keywords as one group), single shipping address, contact info block - **Decision:** If the group only occurs once in the entire content model → use non-repeatable chunk - Can be used inside pieces for nested grouping @@ -138,17 +138,15 @@ See [Shapes Reference](references/shapes.md) and [Content Modelling Guide](refer - Think: "Will this exact structure be used in 2+ locations?" - **Rule:** If only used once → use chunk instead of piece - Perfect for componentMultipleChoice options (page builder pattern) -- **Choice** → One of N mutually exclusive forms (polymorphic) - - **MUST have at least 2 choices** - Single option is invalid -- **Component Choice (componentChoice)** → Similar to choice but for component-level choices +- **Choice (componentChoice)** → One of N mutually exclusive forms (polymorphic). The API type is `componentChoice`, and its options go in `config.componentChoice.choices`. There is no `choice` type. - **MUST have at least 2 choices** - Single option is invalid - **EVERY choice item MUST have a `type` field** - choices with no `type` or with a raw `components` array are invalid API structures - When each choice needs multiple fields → create a `piece/upsert` operation for each, then reference with `{ "type": "piece", "config": { "piece": { "identifier": "..." } } }` - **Multiple Choice (componentMultipleChoice)** → Multiple coexisting forms (page builders) - - **MUST have at least 2 choices** - Single option is invalid + - **MUST have at least 1 choice** - One choice with `allowDuplicates` is a valid way to repeat a single piece - Use when: Editors choose between different content types at the same location - **Valid children**: Piece references (`type: "piece"`) or single regular components (images, singleLine, etc.) - - **NEVER as children**: contentChunk, choice, componentChoice, or nested componentMultipleChoice + - **NEVER as children**: contentChunk, componentChoice, or nested componentMultipleChoice - **NEVER**: anonymous objects with only a `components` array and no `type` field — these are not valid API structures - Common pattern: Page sections where each option is a piece (Banner, Hero, USP, Gallery) - Examples: Blocks (banner piece, hero piece, usp piece), Content areas (text piece, video piece) @@ -178,7 +176,7 @@ componentChoice/componentMultipleChoice ```json { - "id": "type", + "id": "product-type", "name": "Product Type", "type": "componentChoice", "config": { @@ -204,6 +202,21 @@ componentChoice/componentMultipleChoice For complete component reference with nesting rules, validation, and localization, see [Components Reference](references/components.md). +### Defaults That Surprise + +Leaving a config key out is a decision. This is what you get: + +| Key | Left out | Set it when | +| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `repeatable` on `contentChunk` | `true`: the chunk repeats | `false` for a group that occurs once | +| `allowDuplicates` on `componentMultipleChoice` | `true`: a choice can be picked several times | `false` when each choice may be picked once | +| `discoverable` | `true`: the component is indexed and returned by the Discovery API | `false` for internal data (ERP ids, notes, integration keys) | +| `required` | Not the same as `false`: an empty component is still held to its `min`, `pattern` and `units` | `false` on an optional field that has one of those rules | +| `multilingual` on a field | `singleLine`, `richText` and `colors` are stored per language; every other component is shared | `false` on `colors` that are universal, `true` on other fields that translate | +| `multilingual` on a chunk, choice or multiple choice | Structure is shared; each component inside follows its own setting | `true` to vary the structure per language. `false` forces everything inside to shared, so avoid it | + +Details and the App's own defaults are in [Components Reference](references/components.md#multilingual--localization). + ## Design Patterns for Relationships ### When to use each pattern? @@ -225,6 +238,7 @@ For complete component reference with nesting rules, validation, and localizatio ```json { "id": "brand", + "name": "Brand", "type": "itemRelations", "config": { "itemRelations": { @@ -238,502 +252,34 @@ For complete component reference with nesting rules, validation, and localizatio **Key point:** `acceptedShapeIdentifiers` enforces type safety - only "brand" documents can be linked. -### Quantised Classification Bridge - -**Problem:** Relationships have measurable quantities (200g flour, 4 screws). - -**Solution:** Repeating contentChunk with numeric + item relation. - -```json -{ - "id": "ingredients", - "type": "contentChunk", - "config": { - "contentChunk": { - "repeatable": true, - "components": [ - { "id": "quantity", "type": "numeric" }, - { "id": "unit", "type": "selection" }, - { - "id": "ingredient", - "type": "itemRelations", - "config": { - "itemRelations": { - "acceptedShapeIdentifiers": ["ingredient"], - "minItems": 1, - "maxItems": 1 - } - } - } - ] - } - } -} -``` +Quantised, Composite and Conditional bridges all use a repeating `contentChunk` that holds the Item Relation next to a Numeric, a role Selection or a rule Selection. ### Critical: Always Use acceptedShapeIdentifiers -**Without shape restrictions:** - -```json -// ❌ Bad - editors can link ANY shape -{ - "type": "itemRelations", - "config": { "itemRelations": {} } -} -``` - -**With shape restrictions:** - -```json -// ✅ Good - enforces semantic meaning -{ - "type": "itemRelations", - "config": { - "itemRelations": { - "acceptedShapeIdentifiers": ["brand"], - "minItems": 1, - "maxItems": 1 - } - } -} -``` - -This acts like foreign key constraints in relational databases, preventing data integrity issues. +Without shape restrictions, editors can link ANY shape. With them, a link to any other shape is rejected, for items and for SKUs alike. This acts like foreign key constraints in relational databases, preventing data integrity issues. ### Page Builder Pattern (componentMultipleChoice with Pieces) **Problem:** Editors need flexible page layouts with different section types. -**Solution:** componentMultipleChoice with piece references, each piece containing repeating chunks for items. - -```json -// Folder Shape: Landing Page (folders can have children, documents cannot) -{ - "components": [ - { - "id": "blocks", - "name": "Page Sections", - "type": "componentMultipleChoice", - "config": { - "componentMultipleChoice": { - "allowDuplicates": true, - "choices": [ - { "type": "piece", "config": { "piece": { "identifier": "banner-section" } } }, - { "type": "piece", "config": { "piece": { "identifier": "usp-section" } } }, - { "type": "piece", "config": { "piece": { "identifier": "hero-section" } } } - ] - } - } - } - ] -} - -// Piece: banner-section -{ - "identifier": "banner-section", - "name": "Banner Section", - "components": [ - { "id": "title", "type": "singleLine" }, - { "id": "description", "type": "richText" }, - { "id": "background", "type": "images" }, - { - "id": "ctas", - "name": "Call to Actions", - "type": "contentChunk", - "config": { - "contentChunk": { - "repeatable": true, - "components": [ - { "id": "text", "type": "singleLine" }, - { "id": "url", "type": "singleLine" } - ] - } - } - } - ] -} - -// Piece: usp-section -{ - "identifier": "usp-section", - "name": "USP Section", - "components": [ - { "id": "title", "type": "singleLine" }, - { - "id": "items", - "name": "USP Items", - "type": "contentChunk", - "config": { - "contentChunk": { - "repeatable": true, - "components": [ - { "id": "icon", "type": "images" }, - { "id": "title", "type": "singleLine" }, - { "id": "description", "type": "richText" } - ] - } - } - } - ] -} -``` - -**Key points:** +**Solution:** componentMultipleChoice with piece references, each piece containing repeating chunks for items. Chunks (CTAs, USP items) are inside pieces, not direct children of componentMultipleChoice. -- componentMultipleChoice contains pieces (banner-section, usp-section, hero-section) -- Each piece contains regular components and repeating chunks for items -- Chunks (CTAs, USP items) are inside pieces, not direct children of componentMultipleChoice -- This allows editors to build pages with flexible, reusable sections +See [Page Builder Pattern](references/page-builder-pattern.md) for the complete example, the shared `layout` piece and localized structure. For complete pattern details with examples, see [Design Patterns Reference](references/design-patterns.md). ## Shape Consolidation Strategy -### Minimizing Product and Folder Shapes - **Principle:** Keep the number of product and folder shapes to a minimum by using polymorphic patterns instead of creating separate shapes for similar items. -### When to Consolidate Shapes - -**Use the polymorphic product/folder pattern when:** - -1. **Similarity threshold**: Two or more product/folder shapes share 50%+ of their components - - Example: Plant and Vase both have description and SEO components - -2. **Common base components**: Shapes have the same foundational components but differ in specialized attributes - - Common: description, SEO, images, pricing - - Differ: plant-specific fields (care-level, light-requirement) vs vase-specific fields (material, dimensions) - -3. **Semantic similarity**: Items represent variations of the same concept rather than fundamentally different entities - - Example: Guitar, Amplifier, and Pedal are all music equipment (consolidate into "Music Product") - - Example: Plant and Vase are both home decor items (consolidate into "Home Decor Product") - -### When NOT to Consolidate - -**Keep shapes separate when:** - -1. **Fundamentally different purposes**: Items serve completely different business functions - - Product vs Folder (different shape types) - - Apparel vs Digital Download (different fulfillment, no pricing overlap) - -2. **Low component overlap**: Less than 30% of components are shared - - Consolidation would create a confusing, bloated shape - -3. **Different business logic**: Items require distinct workflows, permissions, or integrations - - Separate checkout flows - - Different inventory systems - - Distinct pricing models - -4. **Editor clarity**: The consolidated shape would confuse content editors - - Too many conditional fields make editing difficult - -### Consolidation Approach - -**Step 1: Identify common components** - -- Extract all shared components (description, SEO, images, etc.) -- These become the base components of the consolidated shape - -**Step 2: Create a type selector** - -- Add a selection component to distinguish between variants -- Example: `product-type` with options "plant", "vase", "bundle" - -**Step 3: Extract variant-specific attributes into pieces** - -- Create separate pieces for each variant's unique components -- Example: `plant-attributes` piece, `vase-attributes` piece - -**Step 4: Use choice component for polymorphic structure** - -- Add a choice component that conditionally shows the appropriate piece based on the type selection - -### Example: Consolidating Plant and Vase into Product - -**Before consolidation (2 shapes):** - -``` -Shape: Plant (product) - ├── description (richText) - ├── care-level (itemRelations → care-level docs) - ├── light-requirement (selection) - ├── mature-size (selection) - └── seo (piece) - -Shape: Vase (product) - ├── description (richText) - ├── material (itemRelations → material docs) - ├── dimensions (contentChunk) - └── seo (piece) -``` - -**After consolidation (1 shape + 2 pieces):** - -```json -{ - "intent": "shape/upsert", - "identifier": "product", - "name": "Product", - "type": "product", - "components": [ - { - "id": "product-type", - "name": "Product Type", - "type": "selection", - "config": { - "selection": { - "options": [ - { "key": "plant", "value": "Plant", "label": "Plant" }, - { "key": "vase", "value": "Vase", "label": "Vase" } - ] - } - } - }, - { "id": "description", "name": "Description", "type": "richText" }, - { - "id": "variant-attributes", - "name": "Product Details", - "type": "choice", - "config": { - "choice": { - "choices": [ - { - "id": "plant", - "name": "Plant Details", - "type": "piece", - "config": { - "piece": { "identifier": "plant-attributes" } - } - }, - { - "id": "vase", - "name": "Vase Details", - "type": "piece", - "config": { - "piece": { "identifier": "vase-attributes" } - } - } - ] - } - } - }, - { - "id": "seo", - "name": "SEO", - "type": "piece", - "config": { "piece": { "identifier": "seo" } } - } - ] -} -``` +- **Consolidate** when shapes share 50%+ of their components, have the same foundational components but differ in specialized attributes, or represent variations of the same concept. +- **Keep separate** when items serve different business functions, share less than 30% of their components, need distinct workflows, permissions or integrations, or when the merged shape would confuse editors. -**Supporting pieces:** - -```json -{ - "intent": "piece/upsert", - "identifier": "plant-attributes", - "name": "Plant Attributes", - "components": [ - { - "id": "care-level", - "name": "Care Level", - "type": "itemRelations", - "config": { - "itemRelations": { - "acceptedShapeIdentifiers": ["care-level"], - "minItems": 1, - "maxItems": 1 - } - } - }, - { - "id": "light-requirement", - "name": "Light Requirement", - "type": "selection", - "config": { - "selection": { - "options": [ - { "key": "low", "value": "Low Light", "label": "Low Light" }, - { "key": "medium", "value": "Medium Light", "label": "Medium Light" }, - { "key": "bright", "value": "Bright Light", "label": "Bright Light" }, - { "key": "direct", "value": "Direct Sunlight", "label": "Direct Sunlight" } - ] - } - } - }, - { - "id": "mature-size", - "name": "Mature Size", - "type": "selection", - "config": { - "selection": { - "options": [ - { "key": "small", "value": "Small (under 12\")", "label": "Small (under 12\")" }, - { "key": "medium", "value": "Medium (12-24\")", "label": "Medium (12-24\")" }, - { "key": "large", "value": "Large (24-48\")", "label": "Large (24-48\")" }, - { "key": "xl", "value": "Extra Large (48\"+)", "label": "Extra Large (48\"+)" } - ] - } - } - } - ] -} -``` - -```json -{ - "intent": "piece/upsert", - "identifier": "vase-attributes", - "name": "Vase Attributes", - "components": [ - { - "id": "material", - "name": "Material", - "type": "itemRelations", - "config": { - "itemRelations": { - "acceptedShapeIdentifiers": ["material"], - "minItems": 1, - "maxItems": 3 - } - } - }, - { - "id": "dimensions", - "name": "Dimensions", - "type": "contentChunk", - "config": { - "contentChunk": { - "components": [ - { - "id": "height", - "name": "Height", - "type": "numeric", - "config": { "numeric": { "units": ["cm", "in"] } } - }, - { - "id": "diameter", - "name": "Diameter", - "type": "numeric", - "config": { "numeric": { "units": ["cm", "in"] } } - } - ] - } - } - } - ] -} -``` - -**Benefits:** - -- Single product shape instead of multiple -- Shared components (description, SEO) defined once -- Type-specific attributes cleanly separated into pieces -- Easier to maintain and extend -- Clearer information architecture - -**Trade-offs:** - -- Slightly more complex for editors (choice component requires selecting variant type) -- Frontend may need to handle polymorphic structure -- Only worthwhile when shapes share significant overlap - -### Mass Operations Pattern for Consolidated Shapes - -When using the 4-phase ordering with consolidated shapes: - -1. **Phase 1**: Create variant-specific pieces (plant-attributes, vase-attributes) -2. **Phase 2**: Create consolidated shape container -3. **Phase 3**: Add components to variant pieces -4. **Phase 4**: Add components (including choice with piece references) to consolidated shape - -This ensures all piece references in the choice component resolve correctly. +See [Shape Consolidation](references/shape-consolidation.md) for the approach, a worked example with the shape and pieces in JSON, the trade-offs, and how it fits the 4-phase ordering. ## Common Use Cases -### Standard SEO Piece - -A reusable piece for SEO metadata, commonly added to products, folders (landing pages), and documents (articles): - -```json -{ - "identifier": "seo", - "name": "SEO", - "components": [ - { "id": "title", "name": "Title", "type": "singleLine" }, - { "id": "description", "name": "Description", "type": "richText" }, - { "id": "image", "name": "Image", "type": "images" } - ] -} -``` - -Usage in a shape: - -```json -{ - "id": "seo", - "name": "SEO", - "type": "piece", - "config": { "identifier": "seo" } -} -``` - -### E-commerce Product with Variants - -``` -Product Shape: Clothing - ├── Description (Rich Text, translatable) - ├── Material (Item Relations → material documents, acceptedShapeIdentifiers: ["material"]) - ├── Brand (Item Relations → brand document, acceptedShapeIdentifiers: ["brand"], min:1, max:1) - └── Care Instructions (Rich Text, translatable) - -Variant-level (built-in): - ├── Size (attribute) - ├── Color (attribute) - ├── SKU (built-in) - ├── Images (built-in) - └── Price (built-in) -``` - -### Recipe with Ingredients - -``` -Document Shape: Recipe - ├── Description (Rich Text) - ├── Ingredients (contentChunk, repeating) - │ ├── Quantity (Numeric) - │ ├── Unit (Selection: g, ml, pcs) - │ └── Ingredient (Item Relations → ingredient products, acceptedShapeIdentifiers: ["ingredient"]) - └── Allergens (Item Relations → allergen documents, acceptedShapeIdentifiers: ["allergen"]) -``` - -### Blog Post with Topics - -``` -Document Shape: Blog Post - ├── Hero Image (Images) - ├── Content (Paragraph Collection) - ├── Author (Item Relations → author document, acceptedShapeIdentifiers: ["author"]) - ├── SEO (Piece → seo-metadata) - └── topics → Category topic map, Tags topic map -``` - -### Product Configurator - -``` -Product Shape: Configurable Product - └── Options (contentChunk, repeating) - ├── Option (Item Relations → option products, acceptedShapeIdentifiers: ["option"]) - └── Quantity (Numeric) - -Document Shape: Configuration Rule - ├── If Option (Item Relations → option, acceptedShapeIdentifiers: ["option"]) - ├── Rule Type (Selection: requires, excludes, optional) - └── Then Option (Item Relations → option, acceptedShapeIdentifiers: ["option"]) -``` +Ready-made outlines live in the references: the standard SEO piece in [Pieces Reference](references/pieces.md), and the e-commerce product, recipe, blog post, configurator and per-industry models in [Content Modelling Guide](references/content-modelling.md). ## Best Practices @@ -765,8 +311,8 @@ Piece: "SEO" (not "SEO Metadata") 6. **Consolidate similar shapes** - When classification documents share the same structure (e.g., "Coffee Origin" and "Tea Origin" both need name, description, image), combine them into a single shape ("Origin") and differentiate via folder placement or topic tags 7. **Use chunks for grouping** - Related fields that repeat together 8. **Choose topics vs documents wisely** - Simple labels → topics, rich content → documents -9. **Plan localization early** - Enable `isTranslatable` from the start -10. **Mark fields discoverable** - Enable for filtering/search in Discovery API +9. **Plan localization early** - Set `multilingual` from the start +10. **Decide what Discovery sees** - Components are indexed by default. Set `discoverable: false` on internal fields 11. **Apply design patterns** - Use established patterns for scalable models ## Anti-Patterns to Avoid @@ -791,22 +337,26 @@ Piece: "SEO" (not "SEO Metadata") ❌ **Using contentChunk as direct child of componentMultipleChoice** - Chunks should be inside pieces, not direct children -❌ **Using choice/componentChoice as direct children of componentMultipleChoice** - Choices cannot be nested directly +❌ **Using componentChoice as a direct child of componentMultipleChoice** - Choices cannot be nested directly ❌ **Nesting structural components directly** - componentChoice, componentMultipleChoice, and contentChunk can only have pieces or regular components as direct children, never other structural components. Use pieces as intermediaries. -❌ **Choice items without a `type` field** - Every item in a `choices` / `componentConfigs` array MUST have a `type` field. Objects like `{ "id": "smartphone", "name": "Smartphone", "components": [...] }` are INVALID — the `components` key does not exist on a choice item and `type` is missing. If a choice needs multiple fields, create a separate piece and reference it with `{ "type": "piece", "config": { "piece": { "identifier": "smartphone-spec" } } }`. +❌ **Choice items without a `type` field** - Every item in a `choices` array MUST have a `type` field. Objects like `{ "id": "smartphone", "name": "Smartphone", "components": [...] }` are INVALID — the `components` key does not exist on a choice item and `type` is missing. If a choice needs multiple fields, create a separate piece and reference it with `{ "type": "piece", "config": { "piece": { "identifier": "smartphone-spec" } } }`. ❌ **Polymorphic Choice with inline component groups** - When different choice variants (e.g. Smartphone, Laptop, Headphones) each need their own set of fields, **do NOT** embed a `components` array directly on the choice object. This is the most common componentChoice mistake. **Always** create a `piece/upsert` operation for each variant and reference the piece. Example: `{ "type": "piece", "config": { "piece": { "identifier": "smartphone-spec" } } }` where `smartphone-spec` is a separate piece containing Screen Size, Storage, OS fields. ❌ **CTA/USP items as componentMultipleChoice choices** - These should be repeating chunks inside a piece, not top-level choices -❌ **Single-choice configurations** - Choice components (choice, componentChoice, componentMultipleChoice) MUST have at least 2 options. Single option is invalid. If you only have one option, use a direct piece reference instead of wrapping it in a componentChoice. +❌ **Single-choice configurations** - `componentChoice` MUST have at least 2 options. Single option is invalid. If you only have one option, use a direct piece reference instead of wrapping it in a componentChoice. `componentMultipleChoice` needs at least 1. ❌ **Wrapping single piece in componentChoice** - If a component only needs one piece (e.g., CTA), add the piece directly. Don't use `componentChoice` with one option. ❌ **Empty contentChunk (no components)** - A `contentChunk` with zero child components is meaningless and invalid. Always define at least 1 component inside a chunk. If you find yourself creating an empty chunk, reconsider the design. +❌ **`maxItems` as "unlimited"** - An Item Relation holds at most 75 items and 75 SKUs. A `maxItems` or `maxSkus` above 75 is rejected, so a high number like 999 fails. Leave it out for "as many as allowed". For more than 75, invert the relation or use a topic. + +❌ **Internal fields left discoverable** - Discovery indexes every component unless it says `discoverable: false`, and the Discovery API is typically public. + See [Content Modelling Guide](references/content-modelling.md) for detailed anti-patterns and migration strategies. ## Mass Operations — 4-Phase Ordering @@ -822,125 +372,19 @@ When creating shapes and pieces via mass operations (Bulk Task API), use a **4-p **REQUIRED FIELDS**: -- All upsert operations MUST include `identifier` and `name` +- All upsert operations MUST include `identifier` (2 to 64 characters) and `name` - Shape upserts MUST include `type` +- Every component definition, including each entry of `choices`, MUST include `id` and `name` **How upsert works:** Multiple operations with the same `identifier` will update the same resource. Phase 1-2 create containers, phase 3-4 populate them with components. +**An upsert replaces the whole definition.** On a shape or piece that already exists, `shape/upsert`, `shape/update`, `piece/upsert` and `piece/update` set `components` to exactly what you send, and leaving it out empties it. On a product shape the same goes for `variantComponents`, so an upsert that sends only `components` deletes them. When the tenant already has the shape or piece, skip phases 1-2 for it and send the complete definition in phases 3-4. Otherwise a run that stops between phases leaves it empty. + ### Why This Order? -Components can reference pieces (via `type: "piece"`) and shapes (via `itemRelations` with `acceptedShapeIdentifiers`). By creating containers first (phases 1-2), then populating them with components that may contain references (phases 3-4), all references resolve correctly. +Components can reference pieces (via `type: "piece"`) and shapes (via `itemRelations` with `acceptedShapeIdentifiers`). A reference to a piece or shape that does not exist yet fails the operation. By creating containers first (phases 1-2), then populating them with components that may contain references (phases 3-4), all references resolve correctly. -### Example Operations File - -```json -{ - "version": "1.0.0", - "operations": [ - // Phase 1: Create piece containers (no components) - { - "intent": "piece/upsert", - "identifier": "seo", - "name": "SEO" - }, - { - "intent": "piece/upsert", - "identifier": "hero", - "name": "Hero Section" - }, - // Phase 2: Create shape containers (no components) - { - "intent": "shape/upsert", - "identifier": "product", - "name": "Product", - "type": "product" - }, - { - "intent": "shape/upsert", - "identifier": "brand", - "name": "Brand", - "type": "document" - }, - // Phase 3: Update pieces with components (same identifier - upsert updates existing) - { - "intent": "piece/upsert", - "identifier": "seo", - "name": "SEO", - "components": [ - { "id": "title", "name": "SEO Title", "type": "singleLine" }, - { "id": "description", "name": "SEO Description", "type": "singleLine" } - ] - }, - { - "intent": "piece/upsert", - "identifier": "hero", - "name": "Hero Section", - "components": [ - { "id": "headline", "name": "Headline", "type": "singleLine" }, - { "id": "image", "name": "Hero Image", "type": "images" } - ] - }, - // Phase 4: Update shapes with components (can now safely reference pieces) - { - "intent": "shape/upsert", - "identifier": "product", - "name": "Product", - "type": "product", - "components": [ - { "id": "description", "name": "Description", "type": "richText" }, - { - "id": "seo", - "name": "SEO", - "type": "piece", - "config": { "piece": { "identifier": "seo" } } - }, - { - "id": "brand", - "name": "Brand", - "type": "itemRelations", - "config": { - "itemRelations": { - "maxItems": 1, - "acceptedShapeIdentifiers": ["brand"] - } - } - } - ], - "variantComponents": [ - { - "id": "weight", - "name": "Weight", - "type": "numeric", - "config": { "numeric": { "units": ["g", "kg"], "discoverable": true } } - }, - { - "id": "color", - "name": "Color", - "type": "selection", - "config": { - "selection": { - "options": [ - { "key": "black", "value": "Black", "label": "Black" }, - { "key": "white", "value": "White", "label": "White" } - ] - } - } - } - ] - }, - { - "intent": "shape/upsert", - "identifier": "brand", - "name": "Brand", - "type": "document", - "components": [ - { "id": "description", "name": "Description", "type": "richText" }, - { "id": "logo", "name": "Logo", "type": "images" } - ] - } - ] -} -``` +See [Mass Operation Example](references/mass-operation-example.md) for a complete operations file. **Note:** Using `piece/upsert` or `shape/upsert` in a single operation (without the 4-phase split) will work ONLY if there are no cross-references. When pieces reference other pieces or shapes reference pieces/other shapes, the 4-phase order is required. @@ -948,99 +392,31 @@ Components can reference pieces (via `type: "piece"`) and shapes (via `itemRelat Before presenting the mass operations JSON, verify: -1. **Validate with `build-mass-operation` tool** — Run the operations array through the MCP tool and fix any schema errors. This is the source of truth. -2. **`acceptedShapeIdentifiers`** on every `itemRelations` component — no untyped relations -3. **No structural nesting violations** — `contentChunk`, `componentChoice`, `componentMultipleChoice` cannot be direct children of each other -4. **Every `contentChunk` has at least 1 component** — empty chunks are invalid -5. **Every `componentChoice`/`componentMultipleChoice` has at least 2 choices** — single-choice is invalid +1. **Validate with `build-mass-operation` tool** — Run the operations array through the MCP tool and fix any schema errors. It settles field names and types. It does not check the rules marked _(run time)_ below: those pass validation and fail in the operation logs. +2. **`acceptedShapeIdentifiers`** on every `itemRelations` component — no untyped relations. `maxItems` and `maxSkus` stay at 75 or below _(run time)_ +3. **No structural nesting violations** — `contentChunk`, `componentChoice`, `componentMultipleChoice` cannot be direct children of each other, and nesting stops at 4 structural levels _(run time)_ +4. **Every `contentChunk` has at least 1 component** — empty chunks are invalid _(run time)_. Set `repeatable: false` on a group that occurs once +5. **Every `componentChoice` has at least 2 choices, every `componentMultipleChoice` at least 1** _(run time)_ 6. **No custom price/stock components** on product shapes — these are built-in on variants 7. **`paragraphCollection` config always has `multilingual` array** — this is required, not optional (use `[]` for no localization, `["title", "body", "images", "videos"]` for full localization) -8. **`datetime` config uses only valid fields** — `required`, `discoverable`, `multilingual` only. There is no `format` field. -9. **`files` config**: if `acceptedContentTypes` is provided, each entry needs `contentType` (required). If `maxFileSize` is provided, both `size` and `unit` are required. +8. **`datetime` config has no `format` field in mass operations** — the schema rejects it. A datetime created this way uses the default `date` format. `format` is only accepted by the `createShape` and `updateShape` mutations. +9. **`files` config**: if `acceptedContentTypes` is provided, each entry needs `contentType` (required). In mass operations, any `files` config must include `maxFileSize` with both `size` and `unit`. 10. **Naming conventions** — all identifiers use lowercase-with-hyphens (no camelCase, no underscores) 11. **Reusable SEO piece** — create an SEO piece and reference it from multiple shapes 12. **Localization strategy** — translatable for marketing text, non-translatable for universal data (codes, dimensions) +13. **Colors components** — set `customColorSpaces` or `colorPresets`, or editors have nothing to enter in the App. Set `multilingual: false` when the values are universal +14. **`discoverable: false`** on internal fields ## References - [Create Shape API Reference](references/create-shape-api.md) - **Full mutation type reference with all required fields, config structures, and validation rules for `createShape`** -- [Content Modelling Guide](references/content-modelling.md) - Comprehensive design framework, industry patterns, validation +- [Mass Operation Example](references/mass-operation-example.md) - Complete operations file following the 4-phase ordering +- [Content Modelling Guide](references/content-modelling.md) - Comprehensive design framework, use cases, industry patterns, validation - [Components Reference](references/components.md) - All 15+ component types, nesting rules, localization - [Design Patterns Reference](references/design-patterns.md) - 5 bridge patterns with complete JSON examples +- [Page Builder Pattern](references/page-builder-pattern.md) - Block slot, block pieces, shared `layout` piece, localized structure +- [Shape Consolidation](references/shape-consolidation.md) - When and how to merge similar shapes - [Shapes Reference](references/shapes.md) - Shape types, variantComponents, and creation process -- [Pieces Reference](references/pieces.md) - Reusable field sets +- [Pieces Reference](references/pieces.md) - Reusable field sets, standard SEO piece - [Taxonomies Reference](references/taxonomies.md) - Topic Maps and Grids - [[taxonomy]] — Use this skill to design topic maps (hierarchical taxonomies with `displayColor`, `pathIdentifier`, nesting) before referencing them from shapes here. - -## Examples by Industry - -**Fashion:** Product with variants (size/color), material classifications, brand documents, care instructions - -**Food/Recipe Domain:** Complete model using Quantised Bridge pattern: - -``` -Product Shape: Ingredient - ├── Description (Rich Text) - ├── Nutrition (Piece → nutrition) - └── Allergens (Item Relations → allergen docs, acceptedShapeIdentifiers: ["allergen"]) - -Product Shape: Recipe - ├── Description (Rich Text) - ├── Prep Time (Numeric, unit: minutes) - ├── Cook Time (Numeric, unit: minutes) - ├── Servings (Numeric) - ├── Ingredients (contentChunk, repeating) ← Quantised Bridge - │ ├── Quantity (Numeric) - │ ├── Unit (Selection: g, ml, pcs, tbsp, tsp, cup) - │ └── Ingredient (Item Relations → ingredient, acceptedShapeIdentifiers: ["ingredient"]) - ├── Instructions (Paragraph Collection) - └── Allergens (Item Relations → allergen docs, computed from ingredients) - -Product Shape: Meal Kit - ├── Description (Rich Text) - ├── Recipes (contentChunk, repeating) ← Quantised Bridge - │ ├── Servings (Numeric) - │ └── Recipe (Item Relations → recipe, acceptedShapeIdentifiers: ["recipe"]) - └── Dietary Tags (Item Relations → dietary docs) - -Document Shape: Allergen - ├── Icon (Images) - ├── Warning Text (Rich Text) - └── Severity (Selection: mild, moderate, severe) - -Piece: nutrition - ├── Calories (Numeric, unit: kcal) - ├── Protein (Numeric, unit: g) - ├── Carbs (Numeric, unit: g) - └── Fat (Numeric, unit: g) -``` - -This pattern enables: ingredient → recipe → meal kit composition with quantity tracking at each level, automatic allergen rollup, and nutrition calculation. - -**Electronics:** Product with configuration options, compatibility rules, technical specs, brand classifications - -**Music/Instruments:** Guitar store example using Semantic Bridge pattern: - -``` -Product Shape: Guitar - ├── Description (Rich Text) - ├── Body Type (Item Relations → body-type docs, acceptedShapeIdentifiers: ["body-type"]) - ├── Pickup Configuration (Item Relations → pickup docs, acceptedShapeIdentifiers: ["pickup"]) - ├── Wood Type (Item Relations → wood docs, acceptedShapeIdentifiers: ["wood"]) - ├── Brand (Item Relations → brand doc, acceptedShapeIdentifiers: ["brand"], min:1, max:1) - └── SEO (Piece → seo) - -Classification Documents: - ├── body-type: Stratocaster, Les Paul, Telecaster, SG... - ├── pickup: Single Coil, Humbucker, P90, Active... - ├── wood: Alder, Mahogany, Maple, Rosewood... - └── brand: Fender, Gibson, PRS, Ibanez... -``` - -This pattern allows rich content for each classification (e.g., brand logo, wood tone characteristics) while maintaining type safety through `acceptedShapeIdentifiers`. - -**B2B:** Service packages with tiers, case studies, industry classifications, expertise levels - -**Publishing:** Books with contributors (composite bridge), series, publishers, multi-format variants - -See [Content Modelling Guide](references/content-modelling.md) for complete industry-specific patterns. diff --git a/use-crystallize/skills/content-model/references/components.md b/use-crystallize/skills/content-model/references/components.md index 7e8849f..eb160dd 100644 --- a/use-crystallize/skills/content-model/references/components.md +++ b/use-crystallize/skills/content-model/references/components.md @@ -1,6 +1,6 @@ # Components Reference -Components are the **dynamic** fields you add to a Shape. They sit on top of built-in fields that every item already has (`name`, `id`, `language`, `tree`, `topics`, `createdAt`, `updatedAt`). Product variants also have built-in fields for `sku`, `images`, `videos`, `price`, `stock`, and `attributes` — these are NOT components. +Components are the **dynamic** fields you add to a Shape. They sit on top of built-in fields that every item already has (`name`, `id`, `language`, `tree`, `topics`, `externalReference`, `createdAt`, `updatedAt`). Product variants also have built-in fields for `sku`, `images`, `videos`, `price`, `stock`, `attributes`, and `externalReference` — these are NOT components. Choosing the right component for each piece of **additional** data determines how editors interact with it, how it renders on the frontend, and whether it can be filtered/searched via the Discovery API. @@ -15,7 +15,7 @@ Choosing the right component for each piece of **additional** data determines ho | Long-form editorial with mixed media sections | **Paragraph Collection** | - **Single Line** → structured, unformatted, indexable. Ideal for labels, codes, and short metadata. Note: item `name` and variant `sku` are built-in fields, not Single Line components — but additional identifiers (GTIN, EAN, UPC, MPN) beyond the built-in SKU are valid Single Line components. -- **Rich Text** → formatted text block. Produces structured JSON (not HTML). Supports headings, lists, links, bold, italic, inline code. Use for product descriptions, marketing copy, specification text. +- **Rich Text** → formatted text block. Stored as structured JSON, and the APIs return it as `json`, `html` and `plainText`. Supports headings, lists, links, bold, italic, inline code. Use for product descriptions, marketing copy, specification text. - **Paragraph Collection** → repeating sections, each with: title (single line) + body (rich text) + media (images/video). Use for blog posts, editorial storytelling, landing page content blocks, any content that alternates text and media. ### "I need to store numbers" @@ -34,14 +34,15 @@ Choosing the right component for each piece of **additional** data determines ho **Rule of thumb**: If you always know the key name upfront → use Numeric. If the keys vary per item → use Properties Table. -> **A unit list makes a Numeric effectively required.** Content for a numeric with `units` has to name -> one ("No unit provided for numeric item component `voltage`. Need to specify one of the following units: -> V"), even when the list holds a single unit — and a numeric input cannot carry a unit without a number, -> since `number` is non-null. So every item of that shape needs a value, on create **and** on every update -> that sends components. Two builds hit this: a `vehicle` document for an electric car with no -> displacement, and a variant component that only oils and chemicals fill, which made `updateProduct` fail -> on a filter for a component none of its variants had. If the value is genuinely optional, leave `units` -> out and put the unit in the component's name (`Volume (l)`), or split it into unit-free fields. +> **A unit list makes a Numeric effectively required, unless you set `required: false`.** Content for a +> numeric with `units` has to name one ("No unit provided for numeric item component `voltage`. Need to +> specify one of the following units: V"), even when the list holds a single unit — and a numeric input +> cannot carry a unit without a number, since `number` is non-null. So with `required` left out, every item +> of that shape needs a value, on create **and** on every update that sends components. Two builds hit +> this: a `vehicle` document for an electric car with no displacement, and a variant component that only +> oils and chemicals fill, which made `updateProduct` fail on a filter for a component none of its variants +> had. If the value is genuinely optional, set `required: false` on the numeric: an empty component then +> skips the unit check, and a filled one must still name a unit. Component definitions also take a **`description`**, which shows up as editor help. It is the natural place for a mapping to an industry standard — one build stored the ETIM feature code there @@ -66,11 +67,11 @@ for a mapping to an industry standard — one build stored the ETIM feature code | Link to another catalogue item (product, document, folder) | **Item Relation** | | Link to a curated grid of items | **Grid Relation** | -- **Item Relation** → the backbone of all bridge design patterns. Links to one or more items. On product shapes, can link to specific variants (SKU-level). Use for: related products, brand references, ingredients, accessories, any semantic relationship. +- **Item Relation** → the backbone of all bridge design patterns. Links to one or more items, and can link to specific variants (SKU-level) through `skus`. Use for: related products, brand references, ingredients, accessories, any semantic relationship. - **Configuration options** (like relational database constraints): - - **Accepted shape identifiers**: restrict which shapes can be linked (e.g., only allow "Brand" documents, or only "Product" shapes) + - **Accepted shape identifiers**: restrict which shapes can be linked (e.g., only allow "Brand" documents, or only "Product" shapes). Applies to linked SKUs too, through their product's shape - **Min relations**: minimum number of items that must be linked (e.g., every product must have at least 1 brand) - - **Max relations**: maximum number of items that can be linked (e.g., product can link to max 5 related products) + - **Max relations**: maximum number of items that can be linked (e.g., product can link to max 5 related products). The ceiling is 75 items and 75 SKUs per component, even with no `maxItems` set - These constraints enforce data integrity and guide editors to create consistent relationships. - **Grid Relation** → links to a curated grid. Use for: seasonal landing pages, campaign highlights, curated collections. @@ -88,7 +89,7 @@ for a mapping to an industry standard — one build stored the ETIM feature code | A date or timestamp | **Datetime** | | Geographic coordinates | **Location** | -- **Selection** → dropdown, radio buttons, or checkboxes with predefined options. Each option has a `key` (API value) and `value` (display label). Configure min/max selections to control behavior: +- **Selection** → dropdown, radio buttons, or checkboxes with predefined options. Each option has a `key` (API value), a `value` and a `label` (display text). Configure min/max selections to control behavior: - **Radio/Enum pattern**: min=1, max=1, required → exactly one selection, renders as radio buttons or dropdown - **Checkboxes pattern**: min=0, max=N → optional multi-select, renders as checkboxes, can be null Use when the options are a fixed, small set that don't need their own content. If options need enrichment (images, descriptions), use Item Relations instead (Semantic Bridge pattern). @@ -106,10 +107,13 @@ for a mapping to an industry standard — one build stored the ETIM feature code | A colour _name_ used only for filtering and navigation | **Selection** or **Topic** | - **Colors (`colors`)** → stores one or more colour entries. Each entry can carry the same colour in several notations **at once** — `hex`, `rgb`, `hsl`, `cmyk`, `pantone`, `ral` — plus a free-text `label`. The component is a **list**, so a single component holds a whole colourway. - - **Configuration**: `min` / `max` (number of entries), `colorPresets` (a fixed palette editors pick from), `customColorSpaces` (which notations beyond the default are offered), `requireLabel`, plus the usual `required`, `discoverable` and `multilingual`. - - **`colorPresets` is how you enforce a brand palette.** Editors choose from the palette instead of typing hex values, which is the difference between a design system and 40 slightly different greys. + - **Configuration**: `min` / `max` (number of entries, at most 100 when `max` is unset), `colorPresets` (a palette editors pick from), `customColorSpaces` (the only notations an entry may carry), `requireLabel`, plus the usual `required`, `discoverable` and `multilingual`. + - **`customColorSpaces` decides what editors can type in the App.** With none set, the App shows no colour inputs at all, so a component with neither `customColorSpaces` nor `colorPresets` gives editors nothing to enter. Set it, usually to `["hex"]`. When it is set, the API rejects content in any other notation. When it is unset, the API accepts all of them. + - **`colorPresets` with no `customColorSpaces` is how you hold editors to a brand palette.** Editors choose from the palette instead of typing hex values, which is the difference between a design system and 40 slightly different greys. This is an App rule: the API does not check content against the presets. - **`requireLabel: true`** forces every entry to be named ("Midnight Blue"). The label is what the storefront shows; the value is what it renders. +**Content rules the API enforces**: `hex` is `#RRGGBB` or `#RRGGBBAA` (a short `#FFF` is rejected), `ral` is `RAL 1234`, `pantone` is `Pantone 485 C` or `485 C`, and each entry needs at least one of `hex`, `rgb`, `hsl`, `ral` or `pantone`, so a `cmyk`-only entry is rejected. + **Why one entry carries several notations**: screen wants `hex`/`rgb`, print wants `cmyk`/`pantone`, paint and industrial want `ral`. One authoritative entry serves all three. Modelling them as three separate Single Line components guarantees they drift apart the first time a colour is corrected. #### Colors vs Selection — the decision people get wrong @@ -136,23 +140,25 @@ These are **structural components** — they don't store data directly but organ - **Chunk (contentChunk)** → a group of components that belong together (repeatable OR single occurrence) - **Can be repeatable:** Enable `repeatable: true` to create lists (ingredients, specifications, addresses) - - **Can be single:** Use `repeatable: false` (or omit) for one-time groups that only occur once in the model + - **Can be single:** Set `repeatable: false` for one-time groups that only occur once in the model. Omitting `repeatable` makes the chunk repeatable - **When to use chunk:** Fields are tightly coupled and specific to one location in your content model - **Examples (repeatable):** Ingredients list, multiple addresses, product specifications, USP items - **Examples (non-repeatable):** SEO metadata (title+description+keywords as one group), single shipping address, environment settings - **Decision rule:** If this exact group structure only appears once in the entire content model → use a non-repeatable chunk instead of creating a piece - Building block for all bridge patterns (semantic, quantised, conditional, composite) - **A chunk must always have at least 1 child component — empty chunks are invalid.** - - **A chunk that is not `multilingual` shares its text across languages.** The setting covers the whole + - **A chunk with `multilingual: false` shares its text across languages.** The setting covers the whole chunk, including the `singleLine` and `richText` fields inside it, so translating one language - overwrites the others. A chunk holding text that should be translated needs `multilingual: true`. + overwrites the others. A chunk holding text that should be translated leaves `multilingual` out: the + rows stay shared and each field follows its own setting. `multilingual: true` also gives each language + its own rows. - **A chunk on a variant may not publish.** One build found a variant's chunk sitting in the draft while the published version and Discovery had `chunks: []`, where a `singleLine` on the same variant published fine. If that happens, move the data to the product and identify each row by SKU. -- **Choice (choice, componentChoice)** → select exactly ONE structure from a set. The editor picks which form applies, and only sees fields for that choice. Implements the **Polymorphic Choice** pattern. Use when a concept has multiple valid but mutually exclusive representations. +- **Choice (componentChoice)** → select exactly ONE structure from a set. The editor picks which form applies, and only sees fields for that choice. Implements the **Polymorphic Choice** pattern. Use when a concept has multiple valid but mutually exclusive representations. - **Multiple Choice (componentMultipleChoice)** → select ONE OR MORE structures from a set. Similar to Choice but allows combining forms. Use when multiple structural forms can coexist on the same item. - **Common pattern**: Define each option as a **Piece** (reusable component group) for consistency across shapes - - **Validation**: `allowDuplicates` (API field) — when `false`, each choice can only be selected once; when `true`, editors can add the same choice multiple times (e.g., two Banner sections on a page) + - **Validation**: `allowDuplicates` (API field, `true` when left out) — when `false`, each choice can only be selected once; when `true`, editors can add the same choice multiple times (e.g., two Banner sections on a page) - **Use cases**: - **Page builders**: Multiple Choice of section Pieces (Banner, Testimonials, Product Grid, Text Block) — editors add/remove/reorder sections - **Polymorphic products**: Products that are similar but details vary — add fragmented Pieces (Physical Attributes, Digital License, Subscription Terms) that editors select based on product type @@ -189,7 +195,7 @@ They **CANNOT** have other structural components as direct children: "type": "componentChoice", "config": { "componentChoice": { - "componentConfigs": [ + "choices": [ { "id": "image", "name": "Image", "type": "images" }, { "id": "video", "name": "Video", "type": "videos" } ] @@ -204,9 +210,9 @@ They **CANNOT** have other structural components as direct children: "type": "componentChoice", "config": { "componentChoice": { - "componentConfigs": [ - { "id": "smartphone", "type": "piece", "config": { "piece": { "identifier": "smartphone-spec" } } }, - { "id": "laptop", "type": "piece", "config": { "piece": { "identifier": "laptop-spec" } } } + "choices": [ + { "id": "smartphone", "name": "Smartphone", "type": "piece", "config": { "piece": { "identifier": "smartphone-spec" } } }, + { "id": "laptop", "name": "Laptop", "type": "piece", "config": { "piece": { "identifier": "laptop-spec" } } } ] } } @@ -219,7 +225,7 @@ They **CANNOT** have other structural components as direct children: "type": "componentChoice", "config": { "componentChoice": { - "componentConfigs": [ + "choices": [ { "id": "smartphone", "name": "Smartphone", "type": "contentChunk", "components": [...] } ] } @@ -299,8 +305,10 @@ When building a "Product Type" selector where each type (Smartphone, Laptop, Hea "name": "Smartphone Spec", "components": [ { + "id": "details", + "name": "Details", "type": "contentChunk", // ✓ allowed inside a piece - "components": [...] + "config": { "contentChunk": { "components": [...] } } } ] } @@ -333,7 +341,7 @@ Product Shape │ └─ Level 5: headline (Single Line) ✓ │ └─ Level 5: button-url (Single Line) ✓ └─ Video Hero - └─ Level 4: video-options (Chunk) + └─ Level 4: video-options (Piece) └─ Level 5: video (Videos) ✓ └─ Level 5: autoplay (Switch) ✓ ``` @@ -352,52 +360,54 @@ Product Shape ## Multilingual & Localization -Every component has an **isTranslatable** flag that determines whether content can vary by language or is shared across all languages. This is critical for multi-market catalogues where some data is universal (GTINs, dimensions) while other data needs localization (descriptions, marketing copy). +Every component has a **`multilingual`** config flag that determines whether content can vary by language or is shared across all languages. This is critical for multi-market catalogues where some data is universal (GTINs, dimensions) while other data needs localization (descriptions, marketing copy). ### How Localization Works - **Languages** are configured at tenant level (Settings → Languages) with unique codes (ISO 639-1 recommended: `en`, `de`, `fr`, `no-nb`, `se`, but not required) -- **Each component** has an `isTranslatable` configuration you set when designing shapes +- **Each component** has a `multilingual` configuration you set when designing shapes - **Editors** switch between languages in the item edit view to add translated content -- **isTranslatable = true** → component content can vary per language (localized) -- **isTranslatable = false** → component content is shared across all languages (universal) -- **Built-in name field** is translatable and creates the path leaf in the catalogue tree — if not defined for a language, you'll see `MISSING_NAME_FOR_ITEM` as placeholder +- **`multilingual: true`** → component content can vary per language (localized) +- **`multilingual: false`** → component content is shared across all languages (universal) +- **Built-in name field** is translatable and creates the path leaf in the catalogue tree — if not defined for a language, you'll see `MISSING_NAME_FOR_LANGUAGE` as placeholder - **Query language** is specified at the top level of Catalogue API and Discovery API queries ### Default Localization Settings by Component -Different components have different default `isTranslatable` values based on their typical use cases: - -| Component | Default isTranslatable | Typical use case | -| ------------------------ | ---------------------- | -------------------------------------------------------------- | -| **Single Line** | ✓ True | Titles, subtitles, slogans, taglines | -| **Rich Text** | ✓ True | Descriptions, marketing copy, specifications | -| **Paragraph Collection** | ✓ True | Blog posts, landing pages, editorial content | -| **Numeric** | ✗ False | Dimensions, weights, ratings (universal values) | -| **Properties Table** | ✗ False | Technical specs with shared keys/values | -| **Images** | ✗ False | Product photos (same images, translate captions) | -| **Videos** | ✗ False | Product videos (same videos, translate captions) | -| **Files** | ✗ False | Shared downloads (or create separate per-market) | -| **Selection** | ✗ False | Shared options (size, colour codes) | -| **Colors** | ✗ False | Colour values are universal — `label` is the translatable part | -| **Switch** | ✗ False | Boolean flags (universal true/false) | -| **Datetime** | ✗ False | Universal dates (launch, expiration) | -| **Location** | ✗ False | Geographic coordinates (universal) | -| **Item Relation** | ✗ False | Relationships are shared, but related items can be translated | -| **Grid Relation** | ✗ False | Grid references shared, grid content can be translated | - -**Important**: These are defaults when adding components. You can override them based on your specific use case. +Different components behave differently when `multilingual` is left out of the config: + +| Component | Default multilingual | Typical use case | +| ------------------------ | ------------------------------------- | ---------------------------------------------------------------------------------- | +| **Single Line** | ✓ True | Titles, subtitles, slogans, taglines | +| **Rich Text** | ✓ True | Descriptions, marketing copy, specifications | +| **Paragraph Collection** | ✓ Title and body, ✗ images and videos | Blog posts, landing pages, editorial content | +| **Colors** | ✓ True | The whole entry is stored per language. Set `false` when the colours are universal | +| **Numeric** | ✗ False | Dimensions, weights, ratings (universal values) | +| **Properties Table** | ✗ False | Technical specs with shared keys/values | +| **Images** | ✗ False | Product photos (same images, translate captions) | +| **Videos** | ✗ False | Product videos (same videos, translate captions) | +| **Files** | ✗ False | Shared downloads (or create separate per-market) | +| **Selection** | ✗ False | Shared options (size, colour codes) | +| **Switch** | ✗ False | Boolean flags (universal true/false) | +| **Datetime** | ✗ False | Universal dates (launch, expiration) | +| **Location** | ✗ False | Geographic coordinates (universal) | +| **Item Relation** | ✗ False | Relationships are shared, but related items can be translated | +| **Grid Relation** | ✗ False | Grid references shared, grid content can be translated | + +**Important**: These are the API defaults, for shapes created by mutation or mass operation. You can override them based on your specific use case. A component added in the App starts with explicit values instead: on for Single Line, Rich Text, Multiple Choice and every part of Paragraph Collection, off for everything else, Colors included. ### Structural Components (Choice & Multiple Choice) Choice and Multiple Choice components have special translation behavior: -**When the structural component is NOT translatable:** +**When `multilingual` is left out:** - The same choices are used across all languages (universal structure) -- Components and Pieces INSIDE each choice CAN be translatable +- Components and Pieces INSIDE each choice CAN be translatable. Components inside a Piece follow their own setting, and a plain component chosen directly uses its type's default from the table above - Editors see the same choice options in all languages, but content within choices can vary +An explicit `multilingual: false` is not the same: it forces everything inside to be shared, on a chunk, a choice, a multiple choice or a piece. The App sends nothing rather than `false`, so do the same. + **Example:** ``` @@ -413,13 +423,15 @@ Product Shape In this example, the choice between "Physical Product" and "Digital Product" is the same across all languages, but the descriptions inside can be translated. -**When the structural component IS translatable:** +**When `multilingual: true`:** - The choices themselves become translatable (different choice options per language) - All components inside are ALWAYS translatable (cannot be shared) - This is uncommon — only use when the structure itself varies by market -**Best practice**: Keep Choice/Multiple Choice NOT translatable. Translate the content inside the choices instead. This maintains consistent structure across markets while allowing localized content. +**Best practice**: Leave `multilingual` out on Choice/Multiple Choice and translate the content inside the choices instead. This maintains consistent structure across markets while allowing localized content. Page builder blocks are the exception, see [page-builder-pattern.md](page-builder-pattern.md). + +**On a piece component**, `multilingual` overrides every component inside the piece when it is set. Left out, they follow their own setting. ### Paragraph Collection Granular Control @@ -434,9 +446,9 @@ This granularity is useful for blog posts or editorial content where media is un #### Operations Config for paragraphCollection -**CRITICAL**: The `multilingual` property is **REQUIRED** (not optional) for paragraphCollection. It must be an array of **paragraph part names** that should support translations (lowercase), NOT language codes. +**CRITICAL**: In mass operations, the `multilingual` property is **REQUIRED** (not optional) in a paragraphCollection config. It must be an array of **paragraph part names** that should support translations (lowercase), NOT language codes. -Valid values: `"body"`, `"title"`, `"images"`, `"videos"`, `"structure"` +Valid values: `"body"`, `"title"`, `"images"`, `"videos"`, `"structure"`. `"structure"` gives each language its own set of paragraphs. **Examples:** @@ -465,7 +477,7 @@ Valid values: `"body"`, `"title"`, `"images"`, `"videos"`, `"structure"` ### Localized vs Shared Content Patterns -**When to use localized (isTranslatable = true):** +**When to use localized (`multilingual: true`):** - Marketing copy and storytelling (titles, descriptions, taglines) - Editorial content (blog posts, articles, guides) @@ -473,7 +485,7 @@ Valid values: `"body"`, `"title"`, `"images"`, `"videos"`, `"structure"` - Legal disclaimers that must be in local language - SEO metadata (meta descriptions, page titles) -**When to use shared (isTranslatable = false):** +**When to use shared (leave it out, or `multilingual: false` on a field):** - Universal product identifiers (GTIN, EAN, MPN, internal SKUs) - Technical specifications with numeric values (weight, dimensions, voltage) @@ -558,12 +570,22 @@ query GetProduct($path: String!, $language: String!) { price } # Component fields - description { - plainText + description: component(id: "description") { + content { + ... on RichTextContent { + plainText + } + } } - # Relationship components can specify language - brand { - name # Automatically uses parent query language + # Related items resolve in the parent query language + brand: component(id: "brand") { + content { + ... on ItemRelationsContent { + items { + name + } + } + } } } } @@ -572,7 +594,7 @@ query GetProduct($path: String!, $language: String!) { **For non-translatable components:** -- The default language's content is returned for all queries +- The same shared value is returned for every language - Ensures universal data (GTINs, dimensions, dates) is consistent across markets **For translatable components:** @@ -594,19 +616,18 @@ This gives you full control over fallback behavior based on your business rules ### Planning for Production -**Critical**: Changing `isTranslatable` settings after content is created has side effects. +**Critical**: Changing `multilingual` settings after content is created has side effects. Changing the shape rewrites no item content, so the effects show up on read and on the next save. **When switching from localized to shared:** -- All language variations are discarded -- Content defaults back to the default language value -- All markets will see the same content (from default language) -- **This is irreversible** — translations are permanently lost +- Each language keeps showing its own translation until the item is saved, because the read falls back to it +- The next save writes a shared value, and every language then shows it +- All markets end up seeing the same content, item by item, in whatever language was last saved **When switching from shared to localized:** -- Existing shared content becomes the default language value -- All non-default languages start empty (need new translations) +- Every language keeps showing the existing shared value, because the read falls back to it +- A language only diverges once it gets its own value - Editors must add translations for each market **Best practices:** @@ -616,7 +637,7 @@ This gives you full control over fallback behavior based on your business rules 3. **Test with 2-3 languages early** — verify your translation strategy works before scaling to many markets 4. **Document your decisions** — explain why certain components are/aren't translatable for future maintainers 5. **Use Semantic Bridges for reusable translations** — translate classification documents once, reuse everywhere -6. **Avoid changing isTranslatable in production** — if needed, create new components and migrate content gradually +6. **Avoid changing `multilingual` in production** — if needed, create new components and migrate content gradually ### Localization Examples by Use Case @@ -700,23 +721,27 @@ Does the data need grouping/structure? ## Discoverability Rules -The **discoverable** flag determines whether a component is **indexed in the Discovery API at all**. This is not just about filtering — if a component is NOT marked discoverable, it will be completely absent from Discovery API responses. +The **discoverable** flag determines whether a component is **indexed in the Discovery API at all**. It is **on by default**: a component is indexed unless it sets `discoverable: false`. This is not just about filtering — a component with `discoverable: false` will be completely absent from Discovery API responses. -**Discoverable = true:** +**Discoverable left out, or `true`:** - Component data is indexed and returned in Discovery API queries - Component becomes available as a filter/sort/facet field -- Generated field name follows the convention: `{componentId}_{subfield}_{type}` +- Field names are the component path with hyphens turned to camelCase and dots to `_`. A numeric `weight` filters as `weight_number`, a variant component as `variants_weight_number`, and `hero-title` as `heroTitle`. The schema is generated per tenant, so introspect it rather than guess (see [[query]]) +- An id that clashes with a built-in Discovery field becomes `component_`. Among them: `name`, `type`, `path`, `language`, `position`, `shape`, `topics`, `score`, and on products `variants`, `sku`, `price`, `stock`, `attributes`, on variants also `images` and `videos`, on folders `children`. Pick another id for a component you want to query under its own name -**Discoverable = false:** +**`discoverable: false`:** - Component data is NOT indexed in Discovery API - Component will NOT appear in Discovery API responses at all +- On a chunk, choice or piece it removes everything inside. Set on one nested component, it removes only that one - Data is still available via Catalogue API and other APIs -### When to Mark Components as Discoverable +**Narrowing the query surface:** `searchable`, `filterable`, `sortable` and `facetable` are `true` when left out. Turn one off to drop that capability, which keeps the generated schema and full-text search smaller. The component stays stored and is still returned. On a chunk, choice or piece they cap everything inside: a child can only narrow further. + +### When to Keep Components Discoverable -**Mark as discoverable when:** +**Keep them discoverable when:** - Customers need to filter/search by this field (color, size, brand, price range) - You want to sort results by this field (weight, rating, date) @@ -731,12 +756,12 @@ The **discoverable** flag determines whether a component is **indexed in the Dis - Switch (featured, on-sale) → enables boolean filtering - Item Relations (brand, category) → enables filtering by related items -**Colors is the exception — do not reach for `discoverable` to build a colour filter.** Filter on the +**Colors is the exception — do not build a colour filter on it, even though it is indexed.** Filter on the Selection or topic that carries the colour _name_, and keep the Colors component for the value you render. A hex is a poor facet: shoppers pick "Red", not `#B22222`, and two near-identical hexes will not collapse into one bucket. See "Colors vs Selection" above. -**Do NOT mark as discoverable when:** +**Set `discoverable: false` when:** - Data is for internal use only and should not be exposed in public APIs (external system IDs, integration keys, internal workflow flags) - Content contains sensitive organizational information not meant to be shared outside the organization @@ -759,15 +784,15 @@ Remember: **non-discoverable does NOT mean "hidden"** — the data is still avai ### Discovery vs Catalogue API -- **Discovery API** = fast search/filter engine with async indexing. Only includes discoverable component data. **Eventually consistent** — disconnected from database, updates propagate after a short delay. -- **Catalogue API** = full content retrieval with direct database connection. Includes ALL component data regardless of discoverable flag. **Always in sync** — reflects changes immediately. +- **Discovery API** = fast search/filter engine with async indexing. Only includes discoverable component data. **Eventually consistent** — updates propagate after a short delay. +- **Catalogue API** = full content retrieval. Includes ALL component data regardless of discoverable flag. **Always in sync** — reflects changes immediately. **When to use each:** - **Discovery API** — when you need fast search/filter/faceting and can tolerate eventual consistency (typically seconds to minutes). Works for listing pages AND product detail pages if real-time accuracy isn't critical. - **Catalogue API** — when you need complete data or real-time accuracy. Essential for: admin interfaces, real-time inventory checks, editorial previews, integrations requiring guaranteed up-to-date data. -**Important**: Adding or removing discoverable components requires **re-igniting** the Discovery API for the schema changes to take effect. Plan discoverability at shape design time to minimize re-ignitions. +**Important**: Adding or removing discoverable components, and changing `searchable`, `filterable`, `sortable` or `facetable`, requires **re-igniting** the Discovery API (`igniteDiscoApi`) for the schema changes to take effect. Plan discoverability at shape design time to minimize re-ignitions. ## Component Validations @@ -779,39 +804,40 @@ Every component type supports validation rules to enforce data integrity and gui | ------------------------ | ---------------------------------------------------------- | | **Single Line** | Min/max character length, regex pattern, required/optional | | **Rich Text** | Min/max character length, required/optional | -| **Paragraph Collection** | Min/max number of paragraphs, required/optional | +| **Paragraph Collection** | Required/optional | **Example use cases:** -- Single Line with regex: enforce URL format, validate email addresses, require specific SKU patterns -- Rich Text min length: ensure product descriptions are at least 100 characters -- Paragraph Collection min: require at least 3 sections for blog posts +- Single Line with regex: enforce URL format, validate email addresses, require specific SKU patterns. The API rejects a pattern it considers unsafe (nested quantifiers and the like) +- Rich Text min length: ensure product descriptions are at least 100 characters, counted on the plain text +- Paragraph Collection required: at least one paragraph needs body text. A title or images alone count as empty ### Numeric Components -| Component | Available validations | -| -------------------- | ------------------------------------------------ | -| **Numeric** | Min/max value, decimal places, required/optional | -| **Properties Table** | Min/max number of rows, required/optional | +| Component | Available validations | +| -------------------- | -------------------------------- | +| **Numeric** | Allowed units, required/optional | +| **Properties Table** | Required/optional | + +Numeric has no min or max value: range checks belong in your application. `decimalPlaces` is not a validation either: it truncates the stored number on write (`12.3456` with 2 places becomes `12.34`, it does not round). **Example use cases:** -- Numeric min/max: weight must be between 0.1kg and 500kg, rating 1-5 +- Numeric units: only accept `g` or `kg` - Numeric decimal places: price to 2 decimals, weight to 3 decimals -- Properties Table min rows: require at least 5 specifications per product ### Media Components | Component | Available validations | | ---------- | ---------------------------------------------------------- | -| **Images** | Min/max count, max file size, min/max dimensions, required | -| **Videos** | Min/max count, max file size, max duration, required | +| **Images** | Min/max count, required | +| **Videos** | Min/max count, required | | **Files** | Min/max count, max file size, allowed file types, required | **Example use cases:** -- Images: require at least 3 product photos, max 10, each under 5MB, min 800px wide -- Videos: max 1 demo video, under 100MB, under 3 minutes +- Images: require at least 3 product photos, max 10 +- Videos: max 1 demo video - Files: allow only PDF/DOCX for datasheets, max 10MB per file ### Selection Components @@ -820,7 +846,7 @@ Every component type supports validation rules to enforce data integrity and gui | ------------- | --------------------------------------------------------------------------------------------- | | **Selection** | Min/max selections, required/optional | | **Switch** | No validations (always boolean true/false) | -| **Datetime** | Min/max date range, required/optional | +| **Datetime** | Required/optional | | **Location** | Required/optional | | **Colors** | Min/max number of entries, require label, restrict available colour spaces, required/optional | @@ -830,17 +856,16 @@ Every component type supports validation rules to enforce data integrity and gui - **Checkboxes pattern**: min=0, max=5 → "Select up to 5 dietary preferences" — optional multi-select, can be null - **Required multi-select**: min=1, max=3 → "Select 1-3 colors" — at least one required, up to 3 allowed - **Preselected defaults**: Set `isPreselected: true` on commonly chosen options to prefill the selection for editors. Useful for default sizes, standard shipping modes, or common categories. Multiple options can be preselected. -- Date range: launch date must be in the future, expiration within 2 years - **Colors with min=1, max=1, `requireLabel: true`**: exactly one named brand colour per item -- **Colors with `colorPresets`**: editors can only choose from the approved palette +- **Colors with `colorPresets` and no `customColorSpaces`**: editors can only choose from the approved palette in the App - **Colors with `customColorSpaces: ["hex", "pantone"]`**: a print-facing shape that wants screen and press values, and nothing else ### Relationship Components -| Component | Available validations | -| ----------------- | ------------------------------------------------------- | -| **Item Relation** | Min/max relations, accepted shape identifiers, required | -| **Grid Relation** | Min/max grids, required/optional | +| Component | Available validations | +| ----------------- | ----------------------------------------------------------------- | +| **Item Relation** | Min/max items, min/max SKUs, accepted shape identifiers, required | +| **Grid Relation** | Min/max grids, required/optional | **Example use cases:** @@ -850,23 +875,24 @@ Every component type supports validation rules to enforce data integrity and gui ### Structural Components -| Component | Available validations | -| ------------------- | ------------------------------------------------------------------------------------------------- | -| **Chunk** | Min/max items (for repeating chunks), required | -| **Choice** | Required/optional | -| **Multiple Choice** | Min/max choices selected, `allowDuplicates` (allow same choice multiple times), required/optional | +| Component | Available validations | +| ------------------- | ----------------------------------------------------------------------- | +| **Chunk** | `repeatable` (`false` allows exactly one chunk), required | +| **Choice** | Required/optional | +| **Multiple Choice** | `allowDuplicates` (allow same choice multiple times), required/optional | + +A chunk or multiple choice has no min or max: the number of rows or blocks cannot be capped. **Example use cases:** -- Chunk repeating: recipe must have at least 3 ingredients, kit must have 1-10 components -- Multiple Choice for page builder: min=1 (at least one section), `allowDuplicates: true` (can repeat Banner section multiple times) -- Multiple Choice for polymorphic product: min=1, max=3, `allowDuplicates: false` (product must have 1-3 detail Pieces, each unique) +- Multiple Choice for page builder: `required: true` (at least one section), `allowDuplicates: true` (can repeat Banner section multiple times) +- Multiple Choice for polymorphic product: `required: true`, `allowDuplicates: false` (product must have at least one detail Piece, each unique) ### Validation Best Practices 1. **Start lenient, tighten later** — it's easier to add validations than remove them after content is created 2. **Use validations to prevent common errors** — regex for SKU format, min/max for ratings, required for critical fields -3. **Guide editors with clear validation messages** — "Product description must be at least 100 characters to ensure quality" +3. **Guide editors in the component `description`** — "Product description must be at least 100 characters to ensure quality". The validation messages themselves cannot be customized 4. **Combine with discoverability** — fields used for filtering should have validations ensuring data consistency 5. **Test validations during shape design** — create test items to verify validation rules work as intended @@ -882,7 +908,7 @@ Every component type supports validation rules to enforce data integrity and gui 2. **Will this group repeat multiple times in the same location?** (like a list) - YES → Use a **Chunk with `repeatable: true`** - - NO → Use a **Chunk with `repeatable: false`** (or omit repeatable property) + - NO → Use a **Chunk with `repeatable: false`** ### Examples @@ -964,10 +990,8 @@ Landing Page (Folder shape - folders can have children, documents cannot) ### Configuration -- **Min choices**: 1 (page needs at least one section) -- **Max choices**: unlimited (or set a limit like 20) -- **Only allow unique**: false (editors can add multiple Banners or Text Blocks) -- **Required**: yes +- **`required`**: true (page needs at least one section) +- **`allowDuplicates`**: true (editors can add multiple Banners or Text Blocks) ### Why This Pattern Works @@ -1012,10 +1036,8 @@ Product Shape ### Configuration -- **Min choices**: 1 (product must have at least one detail type) -- **Max choices**: 3 (product can have multiple detail types but not all) -- **Only allow unique**: true (each detail type can only be selected once) -- **Required**: yes +- **`required`**: true (product must have at least one detail type) +- **`allowDuplicates`**: false (each detail type can only be selected once) ### Why This Pattern Works diff --git a/use-crystallize/skills/content-model/references/content-modelling.md b/use-crystallize/skills/content-model/references/content-modelling.md index 773dce5..823d407 100644 --- a/use-crystallize/skills/content-model/references/content-modelling.md +++ b/use-crystallize/skills/content-model/references/content-modelling.md @@ -13,6 +13,7 @@ Every item in Crystallize has built-in fields that exist automatically: - `language` - Language code - `tree` - Catalogue tree position - `topics` - Topic classifications +- `externalReference` - ID of the item in an external system - `createdAt` - Creation timestamp - `updatedAt` - Last modification timestamp @@ -24,6 +25,7 @@ Every item in Crystallize has built-in fields that exist automatically: - `price` - Pricing information - `stock` - Inventory levels - `attributes` - Additional variant attributes +- `externalReference` - ID of the variant in an external system **Components** are the additional, dynamic fields you add to shapes beyond these built-in fields. When designing shapes, you're defining what extra information is needed beyond what Crystallize provides automatically. @@ -199,6 +201,77 @@ Grids → Homepage features, campaigns, seasonal collections - Publisher: Logo, description, catalogue - Series: Description, reading order, theme +## Examples by Industry + +**Fashion:** Product with variants (size/color), material classifications, brand documents, care instructions + +**Food/Recipe Domain:** Complete model using Quantised Bridge pattern: + +``` +Product Shape: Ingredient + ├── Description (Rich Text) + ├── Nutrition (Piece → nutrition) + └── Allergens (Item Relations → allergen docs, acceptedShapeIdentifiers: ["allergen"]) + +Product Shape: Recipe + ├── Description (Rich Text) + ├── Prep Time (Numeric, unit: minutes) + ├── Cook Time (Numeric, unit: minutes) + ├── Servings (Numeric) + ├── Ingredients (contentChunk, repeating) ← Quantised Bridge + │ ├── Quantity (Numeric) + │ ├── Unit (Selection: g, ml, pcs, tbsp, tsp, cup) + │ └── Ingredient (Item Relations → ingredient, acceptedShapeIdentifiers: ["ingredient"]) + ├── Instructions (Paragraph Collection) + └── Allergens (Item Relations → allergen docs, derived from the ingredients by your own integration) + +Product Shape: Meal Kit + ├── Description (Rich Text) + ├── Recipes (contentChunk, repeating) ← Quantised Bridge + │ ├── Servings (Numeric) + │ └── Recipe (Item Relations → recipe, acceptedShapeIdentifiers: ["recipe"]) + └── Dietary Tags (Item Relations → dietary docs) + +Document Shape: Allergen + ├── Icon (Images) + ├── Warning Text (Rich Text) + └── Severity (Selection: mild, moderate, severe) + +Piece: nutrition + ├── Calories (Numeric, unit: kcal) + ├── Protein (Numeric, unit: g) + ├── Carbs (Numeric, unit: g) + └── Fat (Numeric, unit: g) +``` + +This pattern enables: ingredient → recipe → meal kit composition with quantity tracking at each level. The data supports allergen rollup and nutrition calculation, but Crystallize does not compute them: your application or integration does. + +**Electronics:** Product with configuration options, compatibility rules, technical specs, brand classifications + +**Music/Instruments:** Guitar store example using Semantic Bridge pattern: + +``` +Product Shape: Guitar + ├── Description (Rich Text) + ├── Body Type (Item Relations → body-type docs, acceptedShapeIdentifiers: ["body-type"]) + ├── Pickup Configuration (Item Relations → pickup docs, acceptedShapeIdentifiers: ["pickup"]) + ├── Wood Type (Item Relations → wood docs, acceptedShapeIdentifiers: ["wood"]) + ├── Brand (Item Relations → brand doc, acceptedShapeIdentifiers: ["brand"], min:1, max:1) + └── SEO (Piece → seo) + +Classification Documents: + ├── body-type: Stratocaster, Les Paul, Telecaster, SG... + ├── pickup: Single Coil, Humbucker, P90, Active... + ├── wood: Alder, Mahogany, Maple, Rosewood... + └── brand: Fender, Gibson, PRS, Ibanez... +``` + +This pattern allows rich content for each classification (e.g., brand logo, wood tone characteristics) while maintaining type safety through `acceptedShapeIdentifiers`. + +**B2B:** Service packages with tiers, case studies, industry classifications, expertise levels + +**Publishing:** Books with contributors (composite bridge), series, publishers, multi-format variants + ## Common Patterns by Use Case ### E-commerce Fundamentals @@ -318,6 +391,62 @@ Product Shape: Global Product └── APAC Piece (local certifications) ``` +## Common Use Cases + +### E-commerce Product with Variants + +``` +Product Shape: Clothing + ├── Description (Rich Text, translatable) + ├── Material (Item Relations → material documents, acceptedShapeIdentifiers: ["material"]) + ├── Brand (Item Relations → brand document, acceptedShapeIdentifiers: ["brand"], min:1, max:1) + └── Care Instructions (Rich Text, translatable) + +Variant-level (built-in): + ├── Size (attribute) + ├── Color (attribute) + ├── SKU (built-in) + ├── Images (built-in) + └── Price (built-in) +``` + +### Recipe with Ingredients + +``` +Document Shape: Recipe + ├── Description (Rich Text) + ├── Ingredients (contentChunk, repeating) + │ ├── Quantity (Numeric) + │ ├── Unit (Selection: g, ml, pcs) + │ └── Ingredient (Item Relations → ingredient products, acceptedShapeIdentifiers: ["ingredient"]) + └── Allergens (Item Relations → allergen documents, acceptedShapeIdentifiers: ["allergen"]) +``` + +### Blog Post with Topics + +``` +Document Shape: Blog Post + ├── Hero Image (Images) + ├── Content (Paragraph Collection) + ├── Author (Item Relations → author document, acceptedShapeIdentifiers: ["author"]) + ├── SEO (Piece → seo-metadata) + └── topics → Category topic map, Tags topic map +``` + +### Product Configurator + +``` +Product Shape: Configurable Product + └── Options (contentChunk, repeating) + ├── Option (Item Relations → option products, acceptedShapeIdentifiers: ["option"]) + └── Quantity (Numeric) + +Document Shape: Configuration Rule + ├── If Option (Item Relations → option, acceptedShapeIdentifiers: ["option"]) + ├── Rule Type (Selection: requires, excludes, optional) + └── Then Option (Item Relations → option, acceptedShapeIdentifiers: ["option"]) +``` + ## Anti-Patterns to Avoid ### ❌ Over-Structuring @@ -478,7 +607,17 @@ This enforces: ### Required vs Optional Fields -While Crystallize doesn't have a global "required" flag, you can enforce requirements through: +Every component config takes `required`, and leaving it out is not the same as `false`: + +| `required` | Empty component | Component with content | +| ---------- | ----------------------------------------------------------------------- | ---------------------- | +| `true` | Rejected | Fully validated | +| `false` | Accepted. Every other rule is skipped | Fully validated | +| left out | Still held to its rules, so a `min`, `pattern` or `units` makes it fail | Fully validated | + +This is why a new component in the App starts with `required: false`, and why a shape created through the API often needs it set explicitly. These checks run when an item is created or its components are updated. + +You can also enforce requirements through: 1. **Item Relations**: `minItems: 1` makes the field required 2. **Selection**: `min: 1, max: 1` for required single selection @@ -492,6 +631,7 @@ Always restrict Item Relation components to specific shapes: ```json { "id": "ingredients", + "name": "Ingredients", "type": "itemRelations", "config": { "itemRelations": { @@ -520,7 +660,7 @@ Without `acceptedShapeIdentifiers`, editors could link any shape type, breaking ### Discovery API Optimization -- Enable discovery only on fields used for filtering/search +- Discovery indexes every component unless it sets `discoverable: false`, so turn it off on fields nobody searches - Numeric fields with discovery enabled can be sorted/filtered - Rich Text discovery enables full-text search on that content @@ -528,7 +668,7 @@ Without `acceptedShapeIdentifiers`, editors could link any shape type, breaking ### Adding New Components -Safe - existing items get null/empty values for new components. +Safe - existing items get null/empty values for new components. The next update to one of those items is validated against the new definition, though: a new component with `required: true`, or with a `min`, `pattern` or `units` and `required` left out, makes that update fail until it is filled. ### Removing Components @@ -537,7 +677,7 @@ Dangerous - data loss. Consider: 1. Export data first 2. Create new shape with migration path 3. Gradually migrate content -4. Archive old shape when complete +4. Delete the old shape when complete. A shape cannot be deleted while it has items or a role refers to it, and a piece cannot be deleted while a shape or another piece uses it ### Changing Component Types diff --git a/use-crystallize/skills/content-model/references/create-shape-api.md b/use-crystallize/skills/content-model/references/create-shape-api.md index d4b5e52..c05e8b7 100644 --- a/use-crystallize/skills/content-model/references/create-shape-api.md +++ b/use-crystallize/skills/content-model/references/create-shape-api.md @@ -36,6 +36,10 @@ mutation CreateShape($input: CreateShapeInput!) { **Result union:** `CreateShapeResult = Shape | ExperimentalFeaturesNotAvailableError | InvalidIdError | PieceIdentifierTakenError | UnauthorizedError | UnknownError` +Always select `... on BasicError { errorName message }`: most failures arrive as `UnknownError`, with the actual reason in `errorName`. + +**`updateShape(identifier: String!, input: UpdateShapeInput!)`** takes the same fields except `identifier` and `type`. For `components` and `variantComponents`, an omitted key changes nothing, `null` clears the list, and an array replaces it entirely. Unlike the mutation, mass operations treat an omitted list as empty. + --- ## CreateShapeInput @@ -44,16 +48,16 @@ mutation CreateShape($input: CreateShapeInput!) { { name: String! // REQUIRED — display name type: ShapeType! // REQUIRED — see ShapeType enum - identifier?: String // optional — auto-generated from name if omitted + identifier?: String // optional — auto-generated from name if omitted (required in mass operations, 2 to 64 characters) components?: ComponentDefinitionInput[] // shape-level component definitions variantComponents?: ComponentDefinitionInput[] // product-only: custom components on variants - meta?: { key: String!, value?: String }[] // arbitrary key-value metadata + meta?: { key: String!, value?: String }[] // arbitrary key-value metadata (mass operations ignore it) } ``` **`ShapeType` enum:** `document` | `folder` | `product` -**`variantComponents`** is only meaningful on `type: "product"` shapes. Use it to add custom fields to product variants beyond the built-in `sku`, `images`, `videos`, `price`, `stock`, and `attributes`. +**`variantComponents`** is only accepted on `type: "product"` shapes: any other type is rejected. Use it to add custom fields to product variants beyond the built-in `sku`, `images`, `videos`, `price`, `stock`, and `attributes`. --- @@ -63,22 +67,36 @@ Used in both `components` and `variantComponents` arrays, and recursively inside ```typescript { - name: String! // REQUIRED — display name + name: String! // REQUIRED — display name, up to 256 characters type: ComponentType! // REQUIRED — see ComponentType enum - id?: String // optional — identifier used in API queries. Auto-generated from name if omitted. Prefer explicit IDs. - description?: String // optional — help text shown to editors + id?: String // optional — identifier used in API queries. Auto-generated from name if omitted. Prefer explicit IDs. Required in mass operations. + description?: String // optional — help text shown to editors, up to 1024 characters config?: ComponentConfigInput // optional — required for some types (see per-type rules below) } ``` **`ComponentType` enum:** `boolean` | `colors` | `componentChoice` | `componentMultipleChoice` | `contentChunk` | `datetime` | `files` | `gridRelations` | `images` | `itemRelations` | `location` | `numeric` | `paragraphCollection` | `piece` | `propertiesTable` | `richText` | `selection` | `singleLine` | `videos` +**`id` and `identifier` rules:** 1 to 64 characters: letters, digits, space and `; : + @ ( ) . _ - " =`. Every `id` must be unique within its list (`components`, `variantComponents`, `choices`). + --- ## ComponentConfigInput — Per-Type Required Fields The `config` object must match the component `type`. Only set the key that corresponds to the component type. +Every type below also takes these keys, left out of the blocks to keep them short: + +- `searchable`, `filterable`, `sortable`, `facetable`: `true` when left out. They narrow what the Discovery API can do with the field and take effect at the next `igniteDiscoApi`. See [Discoverability Rules](components.md#discoverability-rules). + +Three more behave differently from what the name suggests: + +- `required`: `true` rejects an empty component, `false` accepts one and skips its other rules. Left out, an empty component is still held to its `min`, `minItems`, `pattern` and `units`. +- `discoverable`: `true` when left out. Only `false` keeps a component out of Discovery. +- `multilingual`: see the [default per component](components.md#default-localization-settings-by-component). + +On every type with `min` and `max`, `min` cannot exceed 256, `max` cannot exceed 1048576, and `min` cannot exceed `max`. Some types have a lower ceiling, noted below. + ### boolean ```typescript @@ -110,7 +128,7 @@ config: { } ``` -No required sub-fields. +No required sub-fields. The API rejects a `pattern` it considers unsafe, such as one with nested quantifiers. With `required` left out, an empty value fails the pattern. --- @@ -140,7 +158,7 @@ config: { required?: Boolean discoverable?: Boolean multilingual?: Boolean - decimalPlaces?: Int // 1–64; leave it out for integers (mass operations reject 0) + decimalPlaces?: Int // 1–64; floors the stored number to that many decimals. Leave it out to store the number as sent (mass operations reject 0) units?: String[] // list of allowed units editors can select (e.g. ["kg", "g", "lb"]) } } @@ -158,12 +176,12 @@ config: { required?: Boolean discoverable?: Boolean multilingual?: Boolean - format?: DateComponentFormat // "date" | "datetime" (default: datetime) + format?: DateComponentFormat // "date" | "datetime" (default: date) } } ``` -No required sub-fields. +No required sub-fields. Mass operations do not accept `format`, so a datetime created that way uses `date`. --- @@ -192,7 +210,7 @@ config: { discoverable?: Boolean multilingual?: Boolean min?: Int // min number of images - max?: Int // max number of images + max?: Int // max number of images, 512 at most } } ``` @@ -210,7 +228,7 @@ config: { discoverable?: Boolean multilingual?: Boolean min?: Int - max?: Int + max?: Int // 512 at most } } ``` @@ -228,7 +246,7 @@ config: { discoverable?: Boolean multilingual?: Boolean min?: Int - max?: Int + max?: Int // 512 at most acceptedContentTypes?: { contentType: String! // REQUIRED — MIME type e.g. "application/pdf" extensionLabel?: String // e.g. "PDF" @@ -241,7 +259,7 @@ config: { } ``` -Sub-fields `contentType`, `size`, and `unit` are required when their parent objects are provided. +Sub-fields `contentType`, `size`, and `unit` are required when their parent objects are provided. In mass operations `maxFileSize` itself is required whenever `config.files` is present. It cannot exceed the platform's upload limit. --- @@ -265,7 +283,7 @@ config: { } ``` -**`options` is required** (non-nullable array). Each option requires `key` (the API identifier) and `value`. `label` is optional, but it is good practice to set it on every option — it is the text the App UI displays to editors. Use the same human-readable text as `value` unless the user asks for something different. `label` is accepted by the Core API (Next and legacy) and by mass operations. +**`options` is required** (non-nullable array) and each `key` must be unique. Each option requires `key` (the API identifier) and `value`. `label` is optional, but it is good practice to set it on every option — it is the text the App UI displays to editors. Use the same human-readable text as `value` unless the user asks for something different. `label` is accepted by the Core API (Next and legacy) and by mass operations. --- @@ -278,9 +296,9 @@ config: { discoverable?: Boolean multilingual?: Boolean min?: Int // min number of colour entries - max?: Int // max number of colour entries + max?: Int // max number of colour entries (100 when unset) requireLabel?: Boolean // force every entry to carry a label - colorPresets?: { // fixed palette editors pick from + colorPresets?: { // palette editors pick from label?: String hex?: String rgb?: { r: Int!, g: Int!, b: Int!, a?: Float } @@ -294,11 +312,19 @@ config: { } ``` -Every field is optional — `colors` with no config is valid. One colour entry may carry several -notations at once, so `colorPresets` entries and stored content share the same shape. +Every field is optional — `colors` with no config is valid for the API, but it gives editors nothing to +enter in the App, which shows no colour inputs without `customColorSpaces`. Set `customColorSpaces`, or +`colorPresets`, or both. One colour entry may carry several notations at once, so `colorPresets` entries and +stored content share the same shape. -Use `colorPresets` to lock a shape to a brand palette, and `customColorSpaces` to narrow which -notations editors are offered — a print-facing shape might expose only `hex` and `pantone`. +Use `customColorSpaces` to narrow which notations editors are offered — a print-facing shape might expose +only `hex` and `pantone`. Content in any other notation is rejected. Use `colorPresets` with no +`customColorSpaces` to hold editors to a brand palette in the App. The API does not check content against +the presets. + +Unset `multilingual` stores the whole colour list per language. Set `multilingual: false` when the colours +are universal. Content rules (hex, RAL and Pantone formats) are in +[Components Reference](components.md#i-need-to-store-a-colour). --- @@ -318,7 +344,7 @@ config: { } ``` -`keys` is required when a section is provided. Omit `sections` entirely for fluid/dynamic keys (editors define keys at content time). +`keys` is required when a section is provided, and a key cannot repeat within a section. Omit `sections` entirely for fluid/dynamic keys (editors define keys at content time). --- @@ -332,20 +358,20 @@ config: { multilingual?: Boolean acceptedShapeIdentifiers?: String[] // STRONGLY RECOMMENDED — restrict which shapes can be linked minItems?: Int - maxItems?: Int + maxItems?: Int // 75 at most minSkus?: Int // for linking specific variant SKUs - maxSkus?: Int + maxSkus?: Int // 75 at most quickSelect?: { folders: { - folderId: String! // REQUIRED - }[] // REQUIRED — at least one folder - view?: "nerdy" | "pretty" + folderId: String! // REQUIRED — must be a folder of this tenant + }[] // REQUIRED — at least one folder, 100 at most + view?: "nerdy" | "pretty" // not accepted by mass operations } } } ``` -**Always set `acceptedShapeIdentifiers`** — without it, editors can link any shape, breaking semantic meaning. The `min`/`max` fields (without `Items` suffix) are deprecated aliases — use `minItems`/`maxItems` instead. +**Always set `acceptedShapeIdentifiers`** — without it, editors can link any shape, breaking semantic meaning. Every identifier must name an existing shape when the component is saved. A component holds at most 75 items and 75 SKUs even with no `maxItems` set, so a larger `maxItems` is rejected. The `min`/`max` fields (without `Items` suffix) are deprecated aliases — use `minItems`/`maxItems` instead. --- @@ -358,7 +384,7 @@ config: { discoverable?: Boolean multilingual?: Boolean min?: Int - max?: Int + max?: Int // 512 at most } } ``` @@ -376,7 +402,7 @@ config: { required?: Boolean discoverable?: Boolean multilingual?: Boolean - repeatable?: Boolean // true = editors can add multiple chunk instances + repeatable?: Boolean // true = editors can add multiple chunk instances. Defaults to true: set false for a single group } } ``` @@ -407,8 +433,8 @@ config: { ```typescript config: { componentMultipleChoice: { - choices: ComponentDefinitionInput[] // REQUIRED — min 2 choices (single choice is invalid) - allowDuplicates?: Boolean // true = same choice can be selected multiple times + choices: ComponentDefinitionInput[] // REQUIRED — min 1 choice + allowDuplicates?: Boolean // true = same choice can be selected multiple times. Defaults to true required?: Boolean discoverable?: Boolean multilingual?: Boolean @@ -416,7 +442,7 @@ config: { } ``` -**`choices` is required** (non-nullable). **Minimum 2 choices** — a single-choice componentMultipleChoice is invalid. **`allowDuplicates`** (not `repeatable`) controls whether editors can add the same choice more than once (e.g., two Banner sections on one page). Children MUST be pieces or regular components; not other structural components. +**`choices` is required** (non-nullable). **Minimum 1 choice**. **`allowDuplicates`** (not `repeatable`) controls whether editors can add the same choice more than once (e.g., two Banner sections on one page). Children MUST be pieces or regular components; not other structural components. --- @@ -444,13 +470,12 @@ config: { paragraphCollection: { required?: Boolean discoverable?: Boolean - multilingual: ("body" | "images" | "structure" | "title" | "videos")[] // REQUIRED: array of paragraph parts that are translatable (lowercase) + multilingual?: ("body" | "images" | "structure" | "title" | "videos")[] // array of paragraph parts that are translatable (lowercase). Required in mass operations } } ``` -**Important**: `multilingual` is **required** (not optional). If all parts are localized, use `["title", "body", "videos", "images"]`. For universal content (no localization), use `[]`. -No required sub-fields. +**Important**: mass operations require `multilingual` in this config. If all parts are localized, use `["title", "body", "videos", "images"]`. For universal content (no localization), use `[]`. Left out on `createShape`, title and body are translated, images and videos shared. --- @@ -559,9 +584,15 @@ mutation CreateProductShape { ## Common Errors -| Error | Cause | -| --------------------------------------- | ---------------------------------------------------------------------------------- | -| `InvalidIdError` | `identifier` contains invalid characters (use lowercase letters, numbers, hyphens) | -| `PieceIdentifierTakenError` | The `identifier` is already used by an existing piece | -| `UnauthorizedError` | Access token lacks write permissions | -| `ExperimentalFeaturesNotAvailableError` | Feature not available on current plan | +| Error | Cause | +| --------------------------------------- | -------------------------------------------------------------------------- | +| `ShapeIdentifierTakenError` | A shape with this `identifier` already exists | +| `IdentifierInvalidCharactersError` | `identifier` or component `id` contains a character outside the rule above | +| `PieceNotFoundError` | A `piece` component refers to a piece that does not exist yet | +| `InvalidShapeIdentifiersProvidedError` | `acceptedShapeIdentifiers` names a shape that does not exist yet | +| `ComponentNotNestableError` | A structural component is a direct child of another one | +| `ComponentDepthTooBigError` | Nesting goes past the depth limit | +| `UnauthorizedError` | Access token lacks write permissions | +| `ExperimentalFeaturesNotAvailableError` | Feature not available on current plan | + +Apart from the last two, these arrive as `UnknownError` with this value in `errorName`. diff --git a/use-crystallize/skills/content-model/references/design-patterns.md b/use-crystallize/skills/content-model/references/design-patterns.md index fb11d45..b5bd485 100644 --- a/use-crystallize/skills/content-model/references/design-patterns.md +++ b/use-crystallize/skills/content-model/references/design-patterns.md @@ -63,7 +63,7 @@ You have product attributes that are shared across many products, and those attr 4. Configure the Item Relation component: - **Accepted shapes**: restrict to only the classification document shape (e.g., only "Allergen" documents) - **Min relations**: enforce minimum links (e.g., at least 1 brand required) - - **Max relations**: limit how many (e.g., max 3 materials, or unlimited for allergens) + - **Max relations**: limit how many (e.g., max 3 materials, or leave it out for allergens; the ceiling is 75) 5. Products link to the relevant classification documents These configuration options act like foreign key constraints in a relational database, enforcing data integrity and guiding editors. @@ -118,9 +118,11 @@ Product ──(Item Relation)──→ Classification Document "name": "Brand", "type": "itemRelations", "config": { - "acceptedShapeIdentifiers": ["brand"], - "minItems": 1, - "maxItems": 1 + "itemRelations": { + "acceptedShapeIdentifiers": ["brand"], + "minItems": 1, + "maxItems": 1 + } } }, { @@ -128,9 +130,10 @@ Product ──(Item Relation)──→ Classification Document "name": "Allergens", "type": "itemRelations", "config": { - "acceptedShapeIdentifiers": ["allergen"], - "minItems": 0, - "maxItems": 999 + "itemRelations": { + "acceptedShapeIdentifiers": ["allergen"], + "minItems": 0 + } } } ] @@ -141,7 +144,7 @@ Product ──(Item Relation)──→ Classification Document - `acceptedShapeIdentifiers` — array of shape identifiers that can be linked. Enforces type safety. - `minItems` — minimum required links (0 = optional, 1+ = required) -- `maxItems` — maximum allowed links (use high number like 999 for "unlimited") +- `maxItems` — maximum allowed links. Leave it out for no configured limit: a component holds at most 75 items either way, and a `maxItems` above 75 is rejected Without `acceptedShapeIdentifiers`, editors could accidentally link any shape type, breaking the semantic meaning of the relationship. @@ -207,7 +210,7 @@ Parent Product "type": "product", "components": [ { - "id": "nutritionInfo", + "id": "nutrition-info", "name": "Nutrition Info", "type": "propertiesTable" }, @@ -215,7 +218,7 @@ Parent Product "id": "allergens", "name": "Allergens", "type": "itemRelations", - "config": { "acceptedShapeIdentifiers": ["allergen"] } + "config": { "itemRelations": { "acceptedShapeIdentifiers": ["allergen"] } } } ] } @@ -491,21 +494,21 @@ A single semantic concept can take **one of several mutually exclusive structura ### How it works -Add a **Choice** component to the shape. Define multiple named structures within the Choice, each with its own set of components. The editor selects exactly one. +Add a **Choice** (`componentChoice`) component to the shape. Each option that needs several fields is a **Piece** holding that set of components: an option cannot define components inline. An option that is a single field can be that component directly. The editor selects exactly one. ``` Product Shape └── product-type (Choice) - ├── Guitar + ├── Guitar (Piece) │ ├── body-type (Selection: Solid, Semi-hollow, Hollow) │ ├── strings (Numeric) │ ├── scale-length (Numeric, cm) │ └── pickup-config (Selection: SSS, HSS, HH) - ├── Amplifier + ├── Amplifier (Piece) │ ├── wattage (Numeric, W) │ ├── channels (Numeric) │ └── speaker-size (Numeric, inches) - └── Pedal + └── Pedal (Piece) ├── effect-type (Selection: Overdrive, Delay, Reverb, ...) ├── power-draw (Numeric, mA) └── true-bypass (Switch) @@ -532,16 +535,9 @@ Product Shape - You just need a dropdown value, not different field sets → use **Selection** - The variation is about relationships, not field structure → use a bridge pattern -### Advanced: Polymorphic Choice with Pieces - -For large or reusable field sets, reference **Pieces** inside choice options instead of defining components inline. This makes variants more maintainable and reusable across shapes. - -**When to use this variant:** +### Polymorphic Choice with Pieces -- Choice variants have many components (10+ fields each) -- The same variant appears in multiple choice components or shapes -- You want to update variant fields in one place -- You're building reusable patterns across your model +Reference **Pieces** inside choice options for any option with more than one field. This makes variants more maintainable and reusable across shapes. **Structure:** @@ -549,9 +545,9 @@ For large or reusable field sets, reference **Pieces** inside choice options ins { "id": "flower-type", "name": "Flower Type", - "type": "choice", + "type": "componentChoice", "config": { - "choice": { + "componentChoice": { "choices": [ { "id": "rose", diff --git a/use-crystallize/skills/content-model/references/mass-operation-example.md b/use-crystallize/skills/content-model/references/mass-operation-example.md new file mode 100644 index 0000000..11508d8 --- /dev/null +++ b/use-crystallize/skills/content-model/references/mass-operation-example.md @@ -0,0 +1,114 @@ +# Mass Operation Example + +A complete operations file following the 4-phase ordering described in [SKILL.md](../SKILL.md#mass-operations--4-phase-ordering). + +```json +{ + "version": "1.0.0", + "operations": [ + // Phase 1: Create piece containers (no components) + { + "intent": "piece/upsert", + "identifier": "seo", + "name": "SEO" + }, + { + "intent": "piece/upsert", + "identifier": "hero", + "name": "Hero Section" + }, + // Phase 2: Create shape containers (no components) + { + "intent": "shape/upsert", + "identifier": "product", + "name": "Product", + "type": "product" + }, + { + "intent": "shape/upsert", + "identifier": "brand", + "name": "Brand", + "type": "document" + }, + // Phase 3: Update pieces with components (same identifier - upsert updates existing) + { + "intent": "piece/upsert", + "identifier": "seo", + "name": "SEO", + "components": [ + { "id": "title", "name": "SEO Title", "type": "singleLine" }, + { "id": "description", "name": "SEO Description", "type": "singleLine" } + ] + }, + { + "intent": "piece/upsert", + "identifier": "hero", + "name": "Hero Section", + "components": [ + { "id": "headline", "name": "Headline", "type": "singleLine" }, + { "id": "image", "name": "Hero Image", "type": "images" } + ] + }, + // Phase 4: Update shapes with components (can now safely reference pieces) + { + "intent": "shape/upsert", + "identifier": "product", + "name": "Product", + "type": "product", + "components": [ + { "id": "description", "name": "Description", "type": "richText" }, + { + "id": "seo", + "name": "SEO", + "type": "piece", + "config": { "piece": { "identifier": "seo" } } + }, + { + "id": "brand", + "name": "Brand", + "type": "itemRelations", + "config": { + "itemRelations": { + "maxItems": 1, + "acceptedShapeIdentifiers": ["brand"] + } + } + } + ], + "variantComponents": [ + { + "id": "weight", + "name": "Weight", + "type": "numeric", + "config": { "numeric": { "units": ["g", "kg"], "discoverable": true } } + }, + { + "id": "color", + "name": "Color", + "type": "selection", + "config": { + "selection": { + "options": [ + { "key": "black", "value": "Black", "label": "Black" }, + { "key": "white", "value": "White", "label": "White" } + ] + } + } + } + ] + }, + { + "intent": "shape/upsert", + "identifier": "brand", + "name": "Brand", + "type": "document", + "components": [ + { "id": "description", "name": "Description", "type": "richText" }, + { "id": "logo", "name": "Logo", "type": "images" } + ] + } + ] +} +``` + +**Note:** Using `piece/upsert` or `shape/upsert` in a single operation (without the 4-phase split) will work ONLY if there are no cross-references. When pieces reference other pieces or shapes reference pieces/other shapes, the 4-phase order is required. diff --git a/use-crystallize/skills/content-model/references/page-builder-pattern.md b/use-crystallize/skills/content-model/references/page-builder-pattern.md index ee3c98b..e292359 100644 --- a/use-crystallize/skills/content-model/references/page-builder-pattern.md +++ b/use-crystallize/skills/content-model/references/page-builder-pattern.md @@ -34,31 +34,37 @@ This is the backbone of the page builder. A shape (e.g. `landing-page`, `categor "choices": [ { "id": "banner", + "name": "Banner", "type": "piece", "config": { "piece": { "identifier": "banner" } } }, { "id": "feature-highlights", + "name": "Feature Highlights", "type": "piece", "config": { "piece": { "identifier": "feature-highlights" } } }, { "id": "product-slider", + "name": "Product Slider", "type": "piece", "config": { "piece": { "identifier": "product-slider" } } }, { "id": "story-slider", + "name": "Story Slider", "type": "piece", "config": { "piece": { "identifier": "story-slider" } } }, { "id": "picture-grid", + "name": "Picture Grid", "type": "piece", "config": { "piece": { "identifier": "picture-grid" } } }, { "id": "category-slider", + "name": "Category Slider", "type": "piece", "config": { "piece": { "identifier": "category-slider" } } } @@ -72,7 +78,7 @@ Key flags: - **`allowDuplicates: true`** — editors can use the same block type multiple times on the same page (e.g. two separate banners) - **`multilingual: true`** (localized structure) — different languages/markets can have **completely different blocks selected**. See [Localized Structure](#localized-structure-multilingual) below. -- **`min` / `max`** — optionally enforce minimum (e.g. `1` — page needs at least one section) and maximum number of blocks +- **`required: true`** — the page needs at least one section. `componentMultipleChoice` has no `min` or `max`, so the number of blocks cannot be capped ### 2. Block Pieces — The Reusable Sections @@ -94,9 +100,11 @@ Every block piece embeds the `layout` piece as a component. This gives editors p ```json { "identifier": "layout", + "name": "Layout", "components": [ { "id": "display-width", + "name": "Display Width", "type": "selection", "config": { "selection": { @@ -109,6 +117,7 @@ Every block piece embeds the `layout` piece as a component. This gives editors p }, { "id": "theme", + "name": "Theme", "type": "selection", "config": { "selection": { @@ -124,12 +133,13 @@ Every block piece embeds the `layout` piece as a component. This gives editors p }, { "id": "background-media", + "name": "Background Media", "type": "componentChoice", "config": { "componentChoice": { "choices": [ - { "id": "image", "type": "images" }, - { "id": "video", "type": "videos" } + { "id": "image", "name": "Image", "type": "images" }, + { "id": "video", "name": "Video", "type": "videos" } ] } } @@ -144,9 +154,96 @@ Every block piece embeds the `layout` piece as a component. This gives editors p --- +## Minimal Example + +**Problem:** Editors need flexible page layouts with different section types. + +**Solution:** componentMultipleChoice with piece references, each piece containing repeating chunks for items. + +```json +// Folder Shape: Landing Page (folders can have children, documents cannot) +{ + "components": [ + { + "id": "blocks", + "name": "Page Sections", + "type": "componentMultipleChoice", + "config": { + "componentMultipleChoice": { + "allowDuplicates": true, + "choices": [ + { "id": "banner", "name": "Banner", "type": "piece", "config": { "piece": { "identifier": "banner-section" } } }, + { "id": "usp", "name": "USP", "type": "piece", "config": { "piece": { "identifier": "usp-section" } } }, + { "id": "hero", "name": "Hero", "type": "piece", "config": { "piece": { "identifier": "hero-section" } } } + ] + } + } + } + ] +} + +// Piece: banner-section +{ + "identifier": "banner-section", + "name": "Banner Section", + "components": [ + { "id": "title", "name": "Title", "type": "singleLine" }, + { "id": "description", "name": "Description", "type": "richText" }, + { "id": "background", "name": "Background", "type": "images" }, + { + "id": "ctas", + "name": "Call to Actions", + "type": "contentChunk", + "config": { + "contentChunk": { + "repeatable": true, + "components": [ + { "id": "text", "name": "Text", "type": "singleLine" }, + { "id": "url", "name": "URL", "type": "singleLine" } + ] + } + } + } + ] +} + +// Piece: usp-section +{ + "identifier": "usp-section", + "name": "USP Section", + "components": [ + { "id": "title", "name": "Title", "type": "singleLine" }, + { + "id": "items", + "name": "USP Items", + "type": "contentChunk", + "config": { + "contentChunk": { + "repeatable": true, + "components": [ + { "id": "icon", "name": "Icon", "type": "images" }, + { "id": "title", "name": "Title", "type": "singleLine" }, + { "id": "description", "name": "Description", "type": "richText" } + ] + } + } + } + ] +} +``` + +**Key points:** + +- componentMultipleChoice contains pieces (banner-section, usp-section, hero-section) +- Each piece contains regular components and repeating chunks for items +- Chunks (CTAs, USP items) are inside pieces, not direct children of componentMultipleChoice +- This allows editors to build pages with flexible, reusable sections + +--- + ## Localized Structure (`multilingual`) -The `multilingual: true` flag on `componentMultipleChoice` enables **localized structure** — called "Yes - choice varies per language" in the Crystallize PIM UI. This is the **default and recommended setting** for page builder blocks. +The `multilingual: true` flag on `componentMultipleChoice` enables **localized structure** — called "Yes - choice varies per language" in the Crystallize PIM UI. This is the **default in the UI and the recommended setting** for page builder blocks. The API default is the opposite, so set it explicitly in mass operations and mutations. ### What it means @@ -177,7 +274,7 @@ In rare cases, you want all languages to share the same block selection (only tr - Technical/documentation pages where structure must be consistent across languages - When the editorial team is small and manages all languages from one view -In these cases, set `multilingual: false` — but this is the exception, not the rule. +In these cases, leave `multilingual` out rather than setting it to `false`: an explicit `false` forces every component inside the blocks to be shared, so their text could no longer be translated. This is the exception, not the rule. --- @@ -194,12 +291,13 @@ Used when a field should have one value from a set of _structurally different_ t ```json { "id": "background-media", + "name": "Background Media", "type": "componentChoice", "config": { "componentChoice": { "choices": [ - { "id": "image", "type": "images" }, - { "id": "video", "type": "videos" } + { "id": "image", "name": "Image", "type": "images" }, + { "id": "video", "name": "Video", "type": "videos" } ] } } @@ -219,14 +317,15 @@ Used inside a piece when you need an editor-managed list of structured items tha ```json { "id": "usp", + "name": "USP", "type": "contentChunk", "config": { "contentChunk": { "repeatable": true, "components": [ - { "id": "headline", "type": "singleLine" }, - { "id": "description", "type": "richText" }, - { "id": "icon", "type": "images" } + { "id": "headline", "name": "Headline", "type": "singleLine" }, + { "id": "description", "name": "Description", "type": "richText" }, + { "id": "icon", "name": "Icon", "type": "images" } ] } } @@ -295,12 +394,12 @@ category (folder shape) ← same blocks component as landing-page ### Frontend Rendering -**Switch on block type to render.** When consuming the `blocks` array via the API or GraphQL, the discriminator is the chosen piece identifier (or the component choice key). Map each identifier to its React/component. +**Switch on block id to render.** In the Catalogue API the blocks are in `ComponentMultipleChoiceContent.selectedComponents`, and the discriminator is each entry's `id`, which is the `id` of the chosen option. `type` is `piece` for every block, so it tells you nothing. Map each id to its React/component. ```js // Example pattern -blocks.map((block) => { - switch (block.__typename || block.type) { +selectedComponents.map((block) => { + switch (block.id) { case "banner": return ; case "feature-highlights": @@ -324,7 +423,7 @@ blocks.map((block) => { ``` -**Handle `allowDuplicates`.** The same piece type can appear more than once. Use `index` or a stable `id` (e.g. UUID generated at item creation) as the React key, not the block type name. +**Handle `allowDuplicates`.** The same piece type can appear more than once, and entries of the same type share their `id`. Use the array index as the React key, not the block type name. ### Schema Governance diff --git a/use-crystallize/skills/content-model/references/pieces.md b/use-crystallize/skills/content-model/references/pieces.md index 182d094..305ee8c 100644 --- a/use-crystallize/skills/content-model/references/pieces.md +++ b/use-crystallize/skills/content-model/references/pieces.md @@ -10,7 +10,7 @@ Pieces are independent component groups that exist outside of shapes. Once creat - Created once, used many times - Changes to a piece affect all shapes using it -- Can contain any components including chunks +- Can contain any components including chunks and other pieces, but never itself, directly or through another piece - Support localization ## When to Use Pieces @@ -24,6 +24,33 @@ Use pieces for content that: ## Common Piece Examples +### Standard SEO Piece + +A reusable piece for SEO metadata, commonly added to products, folders (landing pages), and documents (articles): + +```json +{ + "identifier": "seo", + "name": "SEO", + "components": [ + { "id": "title", "name": "Title", "type": "singleLine" }, + { "id": "description", "name": "Description", "type": "richText" }, + { "id": "image", "name": "Image", "type": "images" } + ] +} +``` + +Usage in a shape: + +```json +{ + "id": "seo", + "name": "SEO", + "type": "piece", + "config": { "piece": { "identifier": "seo" } } +} +``` + ### SEO Metadata Standard SEO fields for all content: @@ -64,7 +91,7 @@ Banner (Piece) ├── Background (Images) ├── CTA Text (Single Line) ├── CTA Link (Single Line) -└── Theme (Choice: Light, Dark, Transparent) +└── Theme (Selection: Light, Dark, Transparent) ``` ### Author @@ -138,7 +165,7 @@ Additional options: ## Best Practices -1. **Name clearly** - "SEO Metadata" not "SEO" +1. **Name clearly** - "SEO", "Banner", not "Piece 1" 2. **Single responsibility** - One piece = one concept 3. **Plan for reuse** - Design pieces to work in multiple contexts 4. **Keep pieces small** - Large pieces are harder to maintain @@ -160,15 +187,18 @@ Pieces appear in the API as nested objects: ```graphql { - catalogue(path: "/products/coffee") { + catalogue(path: "/products/coffee", language: "en") { ... on Product { - components { - id + component(id: "seo") { content { - ... on ParagraphCollectionContent { - paragraphs { - title - body + ... on PieceContent { + components { + id + content { + ... on SingleLineContent { + text + } + } } } } diff --git a/use-crystallize/skills/content-model/references/shape-consolidation.md b/use-crystallize/skills/content-model/references/shape-consolidation.md new file mode 100644 index 0000000..eab23ad --- /dev/null +++ b/use-crystallize/skills/content-model/references/shape-consolidation.md @@ -0,0 +1,265 @@ +# Shape Consolidation Strategy + +## Minimizing Product and Folder Shapes + +**Principle:** Keep the number of product and folder shapes to a minimum by using polymorphic patterns instead of creating separate shapes for similar items. + +## When to Consolidate Shapes + +**Use the polymorphic product/folder pattern when:** + +1. **Similarity threshold**: Two or more product/folder shapes share 50%+ of their components + - Example: Plant and Vase both have description and SEO components + +2. **Common base components**: Shapes have the same foundational components but differ in specialized attributes + - Common: description, SEO, images, pricing + - Differ: plant-specific fields (care-level, light-requirement) vs vase-specific fields (material, dimensions) + +3. **Semantic similarity**: Items represent variations of the same concept rather than fundamentally different entities + - Example: Guitar, Amplifier, and Pedal are all music equipment (consolidate into "Music Product") + - Example: Plant and Vase are both home decor items (consolidate into "Home Decor Product") + +## When NOT to Consolidate + +**Keep shapes separate when:** + +1. **Fundamentally different purposes**: Items serve completely different business functions + - Product vs Folder (different shape types) + - Apparel vs Digital Download (different fulfillment, no pricing overlap) + +2. **Low component overlap**: Less than 30% of components are shared + - Consolidation would create a confusing, bloated shape + +3. **Different business logic**: Items require distinct workflows, permissions, or integrations + - Separate checkout flows + - Different inventory systems + - Distinct pricing models + +4. **Editor clarity**: The consolidated shape would confuse content editors + - Too many conditional fields make editing difficult + +## Consolidation Approach + +**Step 1: Identify common components** + +- Extract all shared components (description, SEO, images, etc.) +- These become the base components of the consolidated shape + +**Step 2: Create a type selector** + +- Add a selection component to distinguish between variants +- Example: `product-type` with options "plant", "vase", "bundle" + +**Step 3: Extract variant-specific attributes into pieces** + +- Create separate pieces for each variant's unique components +- Example: `plant-attributes` piece, `vase-attributes` piece + +**Step 4: Use a `componentChoice` for polymorphic structure** + +- Add a `componentChoice` with one piece per variant. The editor picks the form there and only sees its fields +- Nothing links it to the selection from step 2: a component cannot show or hide another one. Keep the selection only if you need it as a filter token + +## Example: Consolidating Plant and Vase into Product + +**Before consolidation (2 shapes):** + +``` +Shape: Plant (product) + ├── description (richText) + ├── care-level (itemRelations → care-level docs) + ├── light-requirement (selection) + ├── mature-size (selection) + └── seo (piece) + +Shape: Vase (product) + ├── description (richText) + ├── material (itemRelations → material docs) + ├── dimensions (contentChunk) + └── seo (piece) +``` + +**After consolidation (1 shape + 2 pieces):** + +```json +{ + "intent": "shape/upsert", + "identifier": "product", + "name": "Product", + "type": "product", + "components": [ + { + "id": "product-type", + "name": "Product Type", + "type": "selection", + "config": { + "selection": { + "options": [ + { "key": "plant", "value": "Plant", "label": "Plant" }, + { "key": "vase", "value": "Vase", "label": "Vase" } + ] + } + } + }, + { "id": "description", "name": "Description", "type": "richText" }, + { + "id": "variant-attributes", + "name": "Product Details", + "type": "componentChoice", + "config": { + "componentChoice": { + "choices": [ + { + "id": "plant", + "name": "Plant Details", + "type": "piece", + "config": { + "piece": { "identifier": "plant-attributes" } + } + }, + { + "id": "vase", + "name": "Vase Details", + "type": "piece", + "config": { + "piece": { "identifier": "vase-attributes" } + } + } + ] + } + } + }, + { + "id": "seo", + "name": "SEO", + "type": "piece", + "config": { "piece": { "identifier": "seo" } } + } + ] +} +``` + +**Supporting pieces:** + +```json +{ + "intent": "piece/upsert", + "identifier": "plant-attributes", + "name": "Plant Attributes", + "components": [ + { + "id": "care-level", + "name": "Care Level", + "type": "itemRelations", + "config": { + "itemRelations": { + "acceptedShapeIdentifiers": ["care-level"], + "minItems": 1, + "maxItems": 1 + } + } + }, + { + "id": "light-requirement", + "name": "Light Requirement", + "type": "selection", + "config": { + "selection": { + "options": [ + { "key": "low", "value": "Low Light", "label": "Low Light" }, + { "key": "medium", "value": "Medium Light", "label": "Medium Light" }, + { "key": "bright", "value": "Bright Light", "label": "Bright Light" }, + { "key": "direct", "value": "Direct Sunlight", "label": "Direct Sunlight" } + ] + } + } + }, + { + "id": "mature-size", + "name": "Mature Size", + "type": "selection", + "config": { + "selection": { + "options": [ + { "key": "small", "value": "Small (under 12\")", "label": "Small (under 12\")" }, + { "key": "medium", "value": "Medium (12-24\")", "label": "Medium (12-24\")" }, + { "key": "large", "value": "Large (24-48\")", "label": "Large (24-48\")" }, + { "key": "xl", "value": "Extra Large (48\"+)", "label": "Extra Large (48\"+)" } + ] + } + } + } + ] +} +``` + +```json +{ + "intent": "piece/upsert", + "identifier": "vase-attributes", + "name": "Vase Attributes", + "components": [ + { + "id": "material", + "name": "Material", + "type": "itemRelations", + "config": { + "itemRelations": { + "acceptedShapeIdentifiers": ["material"], + "minItems": 1, + "maxItems": 3 + } + } + }, + { + "id": "dimensions", + "name": "Dimensions", + "type": "contentChunk", + "config": { + "contentChunk": { + "repeatable": false, + "components": [ + { + "id": "height", + "name": "Height", + "type": "numeric", + "config": { "numeric": { "units": ["cm", "in"] } } + }, + { + "id": "diameter", + "name": "Diameter", + "type": "numeric", + "config": { "numeric": { "units": ["cm", "in"] } } + } + ] + } + } + } + ] +} +``` + +**Benefits:** + +- Single product shape instead of multiple +- Shared components (description, SEO) defined once +- Type-specific attributes cleanly separated into pieces +- Easier to maintain and extend +- Clearer information architecture + +**Trade-offs:** + +- Slightly more complex for editors (`componentChoice` requires selecting variant type) +- Frontend may need to handle polymorphic structure +- Only worthwhile when shapes share significant overlap + +## Mass Operations Pattern for Consolidated Shapes + +When using the 4-phase ordering with consolidated shapes: + +1. **Phase 1**: Create variant-specific pieces (plant-attributes, vase-attributes) +2. **Phase 2**: Create consolidated shape container +3. **Phase 3**: Add components to variant pieces +4. **Phase 4**: Add components (including the `componentChoice` with piece references) to consolidated shape + +This ensures all piece references in the `componentChoice` resolve correctly. diff --git a/use-crystallize/skills/content-model/references/shapes.md b/use-crystallize/skills/content-model/references/shapes.md index c9ca765..b986254 100644 --- a/use-crystallize/skills/content-model/references/shapes.md +++ b/use-crystallize/skills/content-model/references/shapes.md @@ -71,6 +71,7 @@ mutation { type: contentChunk config: { contentChunk: { + repeatable: false components: [ { id: "width" @@ -157,8 +158,8 @@ Components are the atomic fields that make up shapes. | Component | Description | API Type | | ----------- | --------------------------- | ------------ | -| Single Line | Short text (max ~256 chars) | `singleLine` | -| Rich Text | Formatted HTML content | `richText` | +| Single Line | Short text | `singleLine` | +| Rich Text | Formatted content | `richText` | | Numeric | Numbers with optional units | `numeric` | | Boolean | True/false values | `boolean` | | Datetime | Date and time picker | `datetime` | @@ -178,7 +179,7 @@ Images are automatically optimized and served in WebP/AVIF formats with responsi #### Chunk -Groups related fields together. Can be marked as repeatable. +Groups related fields together. Repeatable unless you set `repeatable: false`. ``` Specifications (Chunk, Repeatable) @@ -193,10 +194,10 @@ Polymorphic field where editors select ONE option from predefined choices. ``` Guitar Specs (Choice) -├── Electric Specs +├── Electric Specs (Piece) │ ├── Pickup Type (Single Line) │ └── Wattage (Numeric) -└── Acoustic Specs +└── Acoustic Specs (Piece) ├── Body Wood (Single Line) └── Soundhole Style (Single Line) ``` @@ -207,11 +208,12 @@ Like Choice, but multiple options can be selected simultaneously. ### Relation Components -| Component | Description | Use Case | -| -------------- | ------------------------ | ----------------------------- | -| Item Relation | Links to catalogue items | Related products, cross-sells | -| Grid Relation | Links to grids | Featured collections | -| Topic Relation | Links to topics | Categories, tags | +| Component | Description | Use Case | +| ------------- | ------------------------ | ----------------------------- | +| Item Relation | Links to catalogue items | Related products, cross-sells | +| Grid Relation | Links to grids | Featured collections | + +Topics are not a component: `topics` is a built-in field on items and variants. ## Component Settings @@ -219,26 +221,26 @@ Like Choice, but multiple options can be selected simultaneously. Unique key used in API queries. Auto-generated from name but customizable. -- Use camelCase: `productDescription` +- Use lowercase with hyphens: `product-description` - Keep stable once in production -- Must be unique within the shape +- Must be unique within its component list ### Localization Enable to allow translation into multiple languages. When enabled: - Content can be entered per language -- API queries require language parameter +- Queries pick the language with the `language` argument - Missing translations return null ### Discoverability -Controls whether the field appears in Discovery API: +Controls whether the field appears in Discovery API. It is on unless you set `discoverable: false`: - **Enabled**: Field is searchable and filterable - **Disabled**: Field only in Catalogue API -Use for fields that need search/filter capabilities. +Disable it for internal fields that should stay out of the Discovery API. ### Repeatability (in Chunks) @@ -268,9 +270,9 @@ Result: ## Shape Best Practices 1. **Plan before building** - Sketch your data model first -2. **Use semantic names** - `productDescription` not `field1` +2. **Use semantic names** - `product-description` not `field1` 3. **Group with chunks** - Keep related fields together -4. **Make fields discoverable** - Enable for searchable attributes +4. **Keep internal fields out of Discovery** - Set `discoverable: false` on them 5. **Consider localization** - Enable early if multilingual 6. **Keep shapes focused** - One shape per content type 7. **Version carefully** - Changing shapes affects existing content diff --git a/use-crystallize/skills/content-model/references/taxonomies.md b/use-crystallize/skills/content-model/references/taxonomies.md index c5d030e..da43d88 100644 --- a/use-crystallize/skills/content-model/references/taxonomies.md +++ b/use-crystallize/skills/content-model/references/taxonomies.md @@ -69,42 +69,34 @@ No limit on nesting depth. Design for your use case. Topics are assigned via: - Catalogue UI when editing items -- Topic Relation component in shapes - API mutations +`topics` is a built-in field on items and variants. There is no topic component type to add to a shape. + ### Querying Topics -In Discovery API: +In Discovery API, an item carries the path of each of its topics and of their ancestors, so filtering on a parent topic also matches items tagged with its children: ```graphql { - search(filter: { type: PRODUCT, topics: { path: { equals: "/flavour/fruity/citrus" } } }) { - edges { - node { - name - topics { - name - path - } - } + search(language: en, filters: { type_in: [product], topics: { equals: "/flavour/fruity/citrus" } }) { + hits { + name + path } } } ``` -### Topic Aggregations +### Topic Facets Get facet counts: ```graphql { - search(filter: { type: PRODUCT }) { - aggregations { - topics { - path - name - count - } + search(language: en, filters: { type_in: [product] }, facets: { topics: { limit: 20 } }) { + summary { + facets } } } From 33bbd060d71954de7b293108be2bc69f47cff105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 15:48:41 -0700 Subject: [PATCH 06/29] fix(skills): correct mass-operations graphql, permissions and silent data loss The API examples could not run, the runner's identity and required fields were wrong, and templating failures were misdescribed. Documents two new price list intents, volume tiers, and fields the runner silently drops. --- .../skills/mass-operations/SKILL.md | 91 +++++--- .../mass-operations/references/intents.md | 197 +++++++++++------- .../mass-operations/references/lifecycle.md | 188 +++++++++-------- .../mass-operations/references/limits.md | 117 ++++++----- .../mass-operations/references/templating.md | 120 ++++++----- 5 files changed, 419 insertions(+), 294 deletions(-) diff --git a/use-crystallize/skills/mass-operations/SKILL.md b/use-crystallize/skills/mass-operations/SKILL.md index 013307c..0bbad2e 100644 --- a/use-crystallize/skills/mass-operations/SKILL.md +++ b/use-crystallize/skills/mass-operations/SKILL.md @@ -15,10 +15,12 @@ the same validation and side effects as the equivalent GraphQL mutation. Use this for: tenant seeding/migration, catalog and customer/order imports, replaying or fixing data outside API rate limits, coordinated multi-step changes, and large-scale content updates. -**The runner acts as a tenant admin.** These files are infrastructure artefacts — review them like code. +**The runner acts as the user who created the task, with that user's current roles**, checked on every +operation: a missing permission fails that operation, and a creator who was deleted or has no role on the +tenant fails the whole task. These files are infrastructure artefacts — review them like code. -> Verified against `@crystallize/schema` v6.13.0 and the behaviour of the mass-operations -> runner as of 2026-08-18. These are observed runtime behaviours, not contractual API +> Verified against `@crystallize/schema` v6.17.0 and the behaviour of the mass-operations +> runner as of 2026-10-29. These are observed runtime behaviours, not contractual API > guarantees — re-check anything load-bearing before depending on it. > Several examples in the public docs do not validate, and several documented behaviours differ > from what the runner does — see `references/intents.md` § "Documentation discrepancies". @@ -28,6 +30,7 @@ outside API rate limits, coordinated multi-step changes, and large-scale content | Situation | Path | | ------------------------------------------------------------------ | ------------------------------------------- | | User has the Crystallize CLI, or is doing a production migration | **CLI** (default — recommended) | +| Connected to the Crystallize MCP server | **MCP** (see step 6) | | Browser/app context, CI without CLI, or building tooling around it | **Raw API** (see `references/lifecycle.md`) | ## Workflow @@ -40,7 +43,9 @@ Do not invent IDs, shape identifiers, component IDs, or languages. Confirm with crystallize mass-operation dump-content-model ``` -Component IDs that don't exist on the target shape are the single most common cause of a failed task. +Component IDs that don't exist on the target shape are the most common mistake, and most intents won't +tell you: `item/updateComponent/*` fails with `Invalid component id specified`, but +`product|document|folder /create|update|upsert` silently drop that content. ### 2. Write the file @@ -87,10 +92,11 @@ file's `version`. Two sets exist: `0.0.1` (legacy) and `1.0.0` (current). - `"1.0.0"` — current converters. **Always use this.** - `"0.0.1"` — legacy converters with no result normalisation. This is where the "upsert returns a - different shape" folklore comes from; see `references/templating.md`. -- `"1"`, `"1.0"`, `"*"` — **pass schema validation, then crash the whole task at runtime.** The version - string is fed to `semver.eq`/`semver.lt`, which throw `TypeError: Invalid Version: 1` on any - non-full semver. Verified empirically against semver 7.8.5. + different shape" folklore comes from; see `references/templating.md`. `product/variant/create|update|upsert|delete` + have no handler at this version, so they crash the whole task. `dump-content-model` writes `"0.0.1"`: + change it to `"1.0.0"` before adding operations. +- `"1"`, `"1.0"`, `"*"` — **pass schema validation, then crash the whole task at runtime** with + `TypeError: Invalid Version: 1`. The runner needs a full `major.minor.patch`. ### 4. Identify items by `resourceIdentifier` where it is actually implemented @@ -104,8 +110,9 @@ Expected at least a resourceIdentifier or an id/itemId/topicID. **But passing schema validation does not mean the runner honours it.** Only these intents actually resolve it: `product|document|folder /update` and `/upsert`, `item/delete`, `topic/update|upsert|delete`, -`image/register`, and all `item/paths/*`. Everywhere else it is either rejected at runtime or silently -ignored — the full table is in `references/intents.md` § "Where `resourceIdentifier` actually works". +and all `item/paths/*`. Everywhere else it is either rejected at runtime or silently ignored, and +`image/register` only looks it up and never stores it — the full table is in `references/intents.md` +§ "Where `resourceIdentifier` actually works". ### 5. Order operations as a dependency tree @@ -130,6 +137,10 @@ content model. There is no deferred resolution and no second pass. Chain with `_ref` where there is no natural identifier, and with a shared `identifier` / `resourceIdentifier` where there is. +Shapes or pieces that reference each other in a cycle: create them first with `"components": []` +(`shape/create`, `piece/create`), then send the full definition with `shape/upsert` / `piece/upsert`. +`dump-content-model` writes files in this form. + **Nothing checks this for you.** The schema validates structure, not existence, so a file referencing something that isn't there yet uploads and starts happily — it fails partway through, after earlier operations have already been applied, with no rollback. Worse, `item/updateComponent/item` runs with @@ -140,15 +151,23 @@ array correctly rather than relying on an error. ### 6. Validate and run ``` +crystallize mass-operation validate crystallize mass-operation run ``` -Validates, requests presigned upload, pushes the file, creates the bulk task with `autoStart`, and waits -while tailing logs. Add `--no-interactive` for CI. +`validate` is a local schema check with per-issue output and no network call. `run` validates, requests +presigned upload, pushes the file, creates and starts the bulk task, and waits while tailing logs. Add +`--no-interactive` for CI. `run` only exits on `complete`: a task that ends in `error` keeps it polling +forever, so put a timeout around it and read `bulkTask` yourself. To validate without the CLI, parse the file with `OperationsSchema` from `@crystallize/schema/mass-operation`. **Do this locally every time** — the server discards per-field detail (see step 7). +With the Crystallize MCP server, `build-mass-operation` validates and also reports keys the schema would +silently strip, `run-mass-operation` uploads and starts in one call, and `get-mass-operation-status` +polls. They refuse price tier keys, so a file with volume or graduated tiers needs the CLI or the raw API +(`references/intents.md` § "Schema quirks worth knowing"). + Raw API sequence: `references/lifecycle.md`. ### 7. Monitor and verify @@ -167,9 +186,9 @@ Three ways an operation can leave **no log entry at all**: - the whole task died first, - schema validation rejected the file, so nothing ran. -A schema violation or unparseable JSON marks the task `error` before any operation runs, and -`bulkTask.info.error` reads only `Invalid Operation File` — the per-field issues go to the worker's own -logs, not to you. Validate locally to see them. +A schema violation marks the task `error` before any operation runs, and `bulkTask.info.error` reads +only `Invalid Operation File` — the per-field issues are never returned to you. Validate locally to see +them. Unparseable JSON also marks the task `error`, with the parser's message in `info.error`. **Seeding a tenant that Discovery will serve:** run `igniteDiscoApi` after the operations that create the shapes and content have completed with clean `operationLogs`, never before or alongside them. Poll @@ -185,21 +204,28 @@ the ignition task like any other bulk task, treat `error` as blocking, and probe `order/upsert` and `subscription-contract/upsert` are not unless you pass a real `id`. - **`item/unpublish` is accepted by the schema but not supported by the runner.** It validates and 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. + the only one of the 61 intents in this position. Unpublish via the Core API `unpublishItem` or + `unpublishItems` 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 - `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 - upsert result to a flat `{id}` or `{identifier}`. The `{{#if x.id.id}}…{{else}}…{{/if}}` guard seen in - the public docs is legacy `0.0.1` behaviour — harmless but unnecessary. See `references/templating.md`. + `variants` exactly as `product/create` does. For a sparse patch use the `update` intent: most need only + their key (`references/intents.md`). +- **`components` and `variants` replace the whole list, on `update` as on `upsert`.** A component you + leave out loses its content in that language. A variant you leave out is removed, and a listed variant's + omitted `priceVariants`, `stockLocations`, `images`, `videos` and `attributes` reset to empty. To change + one component use `item/updateComponent/*`. An upsert also re-applies `tree.parentId`, moving the item + back if someone moved it. +- **At `version: "1.0.0"` there is no upsert return-shape trap for items.** The v1.0.0 converters normalise + `product|document|folder /upsert` to a flat `{id}` and `piece|shape|customer /upsert` to `{identifier}`. + The `{{#if x.id.id}}…{{else}}…{{/if}}` guard seen in the public docs is legacy `0.0.1` behaviour — + harmless but unnecessary. See `references/templating.md`. `topic/upsert` is not normalised: read its id + with `fetchTopicByResourceIdentifier`. - **`richText.html` is an array of strings**, not a string. - **Shape and piece component settings go under `config`**, keyed by type: `{"id": "name", "name": "Name", "type": "singleLine", "config": {"singleLine": {"required": true}}}`. The inline form the public docs use (`"singleLine": {"required": true}` as a sibling of `type`) **validates and is then silently discarded** — the component is created with default settings. -- **`componentChoice` / `componentMultipleChoice` need ≥2 choices, and each choice needs a `type`.** +- **`componentChoice` needs ≥2 choices, `componentMultipleChoice` ≥1, and each choice needs a `type`.** When a choice carries more than one field, make it a `piece/upsert` and reference it with `{"type": "piece", "config": {"piece": {"identifier": "…"}}}` — an inline `components` array on a choice is not a valid API structure. Structural components (`contentChunk`, `componentChoice`, @@ -207,9 +233,13 @@ the ignition task like any other bulk task, treat `error` as blocking, and probe - **ID fields accept handlebars.** `IdSchema` is `/^(?:[0-9a-f]{24}|{{.*}})$/` — a 24-char hex ID or a `{{ ... }}` 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 - `{{ myRef.id }}` as data rather than failing the operation. +- **A reference that resolves to nothing renders as an empty string, and the operation still runs.** A + `_ref` that doesn't exist yet, a failed or skipped operation and a wrong property all give `""`. On a + text field that stores a blank; on an upsert's `itemId` it reads as "no id", so the upsert creates; on + `tree.parentId` the item is created **outside the catalogue tree**, logged as a success. So one failed + parent folder orphans its children. Only a template that throws (a syntax error, a failing helper) is + written through literally, storing `{{ myRef.id }}` as data. +- **`{{ x }}` HTML-escapes its output** (`&` becomes `&`). Copy text between operations with `{{{ x }}}`. - **`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 @@ -224,12 +254,17 @@ the ignition task like any other bulk task, treat `error` as blocking, and probe `numeric` `decimalPlaces` floors the stored value; choice/selection values not in the config are filtered out; strings are never trimmed. Read `references/limits.md` before writing content at scale. - **Chunk large jobs** so a failure doesn't force replaying everything. -- **Never guess an intent name.** The union in `references/intents.md` is exhaustive as of v6.13.0. +- **Orders and subscription contracts lose fields the schema accepts.** `payment` keeps only `provider`, + and `order/register` is asynchronous, so a later operation in the same file can miss the order. Read + `references/intents.md` § "Schema quirks worth knowing" before importing orders. +- **`item/publish` is one item at a time and only a request.** `includeDescendants` validates and is + ignored, and `success` means accepted, not yet published. +- **Never guess an intent name.** The union in `references/intents.md` is exhaustive as of v6.17.0. - **Presigned URLs are short-lived.** Upload immediately after requesting one. ## Reference files -- `references/intents.md` — all 59 intents, required fields, verified `_ref` outputs, silent skips, +- `references/intents.md` — all 61 intents, required fields, verified `_ref` outputs, silent skips, limits, where `resourceIdentifier` works, corrected examples, docs bugs - `references/templating.md` — `_ref`, positional refs, handlebars, `defaults.*`, `upload`, `fetch*` - `references/limits.md` — every enforced limit: file-level, batch caps, silent data loss, config diff --git a/use-crystallize/skills/mass-operations/references/intents.md b/use-crystallize/skills/mass-operations/references/intents.md index 8c47bb1..abc2c26 100644 --- a/use-crystallize/skills/mass-operations/references/intents.md +++ b/use-crystallize/skills/mass-operations/references/intents.md @@ -1,12 +1,12 @@ # Intents `intent` is a `z.literal` in a `z.discriminatedUnion`. The list below is **exhaustive** for -`@crystallize/schema` v6.13.0 — 59 intents. An intent not listed here will fail validation. +`@crystallize/schema` v6.17.0 — 61 intents. An intent not listed here will fail validation. The rest of the operation object is the matching GraphQL input's fields, **inlined at the top level**. Runtime facts on this page describe what the mass-operations runner actually does, verified as of -2026-08-18. They are observed behaviours, not contractual guarantees. +2026-10-29. They are observed behaviours, not contractual guarantees. ## Contents @@ -24,14 +24,14 @@ Runtime facts on this page describe what the mass-operations runner actually doe ## Not supported by the runner **`item/unpublish` is in the schema but the runner has no handler for it.** It is the only one of the -59 intents in this position. +61 intents in this position. The failure mode is unusually harsh. The file validates locally, uploads, and the task starts — then fails when it reaches the operation, and unlike an ordinary per-operation error it is **not** caught and logged. The task is marked `error` and **every remaining operation is abandoned**. Operations already applied are not rolled back. -Unpublish via the Core API `unpublishItem` mutation instead. +Unpublish via the Core API `unpublishItem` or `unpublishItems` mutation instead. ## Required fields per intent @@ -40,56 +40,64 @@ Derived by parsing empty payloads against the real schema. `_ref` is optional on **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` | `language`, + id — everything else optional | +| `folder/upsert` | as create | +| `document/create` | `name`, `shapeIdentifier`, `tree`, `language` | +| `document/update` | `language`, + id — everything else optional | +| `document/upsert` | as create | +| `product/create` | `name`, `shapeIdentifier`, `tree`, `vatTypeId`, `variants`, `language` | +| `product/update` | `language`, + id — everything else optional | +| `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`, `shape/upsert` | `identifier`, `name`, `type` | +| `shape/update` | `identifier`, `name` — **omitted `components` are wiped**, see quirks below | +| `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`, `operation` | +| `product/variant/price/modify` | `sku`, `priceVariantIdentifier`, `price` | +| `customer/create`, `customer/update`, `customer/upsert` | `identifier` | +| `customer/group/create`, `customer/group/update`, `customer/group/upsert` | `identifier` | +| `order/register` | `cart`, `customer` | +| `order/update` | + id — **runner requires `id` specifically**; everything else optional | +| `order/upsert` | `cart`, `customer` — **`pipelines` is omitted from this intent** | +| `subscription-contract/create` | `customerIdentifier`, `subscriptionPlan`, `status`, `item`, `recurring` | +| `subscription-contract/update` | + id — **runner requires `id` specifically**; everything else optional | +| `subscription-contract/upsert` | as create | +| `pricelist/create`, `pricelist/upsert` | `identifier`, `name`, `modifierType`, `priceVariants`, `selectedProductVariants`, `targetAudience` | +| `pricelist/update` | `identifier` | +| `pricelist/selectedProductVariants/upsert` | `identifier`, `variants` (`[{sku, priceVariants?}]`). Optional `ignoreInvalidSkus` | +| `pricelist/selectedProductVariants/remove` | `identifier`, `skus` | +| `topic/create` | `name`, `language` | +| `topic/update` | `language`, + id (`topicId` or `resourceIdentifier`) | +| `topic/upsert` | `name`, `language`, + id (`topicId` or `resourceIdentifier`) | +| `topic/delete` | + id | +| `flow/create`, `flow/upsert` | `name`, `stages`, `identifier`, `type` | +| `flow/update` | `identifier` | +| `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. +you pass — the runner always uses `en` for shortcut operations, not the tenant default language. + +`product/variant/stock/modify` `operation` is `increase`, `decrease` or `overwrite`. + +`pricelist/selectedProductVariants/*` add or remove SKUs on an existing list without resending it. The list +must exist. `/upsert` fails on an unknown SKU unless `ignoreInvalidSkus` is true, which skips it and reports +it in `skippedSkus`. `/remove` fails on an `ALL_SKUS` list. ## Where `resourceIdentifier` actually works @@ -98,14 +106,14 @@ 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 /update` | ✅ resolved, in every 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/delete` | ✅ resolved | | `topic/create` | ✅ stored on the new topic | -| `image/register` | ✅ used as a dedupe key — if already registered, the operation is skipped | +| `image/register` | ⚠️ looked up, **never stored** — a re-run registers a new image, see below | | `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` | @@ -113,10 +121,15 @@ converters carry a literal `ResourceIdentifier is not implemented yet` error. | `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** | +| `order/register` | ❌ accepted and ignored, not stored on the order | +| `subscription-contract/create` | ❌ accepted and ignored, not stored on the 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`). +`image/register` skips the operation if an image already carries that `resourceIdentifier`, but it does not +write the field and no public API sets it on an image, so that match does not happen in practice. + ## Which upserts are idempotent An `upsert` is only idempotent if the converter has something to look the entity up by. @@ -137,19 +150,24 @@ An `upsert` is only idempotent if the converter has something to look the entity 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 -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. +duplicate product on every run. The item lookup is **not** language-scoped: the same `itemId` or +`resourceIdentifier` finds the item in every language, so upserting under a second language writes that +translation on the existing item. `order/upsert` and `subscription-contract/upsert` with an `id` that +doesn't exist create a new record under a generated id, not yours. ## Properties exposed to `_ref` An operation's `_ref` output is the normalised result of the command it ran. The same value is stored -in `OperationLog.output`, which is the authoritative view. +in `OperationLog.output`, which is the authoritative view. A failed or skipped operation stores `{}`. **Verified** (at `version: "1.0.0"`): | Intent | `_ref` output | | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `product/create`, `folder/create`, `document/create` | `{ id, language, versionLabel }` — `{{ myRef.id }}` is the item id | | `product/upsert`, `folder/upsert`, `document/upsert` | `{ id: string }` — flat, in all branches | +| `topic/create` | an **array** of `{ id, language }`, root first, then descendants — `{{ myRef.[0].id }}` | +| `order/register` | `{ id, createdAt }` | | `shape/upsert` | `{ identifier: string }` | | `piece/create`, `piece/upsert` | `{ identifier: string }` | | `customer/create`, `customer/update`, `customer/upsert` | `{ identifier: string }` | @@ -158,6 +176,11 @@ in `OperationLog.output`, which is the authoritative view. | `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 | +| `pricelist/selectedProductVariants/upsert` | `{ identifier, upsertedSkus, skippedSkus }` | +| `pricelist/selectedProductVariants/remove` | `{ identifier, removedSkus }` | + +**`topic/upsert` has no stable output:** the `topic/create` array when it created, an object with a nested +id when it updated. Read the id with `fetchTopicByResourceIdentifier` instead. **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 @@ -169,14 +192,13 @@ Note that `item/publish` is special-cased by the runner and stores its raw resul ## Silent skips -If a converter returns `null`/`undefined`, the runner's `if (command)` guard is false: **no command -runs, no `OperationLog` row is written, and no `_ref` output is saved.** The operation vanishes. A -`_ref` pointing at it resolves to nothing, and positional `_ref` numbering shifts (see -`references/templating.md`). +If the runner builds no command for an operation, **nothing runs and no `OperationLog` row is written.** +The operation vanishes from the logs. Its `_ref` and position still exist and hold `{}`, so +`{{ myRef.id }}` renders empty and positional numbering does not shift (see `references/templating.md`). Known cases: -- `image/register` whose `resourceIdentifier` is already registered — intentional dedupe. +- `image/register` whose `resourceIdentifier` already exists on an image. - `item/delete` where neither `itemId` nor a resolvable `resourceIdentifier` yields an id. - `topic/delete` where the `resourceIdentifier` doesn't resolve. - `item/paths/setAliases | setShortcuts | setHistory` targeting the tree **root** node. @@ -201,22 +223,47 @@ counting the whole subtree; **50 items** per `item/flow/stage/addItems`; **500 c subscription contract. - **`piece/update` also uses `CreatePieceInputSchema`**, so it too requires the full payload — and the converter sends the whole input, so omitted components are dropped, not preserved. -- **`order/upsert` omits `pipelines`** — present on `order/register`, rejected here. +- **`shape/update` validates with only `identifier` and `name`, but it is not a patch.** `components` and + `variantComponents` default to empty, so omitting them wipes them from the shape. The same goes for a + `shape/upsert` that hits an existing shape. Always send the full definition. +- **`order/upsert` omits `pipelines`** — present on `order/register`, silently stripped here. - **`product/variant/update` omits `sku` and `isDefault` from the variant input, then `.partial()`s the - rest**, re-adding `sku` as required. It's the only genuinely patch-style intent. + rest**, re-adding `sku` as required. +- **`product|document|folder /update` are patches, except for `components` and `variants`.** Either one, + when sent, replaces the whole list. See `SKILL.md`. +- **`topic/upsert` ignores `children` when the topic already exists.** Add child topics with `topic/create` + and `parentId`. +- **`subscription-contract/update` resets `addresses` to empty** unless you resend them. +- **`order/*` drops fields the schema accepts.** `relatedOrderIds`, `stockLocationIdentifier` and a cart + line's `subscription` are dropped on every order intent. `appliedPromotions` is dropped on `order/register` + and on the create branch of `order/upsert`. +- **`payment` keeps only `provider`**, on `order/*` and `subscription-contract/*`. The provider details you + send under `stripe`, `klarna`, `paypal`, `cash` or `custom` validate and are discarded, with no error. +- **`order/register` is asynchronous.** You get `{ id, createdAt }` straight away, but the order is stored + later. Later in the same file, `order/update` can fail, `fetchOrderById` can return `null`, and + `order/upsert` can register a second order. Run follow-ups in a separate task. +- **`item/publish` only requests the publish.** `success` lists accepted ids and the published version + appears afterwards. It does validate component content, so content written by `item/updateComponent/item` + can fail here as `partial` / 206. `includeDescendants` validates and is ignored: publish each item. - **`flow/*` requires `identifier`** on top of the flow input. - **`product/variant/create` and `/upsert` require `productId`**; `/update` and `/delete` key off `sku`. - **`version` regex is `/^(\d+\.)?(\d+\.)?(\*|\d+)$/`** — looser than the runner. Only `1.0.0` is safe; `1`, `1.0` and `*` validate and then crash the task. See `SKILL.md` § "The `version` field". - **`priceVariants[].tierType` and `.tiers` are stripped by the schema but re-attached by the runner.** - `enrichPriceVariantTiers` walks the parsed result against the raw JSON and puts them back for - `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 — - there is an explicit `blocked on @crystallize/schema bump` comment in both variant converters. + It reads them back from the raw file for `product/create|update|upsert` and + `product/variant/create|update|upsert`. So volume and graduated tiers work 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. + - `tierType` is `volume` or `graduated`; `tiers` is `[{ "threshold": 1, "price": 100 }, …]`. + - The schema still requires `price`, and it must equal the first tier's price. The first `threshold` + must be 1, thresholds must strictly increase, and every price must be above 0. A breach fails that + operation. + - Upload the raw file, not the output of `OperationsSchema.parse`, which has no tiers left. The CLI + uploads the raw file. The MCP tools refuse these keys as unrecognised, so use the CLI or the raw API. + - `product/variant/price/modify` is flat: on a tiered price it replaces the whole ladder with one price. +- **`variant.topicIds` is silently dropped.** Also stripped by the schema, and _not_ re-attached. Assign variant topics another way. -- **`item/updateComponent/item` runs with `disableContentValidation: true`.** Component content is +- **`item/updateComponent/item` runs with content validation disabled.** Component content is written without validation, so malformed content lands silently. `item/updateComponent/sku` does not disable it. @@ -311,7 +358,7 @@ it duplicates the product on every run. Corrected: ### Piece upsert (docs version is invalid) The published version omits `name` on the component. Every component definition needs both `id` and -`name`: +`name`, and its settings go under `config`: ```json { @@ -321,7 +368,9 @@ The published version omits `name` on the component. Every component definition "intent": "piece/upsert", "identifier": "rating-system", "name": "Rating System", - "components": [{ "id": "name", "name": "Name", "type": "singleLine", "singleLine": { "required": true } }] + "components": [ + { "id": "name", "name": "Name", "type": "singleLine", "config": { "singleLine": { "required": true } } } + ] } ] } diff --git a/use-crystallize/skills/mass-operations/references/lifecycle.md b/use-crystallize/skills/mass-operations/references/lifecycle.md index a0d88ee..28db885 100644 --- a/use-crystallize/skills/mass-operations/references/lifecycle.md +++ b/use-crystallize/skills/mass-operations/references/lifecycle.md @@ -1,6 +1,6 @@ # Lifecycle: upload, run, monitor -Verified against runner and Core API behaviour as of 2026-08-18. +Verified against the CLI, runner and Core API behaviour as of 2026-10-29. ## Contents @@ -22,26 +22,36 @@ 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. +identifiers and component IDs instead of guessing them. The file is written with `version: "0.0.1"`: change +it to `"1.0.0"` before adding operations. `--no-item-ids` removes references to existing item IDs, and +`--force` overwrites an existing file. + +``` +crystallize mass-operation validate +``` + +Checks the file against the schema locally, with no network call, and lists each issue per operation. ``` crystallize mass-operation run ``` -Validates → requests presigned upload → pushes the file → creates the bulk task with `autoStart` → -waits for completion while tailing logs. +Validates → requests presigned upload → pushes the file → creates and starts the bulk task → waits for +completion while tailing logs. It only exits on `complete`: a task that ends in `error` keeps it polling +forever, so put a timeout around it in CI. ``` -crystallize mass-operation execute-mutations [image-mapping-file] +crystallize mass-operation execute-mutations [image-mapping-file] [file-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 -an old Spec File into a mass operation file. +`crystallize mass-operation add-operation ` appends operation skeletons to a file. + +Commands that reach the API support `--no-interactive` for CI, take `--token_id` and `--token_secret`, and +reuse stored credentials. `run --legacy-spec` converts an old Spec File into a mass operation file. -The CLI lives in `CrystallizeAPI/tools` — a different repo, so these command signatures are **not** -verified against the runner source and may drift. `crystallize mass-operation --help` is authoritative. +`crystallize mass-operation --help` is authoritative. ## Raw API path @@ -62,7 +72,7 @@ X-Crystallize-Access-Token-Secret: ```graphql mutation GeneratePresignedUpload($filename: String!, $contentType: String!) { - generatePresignedUploadRequest(input: { type: MASS_OPERATIONS, filename: $filename, contentType: $contentType }) { + generatePresignedUploadRequest(type: MASS_OPERATIONS, filename: $filename, contentType: $contentType) { ... on PresignedUploadRequest { url fields { @@ -71,19 +81,23 @@ mutation GeneratePresignedUpload($filename: String!, $contentType: String!) { } } ... on BasicError { - error + message errorName } } } ``` -`MASS_OPERATIONS` routes to a dedicated bucket, separate from `MEDIA` and `STATIC`. Presigned URLs are -short-lived — upload immediately. +`MASS_OPERATIONS` routes to dedicated private storage, separate from `MEDIA` and `STATIC`. Presigned URLs +are short-lived — upload immediately. + +On Node, `createBinaryFileManager(client).uploadMassOperationFile(path)` from `@crystallize/js-api-client` +does steps 1 and 2 and returns the key. ### 2. Upload the file -Multipart form POST. **Order matters: all presigned fields first, the file last.** +Multipart form POST. **Order matters: all presigned fields first, the file last.** Send every entry of +`fields`, whatever its name: the set varies and the upload is refused if one is missing. Success is `201`. ```typescript async function uploadToPresignedUrl( @@ -114,15 +128,9 @@ Equivalent with curl: ```bash curl -X POST "$PRESIGNED_URL" \ - -F "key=$KEY" \ - -F "bucket=$BUCKET" \ - -F "X-Amz-Algorithm=$ALGORITHM" \ - -F "X-Amz-Credential=$CREDENTIAL" \ - -F "X-Amz-Date=$DATE" \ - -F "X-Amz-Security-Token=$TOKEN" \ - -F "Policy=$POLICY" \ - -F "X-Amz-Signature=$SIGNATURE" \ - -F "file=@operations.json" + -F "$FIELD_1_NAME=$FIELD_1_VALUE" \ + -F "$FIELD_2_NAME=$FIELD_2_VALUE" \ + -F "file=@operations.json" # one -F per entry of fields, in order, then the file ``` ### 3. Register the bulk task @@ -135,7 +143,7 @@ mutation CreateMassOperationBulkTask($key: String!, $autoStart: Boolean) { status } ... on BasicError { - error + message errorName } } @@ -154,7 +162,7 @@ mutation StartMassOperationBulkTask($id: ID!) { status } ... on BasicError { - error + message errorName } } @@ -162,7 +170,8 @@ mutation StartMassOperationBulkTask($id: ID!) { ``` Note: this resolver reads the task **before** dispatching it, so the `status` in the response is the -pre-start value (`pending`). Poll `bulkTask` for the real state. +pre-start value (`pending`). Poll `bulkTask` for the real state. Starting a task that isn't `pending` is +refused. Both mutations are flagged `EXPERIMENTAL: the full feature set is not yet complete.` in the schema. @@ -170,16 +179,16 @@ Both mutations are flagged `EXPERIMENTAL: the full feature set is not yet comple Worth knowing when a task behaves oddly: -1. A task is only picked up while its status is `pending`. Anything else is ignored outright. -2. The file is fetched from S3 and parsed with `OperationsSchema.safeParse`. On failure the worker logs - each issue to **its own** logs and throws `Invalid Operation File`. -3. `enrichPriceVariantTiers` re-attaches `tierType`/`tiers` that the schema stripped from - `priceVariants` (see `intents.md`). -4. An empty `operations` array stops the task as `error` with cause - `No operations retrieved from spec file`. -5. Files above a configured size threshold are not run in the worker at all — the worker broadcasts a - spawn request and a standalone task runs them. Behaviour is identical; only the execution host - differs. +1. A task is only picked up while its status is `pending`. Anything else is ignored outright. A `key` + that points at a missing or empty file is dropped too, and the task stays `pending` with no error. +2. The runner loads the user who created the task, with their current roles. If that user no longer + exists or has no role on the tenant, the task stops as `error` with `Cannot run bulk task …`. +3. The file is fetched and parsed with `OperationsSchema.safeParse`. On failure the issues stay in the + runner's own logs and the task gets `Invalid Operation File`. +4. `tierType`/`tiers` that the schema stripped from `priceVariants` are re-attached from the raw file (see + `intents.md`). +5. An empty `operations` array stops the task as `error`, with + `info.cause` set to `No operations retrieved from spec file`. 6. Operations then run **strictly sequentially** in array order. Queued image uploads for each operation run concurrently (up to 10) and are awaited before the next operation starts. @@ -193,20 +202,19 @@ query GetBulkTaskStatus($id: ID!) { ... on BulkTaskMassOperation { id status - info { - error - errorName - stack - } + info } ... on BasicError { - error + message errorName } } } ``` +`info` is a JSON scalar, so select it with no sub-fields. On a runtime error it holds `error`, `errorName` +and `stack`. + Also available: `bulkTasks(filter: { type: massOperation })`. Lifecycle: **`pending → started → complete | error`**. The status enum is exactly @@ -227,18 +235,24 @@ say. ```graphql query OperationLogs($id: ID!, $first: Int) { operationLogs(filter: { operationId: $id }, first: $first) { - edges { - node { - status - statusCode - message - input - output + ... on OperationLogConnection { + edges { + node { + status + statusCode + message + input + output + } + } + pageInfo { + hasNextPage + endCursor } } - pageInfo { - hasNextPage - endCursor + ... on BasicError { + message + errorName } } } @@ -246,11 +260,12 @@ query OperationLogs($id: ID!, $first: Int) { `operationId` is the **bulk task id**. It's a paginated connection, so page through it — a large import will not return every log in one call. The filter also accepts two fields the docs don't mention: -`status` (`success` | `partial` | `failure`) and `operationTypeStartsWith`. Filtering on -`status: failure` is the fastest triage. +`status` (`success` | `partial` | `failure`) and `operationTypeStartsWith`, an intent prefix such as +`product/`. Filtering on `status: failure` is the fastest triage. Each entry stores the original input payload, the command executed, the result, a `status` -(`success` / `partial` / `failure`), and a `statusCode`. +(`success` / `partial` / `failure`), and a `statusCode`. On a failure `message` is generic: the reason is +in `output.error.message`. | status | statusCode | When | | --------- | ---------- | ---------------------------------------------------------- | @@ -258,8 +273,7 @@ Each entry stores the original input payload, the command executed, the result, | `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. +Those are the only three the runner emits. **Check these even when the task says `complete`.** Operation-level failures are logged and the runner moves on to the next operation — there is no implicit rollback. @@ -281,42 +295,48 @@ operation whose `{{ upload ... }}` queued them. "info": { "error": "Invalid Operation File", "errorName": "Error", - "stack": "Error: Invalid Operation File\n at OperationsFetcherService.fetch..." + "stack": "Error: Invalid Operation File\n at ..." } } } ``` Schema violations and unparseable JSON mark the whole task `error` and no operation runs — unlike a -runtime failure on a single operation, which is logged and stepped over. +runtime failure on a single operation, which is logged and stepped over. For unparseable JSON, `error` +holds the parser's message instead. ## 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 | The per-field issues are not returned in `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`: `info.cause` `No operations retrieved…` | Empty `operations` array | Check the file you uploaded | +| Task `error`: `Cannot run bulk task …` | The task's creator was deleted or has no role on the tenant | Recreate the task as a user with a role on the tenant | +| Task stuck in `pending` | `autoStart: false` and never started, or `key` has no uploaded file behind it | Call `startMassOperationBulkTask`, or re-upload and create a new task | +| `crystallize mass-operation run` never returns | The task ended in `error`: `run` only exits on `complete` | Read `bulkTask`, and put a timeout around `run` | +| 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 (syntax error or failing helper); raw string is stored | Fix the template or the helper call | +| A field is blank where a reference should have resolved | The `_ref` is missing, failed or skipped, or the property is wrong | Check the `_ref` operation's log and its real `output` | +| Text contains `&` or `'` | `{{ x }}` HTML-escapes its output | Use `{{{ x }}}` | +| 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 upload 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` to the `bulkTask` query | +| `Invalid component id specified: …` | `item/updateComponent/*` on a component that isn't on that shape | Re-check against `dump-content-model` output | +| `Component type or content not provided.` | A component entry has no recognised type key | Fix the key, e.g. `singleLine` | ## Browser CORS -The S3 bucket may block cross-origin requests from a browser. Options, in order of preference: +The upload URL may block cross-origin requests from a browser. Options, in order of preference: 1. **Use the CLI** — recommended for production migrations 2. **Server-side proxy** — route uploads through your backend @@ -326,10 +346,10 @@ The S3 bucket may block cross-origin requests from a browser. Options, in order export default defineConfig({ server: { proxy: { - "/api/s3-upload": { - target: "https://crystallize-mass-operations-production.s3.eu-central-1.amazonaws.com", + "/api/mass-operation-upload": { + target: "", changeOrigin: true, - rewrite: (path) => path.replace(/^\/api\/s3-upload/, ""), + rewrite: (path) => path.replace(/^\/api\/mass-operation-upload/, ""), }, }, }, diff --git a/use-crystallize/skills/mass-operations/references/limits.md b/use-crystallize/skills/mass-operations/references/limits.md index 6eabd1b..3b559d0 100644 --- a/use-crystallize/skills/mass-operations/references/limits.md +++ b/use-crystallize/skills/mass-operations/references/limits.md @@ -2,7 +2,7 @@ Every bound that can break a mass operation, or silently change what it writes. -Compiled from an exhaustive audit of runner behaviour as of 2026-08-18, covering every component +Compiled from an exhaustive audit of runner behaviour as of 2026-10-29, covering every component type, primitive and value object. These are **observed limits, not documented API guarantees** — re-check anything load-bearing before depending on it. @@ -32,13 +32,12 @@ because the value is legal — it is the _behaviour_ that differs from what you ## 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 rejected | +| Operations per file | **no cap** | — | +| `version` format | only `1.0.0` is safe, see `SKILL.md` | whole task fails at runtime | **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 @@ -51,7 +50,7 @@ you one chunk, and validate locally before every upload. | `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** | | +| `order/*` | **100 applied promotions** | only sent by `order/update` and the update branch of `order/upsert` | | 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** | | @@ -74,8 +73,12 @@ loss in the whole format. stores an empty list rather than erroring. Same for `numeric: null` (writes a contentless component), `chunks` missing/null (empty chunk list), `paragraphs` omitted (**wipes the whole collection**), per-paragraph `images` omitted (wipes that paragraph's images). -- **Unrecognised component types are dropped** by `filterComponents()` via the mapper's `unknownType` - branch — no error, the component simply does not appear. +- **A `componentChoice` or `piece` with `null` content is dropped** on item and variant writes — no + error, the component simply does not appear. An entry with no recognised type key at all fails the + operation with `Component type or content not provided.` +- **Content for a `componentId` the shape doesn't have, or with a type key that doesn't match the shape, + is dropped** on `product|document|folder /create|update|upsert` — no error. Only + `item/updateComponent/*` reports it. - **Stored content is dropped when its type no longer matches the definition**, e.g. after a shape change. @@ -99,8 +102,7 @@ Treat `propertiesTable` as config-driven: the shape decides the shape of the dat - **`decimalPlaces` FLOORS the stored number.** It is not a display setting — the value is truncated on write, and it does not round. - **`decimalPlaces: 0` is rejected** by mass-operation validation (`Too small: expected number to be >0`). - Leave the key out for integers. If a shape's config holds 0 anyway, the runner's content path takes a - `parseInt(String(...))` branch that destroys small-magnitude numbers. + Leave the key out for integers. - An empty-string `unit` is silently dropped in content, but **throws** in shape config. ### Choice and selection filter to configured options @@ -116,28 +118,29 @@ rather than an error. ### Strings, dates, identifiers -- **`ValidatedString` never trims.** Leading/trailing whitespace is stored _and counts toward every +- **Strings are never trimmed.** 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`. +- **Strings are coerced via `toString()`** where the schema lets a non-string through — 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 silently produces a different stored path. -- **`KeyValuePair` coerces an empty-string value to `null`.** +- **An empty-string `value` in a key/value pair can be coerced to `null`.** - **Tree-path collisions are silently rewritten** with a random suffix — one 5-char attempt, one 10-char, then a forced 15-char. Your requested path is not necessarily the stored one. - **Colour entries are whitelisted to exactly seven fields**; anything else is dropped. ### Retention -- **Path history keeps 100 entries** per item/language, enforced by a Mongo `$slice: -100`. No error - type exists. +- **Path history keeps the last 100 entries** per item/language. Older ones drop off with no error. - **Archived published versions keep 50** per item/language; older ones are removed on publish. ### Images -- Remote download cap **50 MiB**, per-image timeout **30 s**. Both surface as separate `image/upload` - log entries with status 500 — **the operation that queued the image still reports success.** +- Remote download cap **50 MiB**, per-image timeout **30 s**. The source must answer with a + `Content-Length` header and a `Content-Type` of `image/jpg`, `jpeg`, `png`, `webp`, `tiff`, `tif` or + `avif`. Every breach surfaces as a separate `image/upload` log entry with status 500 — **the operation + that queued the image still reports success.** ## Config traps @@ -146,8 +149,9 @@ Cases where two components behave oppositely from the same JSON. - **`max: 0` on a `files` component silently means 512.** A `max` of `0` is treated as "unset" for files and falls back to the ceiling. The same `max: 0` on `images` or `videos` rejects **every** entry. Identical JSON, opposite outcome. -- **`required: false` disables the configured `min` entirely.** `{min: 3, required: false}` accepts - zero items. **Omitting `required` is stricter than setting it to `false`.** +- **`required: false` skips the configured `min` when the content is empty.** `{min: 3, required: false}` + accepts zero items, but one item still fails. **Omitting `required` is stricter than setting it to + `false`.** - **`richText` `min`/`max` of 0 is a silent no-op** — the opposite of `singleLine`. - **`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. @@ -170,35 +174,37 @@ Ceilings on the `max` you may configure, and therefore on content. ## 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 identifier | **2–64**, charset as below | +| Flow identifier | **1–64**, charset as below | +| Identifier 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 | `#RGB`, `#RRGGBB` or `#RRGGBBAA` | +| 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** | **integer, 0 or more** | +| Stock (elsewhere) | integer, **fractional input silently truncated** | ## Structural rules @@ -222,7 +228,6 @@ Minimums and shape rules, not maximums. ## Operational note -A worker that dies mid-run leaves the task stuck in `started`. SQS redelivery after the 90 s -visibility timeout is **refused**, because `getTask` accepts only `pending` tasks and `markStarted` -fires before the run — so there is no double execution, but also no automatic retry. A task sitting in -`started` with no progress needs manual intervention. +A run that dies midway leaves the task stuck in `started`. Only `pending` tasks are picked up, so there +is no double execution, but also no automatic retry. A task sitting in `started` with no progress needs +manual intervention. diff --git a/use-crystallize/skills/mass-operations/references/templating.md b/use-crystallize/skills/mass-operations/references/templating.md index 5305a86..4f41905 100644 --- a/use-crystallize/skills/mass-operations/references/templating.md +++ b/use-crystallize/skills/mass-operations/references/templating.md @@ -1,15 +1,15 @@ # Templating: references, helpers, and variables Operations run sequentially, so a later operation can consume an earlier one's output. The templating -layer is Handlebars (async, via `handlebars-async-helpers`) — `{{ ... }}` expressions are resolved by -the runner immediately before each operation is converted to a command. +layer is Handlebars with async helpers — `{{ ... }}` expressions are resolved by the runner immediately +before each operation is converted to a command. **The schema is templating-aware.** `IdSchema` is `/^(?:[0-9a-f]{24}|{{.*}})$/`: an ID field accepts a 24-character hex ID *or* a handlebars expression, and nothing else. A placeholder like `"TODO"` or `""` fails validation immediately — which is useful, because it means malformed references are caught locally rather than at runtime. -Verified against runner behaviour as of 2026-08-18. +Verified against runner behaviour as of 2026-10-29. ## Contents @@ -24,17 +24,26 @@ Verified against runner behaviour as of 2026-08-18. ## How enrichment works -`MassOperationEnricherService.enrich` walks the operation object recursively and renders **every string -value** as a Handlebars template. Two consequences: +The runner walks the operation object recursively and renders **every string value** as a Handlebars +template. Two consequences: - Any string field can contain `{{ ... }}`, not just ID fields. - The `_ref` key itself is skipped, so a `_ref` name is never templated. -**A template that throws is swallowed.** `MassOperationTemplateRenderer.renderTemplate` catches the -error, logs it, and returns the **original template string**. So a broken reference does not fail the -operation — it writes the literal `{{ myRef.id }}` into your data, and you find out later. On an ID -field the downstream command then fails with a confusing cast error; on a text field it just persists. -Grep your `operationLogs` output for `{{` after a run. +**A reference that resolves to nothing renders as an empty string.** A `_ref` that doesn't exist yet, a +failed or skipped operation and a wrong property all give `""`, and the operation still runs with the +blank value. On a text field that stores a blank. On an upsert's `itemId` it reads as "no id", so the upsert +creates. On `tree.parentId` the item is created **outside the catalogue tree** and logged as a success, so +one failed parent folder orphans its children. `item/publish` and `item/updateComponent/item` do fail on a +blank `itemId`. Check `input` in `operationLogs` for blanks after a run. + +**A template that throws is swallowed.** A syntax error or a failing helper does not fail the operation: +the runner returns the **original template string**, so the literal `{{ myRef.id }}` lands in your data. +On an ID field the downstream command then fails with a confusing cast error; on a text field it just +persists. Grep your `operationLogs` output for `{{` after a run. + +**`{{ x }}` HTML-escapes its output.** `Tom & Jerry` renders as `Tom & Jerry`, and quotes and `=` are +escaped too. IDs are unaffected. Copy text with the triple form, `{{{ x }}}`. ## `_ref` chaining @@ -64,36 +73,30 @@ Tag an operation with `_ref` (a non-empty string), then reference its output by Which properties a `_ref` exposes depends on the intent — see `intents.md` § "Properties exposed to `_ref`". Some outputs are a bare string rather than an object; for those, use `{{ myRef }}` directly. -An operation whose converter produced no command saves nothing at all, so a `_ref` to it resolves to -empty (see `intents.md` § "Silent skips"). +An operation that failed, or that produced no command, stores `{}`, so `{{ myRef.id }}` renders empty +(see `intents.md` § "Silent skips"). ## Positional references -Undocumented, but implemented and unit-tested: a reference whose first segment parses as a number is -looked up by **operation number** rather than by `_ref`. +Undocumented, but implemented: a reference whose first segment parses as a number is looked up by +**operation number** rather than by `_ref`. ```handlebars {{ 0.firstName }} ``` -`MassOperationTemplateRenderer.getOperationId` does `Number.isNaN(+id)` and routes to -`findByOperationNumber(taskId, n)` when it's numeric. - -The counter is **0-based and assigned at save time**, not from your file's array index: -`MassOperationRepository.save` sets `operationNumber = count(existing rows for this task)`. Only -operations that actually produced a command are saved — a silently-skipped operation writes no row, -so everything after it shifts down by one relative to the file. +The number is the operation's **0-based index in the file**. Every operation gets a slot, including +failed and skipped ones, which hold `{}`, so numbering never shifts. -**Prefer named `_ref`.** Positional references are brittle against exactly the failure mode that is -hardest to notice. +**Prefer named `_ref`.** Positional references break as soon as the file is reordered or chunked. ## The upsert return-shape trap (legacy only) The public docs warn that an upsert returns `{ id }` when it creates and `{ id: { id } }` when it updates, and prescribe a Handlebars conditional to cope. -**That applies to `version: "0.0.1"` only.** At `version: "1.0.0"` the runner flattens every upsert -result before storing it, whichever path the backend took — create or update. `product/upsert`, +**That applies to `version: "0.0.1"` only.** At `version: "1.0.0"` the runner flattens these upsert +results before storing them, whichever path the backend took — create or update. `product/upsert`, `folder/upsert` and `document/upsert` all yield a flat `{ id: string }`. `shape/upsert`, `piece/create|upsert` and `customer/create|update|upsert` normalise to `{ identifier: string }`. @@ -114,7 +117,7 @@ Keep it only if you genuinely target `version: "0.0.1"`. You should not. ## Default context variables -Injected by the runner without any `_ref`, assembled once per (tenant, task) by `DefaultsService`: +Injected by the runner without any `_ref`, assembled once per task: | Variable | Source | | --- | --- | @@ -123,14 +126,14 @@ Injected by the runner without any `_ref`, assembled once per (tenant, task) by | `{{ defaults.tenantIdentifier }}` | tenant identifier, falling back to the id | | `{{ defaults.rootItemId }}` | the catalogue tree root | | `{{ defaults.languages.[0] }}` | **tenant default language**; `[1..]` are the custom languages | -| `{{ defaults.vatTypeIds.[0] }}` | vat type ids, **first 100 only** (the repository query is capped) | +| `{{ defaults.vatTypeIds.[0] }}` | vat type ids, **first 100 only** | These are the idiomatic way to satisfy required ID fields portably. `product/create` and `product/upsert` both require `vatTypeId`, which must match `IdSchema` — so `"vatTypeId": "{{ defaults.vatTypeIds.[0] }}"` is how you write a product operation that runs against any tenant. Likewise `{{ defaults.rootItemId }}` for a top-level `tree.parentId`. -Note the `.[0]` bracket syntax for array indexing — Handlebars requires it for numeric segments. +Note the `.[0]` bracket syntax for array indexing — the form Handlebars documents for numeric segments. `defaults.vatTypeIds` has no guaranteed ordering, so `[0]` means "some vat type", not "the default one" — name the vat type explicitly if it matters. @@ -141,9 +144,12 @@ one" — name the vat type explicitly if it matters. {{ upload "https://my-image.com/image.jpg" "hero-image-1" }} ``` -The optional **second argument is a `resourceIdentifier`** (undocumented). If an image is already -registered under it in this tenant, `upload` returns that image's existing key and nothing is -re-fetched — this is what makes image imports re-runnable. +The optional **second argument is a `resourceIdentifier`** (undocumented). If an image in this tenant +already carries it, `upload` returns that image's existing key and nothing is re-fetched. + +**This does not make an import re-runnable on its own.** Nothing writes that field: `image/register` only +looks its `resourceIdentifier` up and does not store it on the image, and no public API sets one. A second +run of the file below downloads the image again under a new key and registers a second image. ```json { @@ -156,11 +162,12 @@ re-fetched — this is what makes image imports re-runnable. Mechanics worth knowing: - `upload` **does not upload during the expression.** It reserves a storage key and queues the job. - The actual download-and-upload runs after the operation completes, on a `p-queue` with concurrency - **10**, and the runner awaits `queue.onIdle()` before the next operation. -- Within one task, repeated calls with the same URL return the same key (deduped in the runner's - SQLite scratch DB) — so calling `upload` inline in several operations is safe. -- The filename is `path.basename(sourceUrl)`, so query strings and redirect URLs can produce odd names. + The actual download-and-upload runs after the operation completes, up to **10** at a time, and the + runner waits for them before the next operation. +- Within one task, repeated calls with the same URL return the same key — so calling `upload` inline in + several operations is safe. +- The filename is the last segment of the URL, so query strings and redirect URLs can produce odd names. +- The source must send `Content-Length` and an allowed image `Content-Type` (`references/limits.md`). - Image uploads are logged as their own entries with `"intent": "image/upload"` and statusCode 200/500, **separate** from the operation that called `upload`. A failed image does not fail its operation. - You can call `upload` inline anywhere a key is expected in another operation. @@ -171,7 +178,7 @@ fetch helpers below do. ## Fetch helpers For pulling in entities that already exist in the tenant, rather than ones created earlier in the file. -Ten helpers, all registered via `@HandlebarsHelper`: +Ten helpers: | Helper | Arguments | | --- | --- | @@ -188,8 +195,7 @@ Ten helpers, all registered via `@HandlebarsHelper`: **Always pass every argument explicitly.** Handlebars appends its own options hash as the final argument, so omitting `language` puts that object into the `language` slot rather than falling back to -a default. The runner's own tests call the helper as -`fetchItemById('item-1', 'en', 'published', {})` — the trailing `{}` is the options hash. +a default. ```handlebars {{ valueOf (fetchItemById '626af86d6f9c909e39ecb8a6' 'en' 'published') 'name' }} @@ -197,30 +203,38 @@ a default. The runner's own tests call the helper as ``` `fetchItemById`'s `version` argument is coerced: **only the literal `'published'` is honoured, anything -else silently becomes `'draft'`** (`version === 'published' ? 'published' : 'draft'`). So `'latest'` or -`'live'` quietly reads the draft. +else silently becomes `'draft'`**. So `'latest'` or `'live'` quietly reads the draft. + +To feed an ID into an intent that needs one, read the right property. `id` on these results is a composite +object, not the plain ID: + +```handlebars +{{ valueOf (fetchItemByResourceIdentifier 'hero-product' 'en') 'itemId' }} +{{ valueOf (fetchItemById '626af86d6f9c909e39ecb8a6' 'en' 'draft') 'id.id' }} +{{ valueOf (fetchTopicByResourceIdentifier 'colors' 'en') 'id.id' }} +``` ### Caching and staleness -Six helpers read through a per-task cache (`MassOperationResourceCacheService`): `fetchItemById`, +Six helpers read through a per-task cache: `fetchItemById`, `fetchOrderById`, `fetchCustomerByIdentifier`, `fetchCustomerGroupByIdentifier`, `fetchPriceListByIdentifier`, `fetchSubscriptionContractById`. The cache is emptied at task start and end. -After each successful operation the runner calls `refreshFetchedResourceCache`, which invalidates only -these intents: +After each successful operation the runner refreshes the cache for these intents only: `product|document|folder /update|upsert` · `order/update|upsert` · `customer/update|upsert` · `customer/group/update|upsert` · `pricelist/update|upsert` · `subscription-contract/update|upsert` **Anything else leaves the cache stale.** In particular `item/updateComponent/item|sku`, -`product/variant/*`, `topic/*` and `image/register` do *not* invalidate — so a `fetchItemById` after a -component update in the same task can return pre-update data. The item refresh is also keyed on -`operation.itemId`, so an update resolved via `resourceIdentifier` alone may not invalidate either. +`product/variant/*`, `topic/*` and `image/register` do *not* refresh it — so a `fetchItemById` after a +component update in the same task can return pre-update data. The item refresh also needs `itemId` on the +operation and only re-reads the `draft` version, so an upsert keyed on `resourceIdentifier` alone, or a +cached `'published'` read, stays stale too. The four uncached helpers — `fetchItemByResourceIdentifier`, `fetchProductVariantBySku`, -`fetchTopicByResourceIdentifier`, `fetchImageByResourceIdentifier` — hit the repository every time and -are always fresh. Prefer them when you need to read something you just wrote. +`fetchTopicByResourceIdentifier`, `fetchImageByResourceIdentifier` — read live every time and are always +fresh. Prefer them when you need to read something you just wrote. `fetchOrderById` and `fetchSubscriptionContractById` load by raw id and then verify tenant ownership, returning `null` for a foreign-tenant record — indistinguishable from "not found". @@ -237,8 +251,10 @@ Reads a property off a helper result. It **splits on `.`**, so dotted paths work ```handlebars {{ valueOf (fetchItemById '626af86d…' 'en' 'draft') 'name' }} -{{ valueOf (fetchItemById '626af86d…' 'en' 'draft') 'components.description.content.plainText' }} +{{ valueOf (fetchItemById '626af86d…' 'en' 'draft') 'components.0.content.text' }} ``` -Implementation is `prop.split('.').reduce((acc, curr) => acc?.[curr], await obj)`, so a missing segment -yields `undefined` (rendered as an empty string) rather than throwing. +`components` is an array in shape order, so address a component by position, not by its id. A +`singleLine` holds `content.text`; a `richText` holds `content.json`, not plain text. + +A missing segment yields `undefined` (rendered as an empty string) rather than throwing. From 72a66dd4c2350a143fdda62fd6be8b63ebe4e5f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 15:48:49 -0700 Subject: [PATCH 07/29] fix(skills): say the MCP mass-operation tools refuse price tiers A price variant with tierType or tiers fails their validation, so those files need the CLI or the raw API. --- use-crystallize/skills/pricing/references/price-variants.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/use-crystallize/skills/pricing/references/price-variants.md b/use-crystallize/skills/pricing/references/price-variants.md index b938e87..5d85c94 100644 --- a/use-crystallize/skills/pricing/references/price-variants.md +++ b/use-crystallize/skills/pricing/references/price-variants.md @@ -213,7 +213,7 @@ A ladder is refused unless the first `threshold` is 1, thresholds strictly incre `tierConfig` on the price variant (`policy: disabled | optional | required`, `allowFreeform`, `presets`) shapes the tier editor in the app. API writes to a product variant are not checked against it. -In a mass operation, `tierType` and `tiers` go on `priceVariants[]` of the `product/*` and `product/variant/*` intents, with a few parsing caveats listed in [[mass-operations]]. `product/variant/price/modify` is flat only, with a minimum of 1. +In a mass operation, `tierType` and `tiers` go on `priceVariants[]` of the `product/*` and `product/variant/*` intents, with a few parsing caveats listed in [[mass-operations]]. The MCP mass-operation tools refuse both keys, so a file with tiers needs the CLI or the raw API. `product/variant/price/modify` is flat only, with a minimum of 1. ## Naming Convention Guide From 37c9bb78c5ebd7fd806648d1d6472e05d9843edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 15:53:54 -0700 Subject: [PATCH 08/29] fix(skills): correct vector-ranking window, indexing and invalid examples The rerank window is the first N by sorting, not the top matches, and taste-only queries rank the whole result set. Also fixes field names, the item taste query, and publish and ignition behaviour. --- .../skills/vector-ranking/SKILL.md | 105 +++++++++-------- .../references/personalization.md | 40 ++++--- .../references/ranking-signals.md | 110 ++++++++++++------ .../references/vocabulary-authoring.md | 79 ++++++++----- 4 files changed, 209 insertions(+), 125 deletions(-) diff --git a/use-crystallize/skills/vector-ranking/SKILL.md b/use-crystallize/skills/vector-ranking/SKILL.md index a6613ea..3dbca96 100644 --- a/use-crystallize/skills/vector-ranking/SKILL.md +++ b/use-crystallize/skills/vector-ranking/SKILL.md @@ -33,13 +33,15 @@ Nothing is learned; the vocabulary is the model. There is nothing to train and n > the Discovery vector-search documentation as of the same day. Where the public docs and the served > schema disagree, this skill follows the schema and says so. Ranking is a capability that is enabled > per tenant — how it is served is an implementation detail and is deliberately not documented here. +> Window, paging and indexing behaviour was reviewed again on 2026-10-29. ## Detect the capability before you write a query **This is the failure mode that catches agents.** Ranking is enabled **per tenant**. Until it is, `rankBy`, `context` and `nearestTo` are **absent from that tenant's Discovery schema** — referencing them -is a GraphQL validation error, not a query that quietly returns unranked results. The surface can also -disappear again if ranking is disabled for a tenant. +is a GraphQL validation error, not a query that quietly returns unranked results. A tenant is served for +ranking once an ignition with `stacks: opensearch` has completed. The surface can also disappear again +if ranking is disabled for a tenant. ```graphql # Probe once, cache the answer. Non-null => this tenant is served for ranking. @@ -62,11 +64,11 @@ the argument in the schema as the capability check. Three enums are generated **per tenant**, so they are never hardcodable: -| Enum | Built from | Gotcha | -| ---------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `TenantVocabularyIdentifier` | your vocabularies | a new vocabulary is only a valid value **after the next index run**; until a tenant has one, the argument is a `String` | -| `TenantRankByField` | NUMBER and DATE **filterable** attributes, **facet fields excluded** | it is _not_ "any numeric field" — introspect it | -| `TenantRankByTieBreaker` | sortable fields (token, number, date) | `tieBreaker` is **required** on every `rankBy` | +| Enum | Built from | Gotcha | +| ---------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | +| `TenantVocabularyIdentifier` | your vocabularies, names normalized for GraphQL | a new vocabulary is only a valid value **after the next index run**; until a tenant has one, the argument is a `String` | +| `TenantRankByField` | NUMBER and DATE **sortable** attributes, **facet fields excluded** | it is _not_ "any numeric field": `sortable: false` on a component drops it — introspect it | +| `TenantRankByTieBreaker` | sortable fields (token, number, date) | `tieBreaker` is **required** on every `rankBy` | Introspect all three rather than guessing: @@ -95,12 +97,13 @@ Introspect all three rather than guessing: | API | Endpoint | Auth | Used for | | ------------- | ------------------------------------------------ | ----------------- | ------------------------------------------------- | | **Core** | `https://api.crystallize.com/@/core` | access token pair | vocabularies, taste entries, publishing, indexing | -| **Discovery** | `https://api.crystallize.com//discovery` | none | querying | +| **Discovery** | `https://api.crystallize.com//discovery` | none by default | querying | Core takes `@tenant` with an at-sign; Discovery takes the bare `tenant`. Vocabulary and index mutations live on **Core**, not on the PIM API. Everything before Discovery is authenticated and server-side; the queries themselves need no credentials, so a browser can build the shopper vector client-side and call -Discovery directly. +Discovery directly. A tenant that protects search answers `401` until each request carries its static +token in `X-Crystallize-Static-Auth-Token`. ## Five concepts @@ -133,22 +136,23 @@ Discovery search(rankBy: + context:) your rules, plus this shopper Discovery search(nearestTo:) ranked by a reference item ``` -**Re-run the index after any change to vocabularies or taste entries** — not only the first time. An -unindexed change has no effect and raises no error. `stacks: opensearch` is required for vectors to be -built; see step 5 in the authoring reference. +**Ignite the first time, and again after any `upsertVocabulary`.** Vectors are built with the vocabulary's +weights, so a vocabulary change has no effect until the next index run and raises no error. A taste +change needs no ignition on a tenant already served for ranking: publishing the item re-indexes it, vectors +included. `stacks: opensearch` is required for ranking to be served; see step 5 in the authoring reference. Authoring detail — vocabulary design, positional weights, key validation, publishing and indexing — is in [references/vocabulary-authoring.md](references/vocabulary-authoring.md). ## Choosing the query path -| You want | Use | Notes | -| ------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------ | -| A house order for everyone (margin, stock, velocity, newness) | `rankBy` alone | The baseline every personalized result builds on | -| Results ordered for this shopper | `context.userTaste` alone | Reranks toward taste automatically; no `terms`, so `rankExplain` is null | -| Your rules **and** this shopper, weighted independently | `rankBy` + `context`, with a `tasteCosine` term | The only way to weight vocabularies separately | -| "Similar products", basket pairings | `nearestTo` | `k` replaces `pagination.limit`; anchor is excluded | -| Neighbours that also respect stock/margin | `nearestTo` + `rankBy` with `tasteCosine`, `from: nearestTo` | `nearestTo` alone ignores `context.userTaste` | +| You want | Use | Notes | +| ------------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------- | +| A house order for everyone (margin, stock, velocity, newness) | `rankBy` alone | The baseline every personalized result builds on | +| Results ordered for this shopper | `context.userTaste` alone | Ranks the whole result set; no `terms`, so `rankExplain` is null | +| Your rules **and** this shopper, weighted independently | `rankBy` + `context`, with a `tasteCosine` term | The only way to weight vocabularies separately | +| "Similar products", basket pairings | `nearestTo` | `k` replaces `pagination.limit`; anchor is excluded | +| Neighbours that also respect stock/margin | `nearestTo` + `rankBy` with `tasteCosine`, `from: nearestTo` | `nearestTo` alone ignores `context.userTaste` | Signals, normalization, `rankScore`/`rankExplain` and the rerank window are in [references/ranking-signals.md](references/ranking-signals.md). Shopper vectors, `magnitude`, where the @@ -165,27 +169,31 @@ Verified by introspection on a ranking-enabled tenant — `rankBy`, `context` an They are **not** on `Topic.children`, which takes `language` only. Note the naming: the public docs say "the children queries on topics and folders", but the field on `Topic` is `items`. +`Folder.children` and `Topic.items` default to `sorting: { position: asc }`, which keeps them on the +windowed path — see [references/ranking-signals.md](references/ranking-signals.md#the-rerank-window). + ## Failure modes -Four of these produce **no error at all** — they are the reason this skill exists. - -| Symptom | Cause | Fix | -| --------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| Order unrelated to taste, no errors | Taste is on the draft only | `publishItem`, index again, retest | -| Order unchanged after editing taste | No index run since the change | `igniteDiscoApi(stacks: opensearch)`, wait for `complete` | -| Index rebuilt, still no vectors | `igniteDiscoApi` run without `stacks: opensearch` | Re-run with `stacks: opensearch` | -| Ignition task ends in `error` | e.g. ignited before any shape existed | Read `bulkTask.info.traces`, fix what it names, then ignite again. Never re-run it unchanged | -| `{ __typename }` fails after `complete` | Discovery cannot build the tenant's schema | Do not re-ignite in a loop. Report it with the tenant identifier and the response body | -| Rankings subtly wrong, no errors | `magnitude` miscomputed client-side | Assert `sqrt(Σ w²)` against a known case | -| `rankScore` comes back `null` | No rerank ran — no `rankBy`, or every term had `weight: 0` | Give at least one term a non-zero weight | -| Vocabulary "does not exist in enum" | No index run since it was created | `igniteDiscoApi(stacks: opensearch)`, wait for `complete` | -| `Vocabulary not found` | No vocabulary indexed yet; the argument is still a `String` | `igniteDiscoApi(stacks: opensearch)`, wait for `complete`, then pass the enum value unquoted | -| `context` / `rankBy` unknown in schema | Ranking not enabled for the tenant, or not indexed | Confirm ranking is enabled, then index and wait for `complete` plus propagation. Detect the capability, don't assume it | -| `Malformed taste entry key` | Key has no colon | Use `dimensionId:value` | -| `Unknown dimension` | Prefix is not a declared dimension | Add it to the vocabulary, or fix the key | -| `tieBreaker` validation error | Required field missing | Add a `tieBreaker` | -| `ExperimentalFeaturesNotAvailableError` | Vectors not enabled for the tenant | Enable the feature before authoring taste | -| Page 2 empty under ranking | `skip` ran past the rerank window | Raise `options.rerankWindow` (default 500, cap 2000) | +Several of these produce **no error at all** — they are the reason this skill exists. + +| Symptom | Cause | Fix | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Order unrelated to taste, no errors | Taste is on the draft only | `publishItem`, wait until it is published, retest | +| Order unchanged after `upsertVocabulary` | No index run since the change | `igniteDiscoApi(stacks: opensearch)`, wait for `complete` | +| Ignition `complete`, no ranking args | `igniteDiscoApi` run without `stacks: opensearch`, or with `both` | Re-run with `stacks: opensearch` | +| Ignition task ends in `error` | e.g. ignited before any shape existed, or `stacks` left out on a tenant already served for ranking | Read `bulkTask.info.traces`, fix what it names, then ignite again. Never re-run it unchanged | +| `{ __typename }` fails after `complete` | Discovery cannot build the tenant's schema | Do not re-ignite in a loop. Report it with the tenant identifier and the response body | +| Rankings subtly wrong, no errors | `magnitude` miscomputed client-side | Assert `sqrt(Σ w²)` against a known case | +| Ranked list misses obvious matches | The window is the first N by `sorting`, `itemId` descending when none | Send `sorting: { score: desc }` with a `term`, or raise `options.rerankWindow` | +| `rankScore` comes back `null` | No rerank ran — no `rankBy`, `context` or `nearestTo`, or every `rankBy` term had `weight: 0` | Give at least one term a non-zero weight | +| Vocabulary "does not exist in enum" | No index run since it was created | `igniteDiscoApi(stacks: opensearch)`, wait for `complete` | +| `Vocabulary not found` | No vocabulary indexed yet; the argument is still a `String` | `igniteDiscoApi(stacks: opensearch)`, wait for `complete`, then pass the enum value unquoted | +| `context` / `rankBy` unknown in schema | Ranking not enabled for the tenant, or not indexed | `igniteDiscoApi(stacks: opensearch)`, wait for `complete` plus propagation. Detect the capability, don't assume it | +| `Malformed taste entry key` | Key has no colon | Use `dimensionId:value` | +| `Unknown dimension` | Prefix is not a declared dimension | Add it to the vocabulary, or fix the key | +| `tieBreaker` validation error | Required field missing | Add a `tieBreaker` | +| `Field not found` | `recency` on a number, or `fieldBoost` / `inStockBoost` on a date | Point the term at a field of the right kind | +| Page 2 empty under ranking | `skip` ran past the rerank window | Raise `options.rerankWindow` (default 500, cap 2000) | ## How ranking composes with the rest of the query @@ -193,15 +201,18 @@ Four of these produce **no error at all** — they are the reason this skill exi set; it does not change which items match. - **Items without a vector are still returned.** They score 0 on taste and sort after the ones that match, rather than disappearing. `tasteCosine` contributes 0 for them, so unmapped products fall back - to your other rules. -- **Ranking happens inside a bounded window** of the top matches — see `rerankWindow` in - [references/ranking-signals.md](references/ranking-signals.md). -- **Cursor pagination degrades under ranking.** When a rerank runs, `after`/`before` fall back to offset - pagination — page with `skip` and `limit`, and size the window for the depth you intend to page. -- **Do not combine `sorting` with ranking.** The public docs contradict themselves here (one page says - sorting selects which candidates enter the window, another says it is applied after the ranking - score). When `rankBy` or `context` is present the reranked order is what you get back — treat any - `sorting` you pass as unspecified behaviour. + to your other rules. 0 is the middle of the range, not the bottom: they outrank anything scoring + negative, and under a negative `tasteCosine` weight they float to the top. +- **`rankBy` ranks inside a bounded window**, and it is not the top matches: it is the first N documents + under the query's `sorting`, or `itemId` descending when you sent none. `context` or `nearestTo` with no + `rankBy` and no `sorting` rank the whole result set. See + [references/ranking-signals.md](references/ranking-signals.md#the-rerank-window). +- **`sorting` selects the window, never the returned order.** The rerank owns the order that comes back. + `sorting: { score: desc }` is how you ask for relevance-selected candidates, and any `sorting` you send + puts the query on the windowed path. +- **Cursors depend on the path.** On the whole-set path `after`/`before` page the ranked set as usual, + with `summary.endToken`. On a windowed path they move the window, not the page — page with `skip` and + `limit`, and size the window for the depth you intend to page. ## Related skills diff --git a/use-crystallize/skills/vector-ranking/references/personalization.md b/use-crystallize/skills/vector-ranking/references/personalization.md index fc45791..57767dc 100644 --- a/use-crystallize/skills/vector-ranking/references/personalization.md +++ b/use-crystallize/skills/vector-ranking/references/personalization.md @@ -1,7 +1,7 @@ # Personalization & Similarity (`context.userTaste`, `nearestTo`) -Discovery API, `https://api.crystallize.com//discovery`, no auth. **Nothing about the shopper is -stored in Crystallize** — you send the vector with each query. +Discovery API, `https://api.crystallize.com//discovery`, no auth unless the tenant requires a +static token. **Nothing about the shopper is stored in Crystallize** — you send the vector with each query. ## `context.userTaste` @@ -14,7 +14,9 @@ ContextInput { userTaste: [UserTasteInput!]! } # one entry per vocab ``` `userTaste` must be **non-empty**, and a vocabulary may appear **at most once**. `weights` maps -`dimensionId:value` to a number — **positive means likes, negative means dislikes**. +`dimensionId:value` to a number — **positive means likes, negative means dislikes**. A negative weight +puts the query on the windowed path; see +[the rerank window](ranking-signals.md#the-rerank-window). Weight keys contain a colon, which is not a valid GraphQL name, so **pass the context as a variable**, never inline in the query document. @@ -66,8 +68,9 @@ query SearchWithTaste($context: ContextInput!) { The two entries are scored separately and **added**, so Kenyan light-roast berry coffees rise above coffees that satisfy only one of them. -Supplied **without** `rankBy`, results are reranked toward that taste automatically. There are no -`terms` on this path, so `rankExplain` is `null`. +Supplied **without** `rankBy`, results are ranked toward that taste automatically, over the whole result +set unless [a windowed case](ranking-signals.md#the-rerank-window) applies. `rankScore` carries the summed +cosine. There are no `terms` on this path, so `rankExplain` is `null`. ## You supply the magnitude @@ -76,8 +79,11 @@ Supplied **without** `rankBy`, results are reranked toward that taste automatica every weight, add them up, take the square root. For `{ "flavor:berry": 1.0, "roast:light": 1.0 }` that is `sqrt(1 + 1) = 1.414`. -**A miscomputed magnitude raises no error.** It quietly distorts every cosine, so the results look -plausible and are wrong. Compute it in exactly one place and unit test it against a known case. +**A miscomputed magnitude raises no error.** The server divides by the number you send and never +recomputes it, so a wrong value rescales that vocabulary's contribution. That is harmless with a single +vocabulary and no `rankBy`, and a ranking bug as soon as you sum vocabularies or blend with other signals: +the results look plausible and are wrong. `magnitude: 0` is not "let the server work it out": it zeroes that +vocabulary for every item. Compute it in exactly one place and unit test it against a known case. ```ts type SparseVector = Record; @@ -91,7 +97,8 @@ const toUserTaste = (vocabulary: string, weights: SparseVector) => ({ }); ``` -**Prune near-zero weights before sending.** They cost payload and contribute nothing beyond rounding. +**Prune near-zero weights before sending, and compute `magnitude` after pruning.** They cost payload and +contribute nothing beyond rounding, and past about 800 weights the query takes the windowed path. ## Where the shopper vector comes from @@ -102,7 +109,7 @@ The vector is UI state or session state, whichever you have. All of these are on - **Cold start from picks.** Let a new visitor pick two or three products, read what they are made of, rebuild the vectors with the same positional rule, and sum them. No history needed. Item taste is **not** exposed on Discovery hits — read `topicPaths` from the hit if your taste keys are derived from - topics, or read `item { taste { ... } }` from Core server-side. + topics, or read `item { ... on Item { taste { ... } } }` from Core server-side. - **Decayed session behaviour.** Add a little weight for every product a shopper opens or adds, decay it over time, and send the result. The profile lives in the browser or your session store. - **Agents.** An AI agent holds a structured taste vector for a customer and sends it with every search, @@ -112,10 +119,12 @@ The vector is UI state or session state, whichever you have. All of these are on `context` alone ranks purely by taste. To put the shopper **on top of** the catalogue rules, add one `tasteCosine` term to a `rankBy` and **pass both** — taste terms read the shopper vector from `context`. +Neither half errors without the other: a `tasteCosine` term with no `context` entry for its vocabulary +scores 0, and a `context` with no `tasteCosine` term in the `rankBy` is ignored. ```graphql query Blended($context: ContextInput!, $rankBy: RankByInput!) { - search(term: "espresso", context: $context, rankBy: $rankBy, pagination: { limit: 24 }) { + search(term: "espresso", context: $context, rankBy: $rankBy, sorting: { score: desc }, pagination: { limit: 24 }) { hits { ... on product { name @@ -136,7 +145,7 @@ query Blended($context: ContextInput!, $rankBy: RankByInput!) { "rankBy": { "terms": [ { "signal": "relevance", "weight": 1.0 }, - { "signal": "fieldBoost", "weight": 0.6, "field": "margin", "normalize": true }, + { "signal": "fieldBoost", "weight": 0.6, "field": "margin_number", "normalize": true }, { "signal": "inStockBoost", "weight": 0.4, "field": "stock_default" }, { "signal": "recency", "weight": 0.3, "field": "publishedAt", "halfLifeDays": 60 }, { "signal": "tasteCosine", "weight": 1.2, "vocabulary": "taste", "from": "userTaste" } @@ -189,12 +198,15 @@ Four things to know: - **The anchor item is excluded** from the results. - **An anchor with no vector for that vocabulary yields an empty result** — not a fallback to relevance. - Check the anchor before rendering an empty shelf. + So does an anchor not found in the query's language and publication state. Check the anchor before + rendering an empty shelf. - **`k` replaces `pagination.limit`** when `nearestTo` is set, and is itself capped by the rerank window. - **`nearestTo` alone does not combine `context.userTaste`.** It scores from the anchor vector only. To blend neighbours with stock, margin or the shopper's own taste, add a `tasteCosine` term with -`from: nearestTo` to a `rankBy`: +`from: nearestTo` to a `rankBy`, and keep `nearestTo` in the query: the term reads the anchor from it and +scores 0 without it. A `rankBy` is windowed, so neighbours outside the window are never considered; narrow +the set with `filters`, or raise `options.rerankWindow`, on a catalogue larger than the window. ```json { @@ -202,7 +214,7 @@ To blend neighbours with stock, margin or the shopper's own taste, add a `tasteC "terms": [ { "signal": "tasteCosine", "weight": 1.0, "vocabulary": "taste", "from": "nearestTo" }, { "signal": "inStockBoost", "weight": 0.5, "field": "stock_default" }, - { "signal": "fieldBoost", "weight": 0.3, "field": "margin", "normalize": true } + { "signal": "fieldBoost", "weight": 0.3, "field": "margin_number", "normalize": true } ], "tieBreaker": "sku" } diff --git a/use-crystallize/skills/vector-ranking/references/ranking-signals.md b/use-crystallize/skills/vector-ranking/references/ranking-signals.md index 40cb3f7..4fd3ae0 100644 --- a/use-crystallize/skills/vector-ranking/references/ranking-signals.md +++ b/use-crystallize/skills/vector-ranking/references/ranking-signals.md @@ -1,9 +1,11 @@ # Ranking Signals (`rankBy`) -Discovery API, `https://api.crystallize.com//discovery`, no auth. +Discovery API, `https://api.crystallize.com//discovery`, no auth unless the tenant requires a +static token. -By default Search orders hits by full-text relevance — a BM25-based score. `rankBy` blends several -weighted signals into one score instead: +On a tenant served for ranking, a query with no `sorting` is **not** in relevance order: hits come back by +`itemId` descending, even with a `term`. `sorting: { score: desc }` gives relevance order. `rankBy` blends +several weighted signals into one score instead: ```text rankScore = Σ (weight × signal) @@ -40,22 +42,29 @@ the order stays stable across pages. | `relevance` | — | — | Text match against `term`. **Contributes 0 when there is no `term`.** | | `fieldBoost` | `field` | `normalize` | A rankable number on the item, used directly as a boost. | | `inStockBoost` | `field` | — | Binary: contributes **1** when the named numeric field is `> 0`. Ignores quantity. | -| `recency` | `field`, `halfLifeDays` | — | Exponential time-decay on the age of a date field. | +| `recency` | `field`, `halfLifeDays` | — | Exponential time-decay on the age of a date field: `0.5 ^ (ageInDays / halfLifeDays)`. | | `tasteCosine` | `vocabulary`, `from` | — | Cosine to the shopper vector (`from: userTaste`) or to an anchor item (`from: nearestTo`). See [personalization.md](personalization.md). | +`recency` needs a date field; `fieldBoost` and `inStockBoost` need a number field. `TenantRankByField` +holds both kinds, so the wrong one passes schema validation and the query fails with +`Field not found`. + +An item without the value scores 0 on all three. That is the worst value under a positive weight and the +best under a negative one, so a penalty on, say, return rate lifts every item that has no return rate. + ## The field enums are per tenant `field` and `tieBreaker` are **not free-form strings** — they are enums generated from the tenant's index settings. Introspect them; do not guess. -| Enum | Built from | -| ---------------------------- | ------------------------------------------------------------------------- | -| `TenantRankByField` | NUMBER and DATE **filterable** attributes, **with facet fields excluded** | -| `TenantRankByTieBreaker` | sortable fields (token, number and date) | -| `TenantVocabularyIdentifier` | the tenant's vocabularies, **only after the next index run** | +| Enum | Built from | +| ---------------------------- | ----------------------------------------------------------------------- | +| `TenantRankByField` | NUMBER and DATE **sortable** attributes, **with facet fields excluded** | +| `TenantRankByTieBreaker` | sortable fields (token, number and date) | +| `TenantVocabularyIdentifier` | the tenant's vocabularies, **only after the next index run** | -`TenantRankByField` being facet-excluded is the trap: a number you can facet on is not necessarily a -number you can boost on. +Following `sortable` is the trap: a number you can filter or facet on drops out of both enums when its +component sets `sortable: false`, so it is not necessarily a number you can boost on. ```graphql { @@ -72,15 +81,17 @@ number you can boost on. } ``` -Typical values on a commerce tenant: `price_default`, `price_sales`, `stock_default`, `stock_oslo`, -`publishedAt`, `createdAt`, `updatedAt`, `position`, `depth`, and any numeric component -(`dimensions_weight_number`, `sold_30d_number`, `margin_number`, `rating_number`, …). +Typical values on a commerce tenant: `price_default`, `price_sales`, `defaultVariantPrice_default`, +`lowestVariantPrice_default`, `highestVariantPrice_default`, `stock_default`, `stock_oslo`, `publishedAt`, +`createdAt`, `updatedAt`, `position`, `depth`, and any numeric component (`dimensions_weight_number`, +`sold_30d_number`, `margin_number`, `rating_number`, …). A numeric component is always `_number`, never +the bare ``. ## Example ```graphql query Catalogue($rankBy: RankByInput!) { - search(term: "espresso", rankBy: $rankBy, pagination: { limit: 24 }) { + search(term: "espresso", rankBy: $rankBy, sorting: { score: desc }, pagination: { limit: 24 }) { hits { ... on product { name @@ -102,8 +113,8 @@ query Catalogue($rankBy: RankByInput!) { "rankBy": { "terms": [ { "signal": "relevance", "weight": 1.0 }, - { "signal": "fieldBoost", "weight": 0.6, "field": "margin", "normalize": true }, - { "signal": "fieldBoost", "weight": 0.5, "field": "sold_30d", "normalize": true }, + { "signal": "fieldBoost", "weight": 0.6, "field": "margin_number", "normalize": true }, + { "signal": "fieldBoost", "weight": 0.5, "field": "sold_30d_number", "normalize": true }, { "signal": "inStockBoost", "weight": 0.4, "field": "stock_default" }, { "signal": "recency", "weight": 0.3, "field": "publishedAt", "halfLifeDays": 60 } ], @@ -119,14 +130,16 @@ rankScore = 1.0·relevance + 0.6·margin + 0.5·sold_30d + 0.4·inStock + 0.3·r ## Zero weights are dropped -A term with `weight: 0` is removed **before** scoring. If _every_ term is 0, the query falls back to -plain relevance and **skips the rerank window entirely** — no rerank runs, and `rankScore` comes back -`null`. Turning a signal off by zeroing it is fine; zeroing all of them silently disables ranking. +A term with `weight: 0` is removed **before** scoring. If _every_ term is 0, the query runs unranked and +**skips the rerank window entirely** — no rerank runs, `rankScore` comes back `null`, and a `context` or +`nearestTo` sent beside it no longer ranks either. Turning a signal off by zeroing it is fine; zeroing all +of them silently disables ranking. ## Normalization `normalize` applies **min–max normalization across the rerank window**, so signals on wildly different -scales can share a weight. The default depends on the signal — set `normalize` explicitly to override. +scales can share a weight. A term whose value is the same across the window normalizes to 0. The default +depends on the signal — set `normalize` explicitly to override. | Signal | `normalize` default | Why | | -------------- | ------------------- | ------------------- | @@ -151,20 +164,26 @@ is fixed and cannot be overridden:** | `inStockBoost` | **highest** | buyable in any variant counts as in stock | | `recency` | **highest** | the most recent date wins | +To boost on another end of a product's prices, use the single-valued `defaultVariantPrice_`, +`lowestVariantPrice_` or `highestVariantPrice_` fields. + ## `rankScore` and `rankExplain` -`rankScore` on each hit is the **raw, un-normalized value the hits were ordered by** — the actual sort -key. It is distinct from `score`, which stays text relevance. `rankScore` is `null` whenever no rerank -ran: no `rankBy`, or every term dropped. +`rankScore` on each hit is the **raw, un-normalized value the hits were ordered by** — `Σ weight × term` +under `rankBy`, the summed per-vocabulary cosine under `context` or `nearestTo`. It is the actual sort key, +which `score` is not guaranteed to be. `rankScore` is `null` whenever no rerank ran: no `rankBy`, `context` +or `nearestTo`, or every `rankBy` term dropped. -With `explain: true` each hit carries one `rankExplain` entry per term, **in `rankBy.terms` order**: +With `explain: true` each hit carries one `rankExplain` entry per **scored** term, **in `rankBy.terms` +order**. A `weight: 0` term has no entry, so join on `index`, never on an entry's position in the list: ```text RankExplainEntry { signal: RankBySignal, index: Int, contribution: Float } ``` -`index` disambiguates a repeated signal — two `fieldBoost` terms, say — and the contributions **sum to -`rankScore` by construction**, which makes it a usable self-check. +`index` is the term's position in the `terms` you sent. It disambiguates a repeated signal — two +`fieldBoost` terms, say — and the contributions **sum to `rankScore` by construction**, which makes it a +usable self-check. ```json { @@ -187,23 +206,44 @@ Ship `explain: true` behind a merchandiser flag, not in the hot storefront path ## The rerank window -`options.rerankWindow` is the **single knob shared by all three rerank paths** (`rankBy`, `context` and -`nearestTo`). The top-N candidates are scored and re-sorted; results beyond N keep plain relevance -order. +`options.rerankWindow` sets how many documents a windowed query reranks: the **first N under the query's +effective sort** — the `sorting` you sent, or `itemId` descending when you sent none. Those N are scored and +re-sorted among themselves. **That is not the N best matches unless you ask for it**: `sorting: { score: desc }` +selects candidates by relevance. On a catalogue larger than the window, a `rankBy` with no `sorting` never +sees the rest. - default **500** -- hard server cap **2000** +- hard server cap **2000**; a larger value is clamped - rerank cost is per candidate, so a larger window costs more ```graphql search( term: "espresso", rankBy: $rankBy, + sorting: { score: desc }, options: { rerankWindow: 1000 } ) { hits { ... on product { name rankScore } } } ``` -**Paging happens inside the window.** Under ranking, `skip` offsets into the reranked window, and a -`skip` past the window returns nothing. Size the window for the depth you intend to page, not just for -the first page. Cursor tokens (`after`/`before`) fall back to offset pagination when a rerank is active, -so use `skip` + `limit` on ranked queries. +**Which queries are windowed.** `context.userTaste` or `nearestTo` with no `rankBy` and no `sorting` are +ranked over the **whole result set**: no window applies, `skip` and `after`/`before` page the ranked set as +usual (`summary.endToken` is the cursor for the next page), and `rerankWindow` only caps `nearestTo.k`. +Every other ranked query takes the window: + +- any query with a `rankBy` +- any query with a `sorting`, including `Folder.children` and `Topic.items`, which default to + `sorting: { position: asc }` — pass `sorting: {}` to clear it +- `autocomplete` +- a `userTaste` weight below zero, or an anchor vector holding one +- more than about 800 weights across the vocabularies +- an item in scope whose taste holds a negative weight +- an index from an older ignition; `igniteDiscoApi(stacks: opensearch)` rebuilds it for the whole-set path + +**Paging happens inside the window.** On a windowed query `skip` offsets into the reranked window. A `skip` +past the window returns no hits, while `totalHits` still counts every match and `summary.hasMoreHits` +reports `false`. Size the window for the depth you intend to page, not just for the first page. + +**`after`/`before` move the window, not the page.** A hit's token is its position under the effective sort, +not its reranked rank, so a cursor taken from a reranked page selects the next N documents past that +position and reranks those. Items the page pulled up from later positions come back again, and items from +earlier positions that missed the page are never reached. Use `skip` + `limit` on windowed queries. diff --git a/use-crystallize/skills/vector-ranking/references/vocabulary-authoring.md b/use-crystallize/skills/vector-ranking/references/vocabulary-authoring.md index 142d2f5..512f4c5 100644 --- a/use-crystallize/skills/vector-ranking/references/vocabulary-authoring.md +++ b/use-crystallize/skills/vector-ranking/references/vocabulary-authoring.md @@ -53,7 +53,10 @@ weight: [1.0, 0.6, 0.3] 1st entry 1.0 ## 2. Create the vocabulary `upsertVocabulary` creates or replaces a vocabulary **in full**. It is a full replace, not a patch — -send the whole dimension list every time. **Any dimension you omit is gone.** +send the whole dimension list every time. **Any dimension you omit is gone**, and the entries items still +hold for it are skipped, without error, the next time their vectors are built. + +A vocabulary needs at least one dimension, dimension ids must be unique, and every weight must be positive. ```graphql mutation UpsertVocabulary($input: UpsertVocabularyInput!) { @@ -89,6 +92,10 @@ GraphqlInputVocabularyDimension { id: String!, weight: [Float!]! } GraphqlVocabulary { name: String!, dimensions: [GraphqlVocabularyDimension!]!, lastUpdated: Date } ``` +Discovery exposes the name normalized for GraphQL (`coffee-taste` becomes the enum value `coffeeTaste`), +and `upsertVocabulary` refuses a new name that normalizes to the same value as an existing one. Core keeps +taking the name as you wrote it. + Read it back with the `vocabulary` query — useful as an idempotency check before a bulk re-author: ```graphql @@ -106,8 +113,8 @@ query { ## 3. Attach taste to items -`setItemTaste` writes the ordered entries for **one item, one language, one vocabulary**. Two -vocabularies means two calls per item. +`setItemTaste` writes the ordered entries for **one item, one language, one vocabulary**, replacing what +that vocabulary held on the item. Two vocabularies means two calls per item. ```graphql mutation SetItemTaste($input: SetItemTasteInput!) { @@ -162,27 +169,29 @@ SetItemTasteResult (union) Product | Folder | Document | UnauthorizedError | UnknownError ``` -`ExperimentalFeaturesNotAvailableError` is in the result union and is **not** in the public docs. It -means vectors are not enabled for the tenant at all — read `errorName` off the `BasicError` fragment to -tell it apart from the others. +`ExperimentalFeaturesNotAvailableError` is a standard member of Core result unions, not a signal about +vectors: nothing in vocabulary or taste authoring raises it, and ranking is switched on by the ignition in +step 5. The validation rejections below come back as `UnknownError`, with the reason in `message`. ### Order is the input to positional weights Entries are read **in array order, per dimension**. Above, `flavor:berry` takes 1.0, `flavor:blackcurrant` 0.6 and `flavor:caramel` 0.3. The optional per-entry `weight` overrides the vocabulary weight for that entry only — and **still consumes its positional slot**, so it does not shift -the entries that follow. +the entries that follow. Overrides are not range-checked: keep them positive, because a negative one puts +every query that reaches the item on the windowed path. ### The key format is validated Every key is `:`. Everything before the first colon must be a **declared dimension -in that vocabulary**. Either mistake rejects the whole call, so there are **no partial writes** — fix -the key and resend the full set of entries. +in that vocabulary**, and the vocabulary must exist. Any of these mistakes rejects the whole call, so there +are **no partial writes** — fix it and resend the full set of entries. | Bad entry | Rejection message | | ---------------------- | --------------------------------------------------------------------------------------- | | `{ key: "chocolate" }` | `Malformed taste entry key "chocolate": expected ":"` | | `{ key: "mood:cozy" }` | `Unknown dimension "mood" in vocabulary "taste". Known dimensions: roast, flavor, body` | +| `vocabulary: "nope"` | `Vocabulary "nope" not found for this tenant` | ### Read taste back @@ -192,11 +201,13 @@ It is **not** exposed on Discovery hits. ```graphql query { item(id: "", language: "en") { - taste { - vocabulary - entries { - key - weight + ... on Item { + taste { + vocabulary + entries { + key + weight + } } } } @@ -216,8 +227,8 @@ See [[taxonomy]] for the topic side. ## 4. Publish -`setItemTaste` writes to the **draft** version. The indexer reads the **published** version. If your -items were published before you attached taste, publish them again or the served documents carry no +`setItemTaste` writes to the **draft** version. Discovery serves the **published** version by default. If +your items were published before you attached taste, publish them again or the served documents carry no vectors. ```graphql @@ -235,25 +246,34 @@ mutation Publish($id: ID!, $language: String!) { } ``` -Publish **per item and per language**. `publishItem` answers with the published version or an error -for that item. `publishItems` returns a `PublishItemsRequest` instead, and its return is not proof that -the items are published yet — index right after it and the index may read the old versions. +Publish **per item and per language**. `publishItem` and `publishItems` both run in the background, so +neither return is proof that the items are published yet — index right after them and the index may read +the old versions. Wait for `item(id:, language:, versionLabel: published)` to show the new taste first. + +On a tenant already served for ranking, each publish re-indexes that item in the background, vectors +included, so a taste change needs no ignition. **Skipping this step produces no error.** Queries still return results, and the order may even change — it just has no relation to taste. The check in step 6 is the only thing that catches it. ## 5. Index -`igniteDiscoApi` rebuilds the tenant's Discovery index and materializes the vectors. Run it after **any** -change to vocabularies or taste entries, not only the first time. +`igniteDiscoApi` rebuilds the tenant's Discovery index and materializes the vectors. Run it the first +time, and again after **any** `upsertVocabulary`: new weights reach existing items only at an index run, and +a new vocabulary only joins the Discovery enum then. + +Only a tenant admin can ignite, and a tenant runs one ignition at a time. Either refusal comes back as a +`BasicError`. The Discovery page in the app's settings runs the same mutation, with `stacks: opensearch`. **Ignite last.** Run it once the shapes exist, each with at least one component, and the content is published, not as an early setup step. A tenant with no shapes that Discovery can index is refused: the task ends in `error`. **Pass `stacks: opensearch`.** The argument is optional in the schema but it selects which index stack -is built, and vector ranking is only served from the `opensearch` stack — an ignition without it -produces a working Discovery index with no vectors in it, and no error. +is built, and vector ranking is only served from the `opensearch` stack. Without it, a tenant not yet +served gets a working Discovery index, no ranking arguments and no error. `both` builds the stack but +leaves reads where they are, so it does not turn ranking on either. A tenant already served for ranking +refuses an ignition that leaves the stack out: the task ends in `error`. ```graphql mutation Index { @@ -307,9 +327,9 @@ ignition that failed, unchanged, fails the same way. Before reporting success, p Anything but `{"data":{"__typename":"Query"}}` means the tenant is not serving, whatever the task said. From then on the tenant's Discovery schema includes `context`, `rankBy` and `nearestTo`, and every -vocabulary you created becomes a value of the `TenantVocabularyIdentifier` enum. **A vocabulary is only -a valid enum value after the next index run** — until then, queries referencing it fail schema -validation. +vocabulary you created becomes a value of the `TenantVocabularyIdentifier` enum, under its normalized +name. **A vocabulary is only a valid enum value after the next index run** — until then, queries +referencing it fail schema validation. Before a tenant has any indexed vocabulary, the same arguments (`nearestTo.vocabulary`, `userTaste.vocabulary`) are typed `String`, and a query fails with `Vocabulary not found`. After @@ -341,6 +361,7 @@ b) Run the same query with and without `context` and compare the order. ## Authoring at scale -`setItemTaste` is one call per item per vocabulary, so a full catalogue is a bulk job. Drive it from -[[mass-operations]] or a script over [[js-api-client]], and **index once at the end** rather than per -item. Order matters: vocabulary → taste → publish → index. +`setItemTaste` is one call per item per vocabulary, so a full catalogue is a bulk job. Mass operations +have no taste intent, so drive it from a script over [[js-api-client]]. The `item/publish` intent of +[[mass-operations]] covers the publish step. **Index once at the end** rather than per item. Order +matters: vocabulary → taste → publish → index. From 12775a4375bfbcb105d3572b7417b01aed537cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 15:54:57 -0700 Subject: [PATCH 09/29] fix(skills): remove the nonexistent topic component and filter from taxonomy Topics are a built-in field and Discovery filters them with filters: { topics: ... }. The skill now says what createTopic and topic/create accept, and that Discovery returns one topics facet across all maps. --- use-crystallize/skills/taxonomy/SKILL.md | 92 ++++++++++++++++++------ 1 file changed, 72 insertions(+), 20 deletions(-) diff --git a/use-crystallize/skills/taxonomy/SKILL.md b/use-crystallize/skills/taxonomy/SKILL.md index 010c2ae..a1f4bfd 100644 --- a/use-crystallize/skills/taxonomy/SKILL.md +++ b/use-crystallize/skills/taxonomy/SKILL.md @@ -1,6 +1,6 @@ --- name: taxonomy -description: Design topic maps (taxonomies) for Crystallize PIM. Plan hierarchical classification systems used to tag and categorize products, documents, and media; enable faceted navigation and filtering; power search and discovery; and create cross-category collections. Use when the user wants to design a topic map, build a taxonomy, plan filters/facets for a storefront, model attributes like color/size/material/style/season, classify content (categories, difficulty, content type), organize brands/origins/certifications, design temporal tags (seasons, collections, events), or generate the `topicTree` JSON for topics. Also use when the user mentions classification, tagging, facets, filters, attribute taxonomies, or cross-cutting tags for items in Crystallize — even if they don't explicitly say "topic map" or "taxonomy". +description: Design topic maps (taxonomies) for Crystallize PIM. Plan hierarchical classification systems used to tag and categorize products, documents, and media; enable faceted navigation and filtering; power search and discovery; and create cross-category collections. Use when the user wants to design a topic map, build a taxonomy, plan filters/facets for a storefront, model attributes like color/size/material/style/season, classify content (categories, difficulty, content type), organize brands/origins/certifications, design temporal tags (seasons, collections, events), generate the `topicTree` JSON for topics, or ship a topic tree to a tenant with `createTopic`. Also use when the user mentions classification, tagging, facets, filters, attribute taxonomies, or cross-cutting tags for items in Crystallize — even if they don't explicitly say "topic map" or "taxonomy". metadata: author: Crystallize version: "1.0" @@ -10,7 +10,7 @@ metadata: Design and generate topic maps (taxonomies) for Crystallize PIM. This skill produces the `topicTree` JSON used to create hierarchical classification systems that tag and categorize catalogue items, power faceted navigation, and drive search and discovery. -Topic maps are one of the three organization systems in Crystallize. Folders define **where** an item lives (single hierarchy) — see [[information-architecture]] for that. Topics define **what** an item is about (multi-dimensional, multiple tags per item). Shapes that consume topics — and decide whether to tag via the built-in `topics` field or a `topicReference` component — are covered in [[content-model]]. To write topics to a tenant via the API, see [[mutation]]. +Topic maps are one of the three organization systems in Crystallize. Folders define **where** an item lives (single hierarchy) — see [[information-architecture]] for that. Topics define **what** an item is about (multi-dimensional, multiple tags per item). `topics` is a built-in field on items and variants, so a shape needs no component to carry them: there is no topic component type. Shape design is covered in [[content-model]]. To write topics to a tenant, see Shipping a Topic Tree below. ## Consultation Approach @@ -43,25 +43,26 @@ After 2–3 answers, propose an initial tree with colors assigned. Concrete prop A topic map is a hierarchical classification system used to: -- **Tag and categorize** products, documents, folders, and media +- **Tag and categorize** products, variants, documents, folders, and media - **Enable faceted navigation** and filtering on the storefront - **Power search and discovery** via the Discovery API - **Create cross-category collections** ("all red items", "all summer items") - **Organize images** and other media -Each tenant can have **many topic maps** — one per facet (Colors, Sizes, Materials, Styles, etc.). An item can carry topics from multiple maps simultaneously. +Each tenant can have **many topic maps** — one per facet (Colors, Sizes, Materials, Styles, etc.). An item can carry topics from multiple maps simultaneously. A topic map is not a separate object in the API: it is a root topic, one with no `parentId`. ### Topic Properties -| Property | Description | -| ---------------- | ------------------------------------------------------------------------ | -| `name` | Display name shown to editors and (optionally) on the storefront | -| `pathIdentifier` | URL-safe identifier (lowercase, hyphens) — becomes part of the topic URL | -| `displayColor` | Hex color (`#rrggbb`) for visual distinction in the editor and UI | -| `parentId` | Reference to a parent topic (for nesting) | -| `children` | Array of child topics | +| Property | Description | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | Display name shown to editors and (optionally) on the storefront | +| `pathIdentifier` | URL-safe identifier (lowercase, hyphens) — becomes the last segment of the topic's `path` (`/colors/reds`). Derived from `name` when omitted | +| `displayColor` | Hex color (`#rrggbb`; the API also takes `#rgb` and `#rrggbbaa`) for visual distinction in the editor and UI | +| `parentId` | Reference to a parent topic (for nesting) | +| `children` | Array of child topics | +| `meta` | Key/value pairs for short machine data (e.g. an ERP code), readable from the Core API and Discovery | -`displayColor` is **required** in every topic generated by this skill — see Color Guidelines below. +`displayColor` is optional in the API: the Crystallize app then shows the nearest ancestor's color, but the APIs return only the topic's own value. It is **required** in every topic generated by this skill — see Color Guidelines below. ## Color Guidelines @@ -115,12 +116,12 @@ Each root topic map gets its own thematic color family so the maps are visually ## Topic Map Design Principles 1. **Flat vs. Deep** — Flat works for stable, exclusive attributes (Size: S/M/L/XL). Deep works for layered classifications (Fragrance → Fresh → Citrus → Bergamot). -2. **Mutually Exclusive vs. Overlapping** — Decide up front whether items can carry multiple topics from this map. Sizes typically exclusive; Materials often overlapping. +2. **Mutually Exclusive vs. Overlapping** — Decide up front whether items can carry multiple topics from this map. Sizes typically exclusive; Materials often overlapping. Crystallize does not enforce this: an item can carry any topics from any map, up to 250 per item. 3. **User-Centric** — Design for how customers think when filtering, not how the internal team categorizes inventory. 4. **Consistent Depth** — Within a single topic map, keep branches at similar depth. Don't put `Reds → Light Red` next to `Blues` (no children) without a reason. 5. **5–10 topics per level** — Beyond that, filters become overwhelming. Group or split into sub-topics. 6. **Plan for Growth** — Leave headroom for new entries. A flat list of 4 colors becomes painful at 40; nest early if you expect growth. -7. **Multiple maps = multiple facets** — One topic map per filter axis. Colors, Sizes, Materials = three maps, three facets in the storefront. +7. **Multiple maps = multiple facets** — One topic map per filter axis. Colors, Sizes, Materials = three maps, three facets in the storefront. Discovery returns all maps in one `topics` facet, so the storefront splits it by root path — see Topics in Discovery. ## Common Topic Map Types @@ -203,7 +204,7 @@ Fragrances (displayColor: "#9c27b0") ## Output Format -Generate topic maps as a `topicTree` JSON document. `displayColor` is required on every topic. +Generate topic maps as a `topicTree` JSON document. `displayColor` is required on every topic. `topicTree` is this skill's design format, not an API payload: see Shipping a Topic Tree before writing it to a tenant. ```json { @@ -259,7 +260,7 @@ Generate topic maps as a `topicTree` JSON document. `displayColor` is required o ### Rules -1. `id` is URL-friendly — lowercase, hyphens, no spaces or special characters. +1. `id` is URL-friendly — lowercase, hyphens, no spaces or special characters. It is a key local to this document: the API generates the real topic id. 2. `pathIdentifier` matches the `id`. 3. `displayColor` is **required** on every topic. Follow the Color Guidelines above. 4. `children` is always an array — use `[]` for leaf nodes. @@ -267,6 +268,56 @@ Generate topic maps as a `topicTree` JSON document. `displayColor` is required o 6. Color-named topics use the actual color as `displayColor`. 7. Non-color topics use a hierarchy gradient (root saturated, descendants lighter). +## Shipping a Topic Tree + +No API accepts a `topicTree` document as it stands. Each root is one write: `createTopic` on the Core API, or `topic/create` in a [[mass-operations]] file. + +```graphql +mutation { + createTopic( + language: "en" + input: { + name: "Colors" + pathIdentifier: "colors" + displayColor: "#f59e0b" + children: [ + { name: "Reds", pathIdentifier: "reds", children: [{ name: "Crimson", pathIdentifier: "crimson" }] } + ] + } + ) { + ... on Topic { + id + path + } + ... on BasicError { + errorName + message + } + } +} +``` + +A write takes less than the design holds: + +- **No `id`.** The API generates topic ids. `createTopic` rejects an `id` field and `topic/create` silently drops it. +- **No `displayColor` on nested `children`.** They take `name`, `pathIdentifier` and `children`, plus `meta` on `createTopic`. `createTopic` rejects a nested color and `topic/create` silently drops it. To color every topic, write one topic at a time, top-down, with the parent's id as `parentId`, or set the colors afterwards with `updateTopic(id:, language:, input: { displayColor: "#e53935" })`. [[mass-operations]] documents the `_ref` output to take that id from. +- **30 topics per write**, counting the root and all its descendants. Split a larger tree and attach each part with `parentId`. +- **One language per write.** Translate with `updateTopic(id:, language:, input: { name: "Farger" })`, parents first: a topic cannot be created or translated in a language its parent lacks. + +After the first write: + +- **A second write does not merge.** Sibling names (case-insensitive) and path identifiers are unique per language, so running the same `createTopic` twice fails. For a re-runnable import, use `topic/upsert` with a `resourceIdentifier` — see [[mass-operations]]. +- **Renaming moves the path.** `updateTopic` with a `name` and no `pathIdentifier` regenerates the path segment, for the topic and its descendants. Send the current `pathIdentifier` with the new name to keep the paths storefront filters rely on. +- **`deleteTopic` also deletes every descendant.** To restructure, move the branch with `updateTopic(id:, language:, input: { parentId: "..." })`. + +## Topics in Discovery + +`topics` is a built-in index field on every shape, so a topic map needs no index setup. Variants have their own `variants_topics` field. + +- **Filter by path.** An item is indexed with the path of each of its topics and of their ancestors, so `filters: { topics: { equals: "/colors/reds" } }` also matches an item tagged only `/colors/reds/crimson`. +- **One facet, whatever the number of maps.** `facets: { topics: { limit: 50 } }` returns a single `topics` facet in `summary.facets`, keyed by path, with labels like `Colors > Reds`. `limit` defaults to 10 and caps the buckets across all maps, ancestors included, keeping the most frequent. Set it high enough for every map, then group the buckets by root path to draw one filter per map. +- **Paths are per language.** Filter with the path of the language you query. A topic with no translation in that language is not indexed for it. + ## Best Practices 1. **Start with use cases** — Which filters do customers need on the storefront? Build maps backward from those facets. @@ -293,16 +344,17 @@ Generate topic maps as a `topicTree` JSON document. `displayColor` is required o ❌ **`id` with spaces, uppercase, or special characters** — `Light Red` or `light_red` is wrong; `light-red` is right. -❌ **`pathIdentifier` ≠ `id`** — Keep them in sync to avoid editor confusion and broken URLs. +❌ **`pathIdentifier` ≠ `id`** — Keep them in sync, so the `id` in the design reads as the path segment the topic gets. ❌ **Flat list of 40+ topics** — Nest them. `Colors → Reds → Light Red, Crimson, …` scales; a flat list of 40 colors does not. ## Integration with Other Skills - **[[information-architecture]]** — Folders define where an item lives (single hierarchy). Topics define what it is about (multi-dimensional). Design folders and topic maps together. -- **[[content-model]]** — Shapes can reference topic maps via a `topicReference` component, or items can use the built-in `topics` field. If a classification needs rich content (logo, description, icon), model it as a Document via the Semantic Bridge pattern instead of a topic map. -- **[[mutation]]** — Use the Core API to write the generated `topicTree` to a tenant. The output of this skill is the design payload; the mutation skill explains how to ship it. -- **[[query]]** — Use the Discovery API to filter and aggregate by topic on the storefront (`topics.path.equals`, topic aggregations for facet counts). +- **[[content-model]]** — Items and variants carry topics through the built-in `topics` field; there is no topic component. If a classification needs rich content (logo, description, icon), model it as a Document via the Semantic Bridge pattern instead of a topic map. +- **[[mutation]]** — Core API conventions. Write the tree as described in Shipping a Topic Tree, then tag items and variants with `topicIds`. +- **[[mass-operations]]** — The `topic/*` intents, for bulk or re-runnable imports. +- **[[query]]** — Use the Discovery API to filter and aggregate by topic on the storefront — see Topics in Discovery above. ## Related Links From 7ab0995ecd1f4e32f846d945339522761cf24fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 15:55:20 -0700 Subject: [PATCH 10/29] fix(skills): align mutation, query and content-model with vector ranking and topics Discovery orders by itemId unless sorting says otherwise, taste changes need a publish rather than an ignition, an omitted stacks argument errors on a tenant already served for ranking, and shapes do not reference topics. --- use-crystallize/skills/content-model/SKILL.md | 2 +- .../skills/mutation/references/core-api.md | 17 +++++++++-------- .../skills/query/references/discovery-api.md | 3 +++ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/use-crystallize/skills/content-model/SKILL.md b/use-crystallize/skills/content-model/SKILL.md index 6c6aa89..60d1261 100644 --- a/use-crystallize/skills/content-model/SKILL.md +++ b/use-crystallize/skills/content-model/SKILL.md @@ -419,4 +419,4 @@ Before presenting the mass operations JSON, verify: - [Shapes Reference](references/shapes.md) - Shape types, variantComponents, and creation process - [Pieces Reference](references/pieces.md) - Reusable field sets, standard SEO piece - [Taxonomies Reference](references/taxonomies.md) - Topic Maps and Grids -- [[taxonomy]] — Use this skill to design topic maps (hierarchical taxonomies with `displayColor`, `pathIdentifier`, nesting) before referencing them from shapes here. +- [[taxonomy]] — Use this skill to design topic maps (hierarchical taxonomies with `displayColor`, `pathIdentifier`, nesting). Topics are a built-in field on every item, so a shape does not reference them. diff --git a/use-crystallize/skills/mutation/references/core-api.md b/use-crystallize/skills/mutation/references/core-api.md index 2f2dd24..d64e92a 100644 --- a/use-crystallize/skills/mutation/references/core-api.md +++ b/use-crystallize/skills/mutation/references/core-api.md @@ -772,21 +772,22 @@ mutation Index { `setItemTaste` and `igniteDiscoApi` return unions whose error members implement `BasicError`, so a single fragment covers every failure and `errorName` identifies it. `upsertVocabulary` returns the `Vocabulary` -directly and fails as a GraphQL error. `setItemTaste` and `igniteDiscoApi` can both return -`ExperimentalFeaturesNotAvailableError`, which means vectors are not enabled for the tenant. +directly and fails as a GraphQL error. -Read back with `vocabulary(name:)` and `item(id:, language:) { taste { vocabulary entries { key weight } } }`. +Read back with `vocabulary(name:)` and `item(id:, language:) { ... on Item { taste { vocabulary entries { key weight } } } }`. `publishItem` and `publishItems` both run in the background, so neither return is proof that the items are published yet — index right after them and the index may read the old versions. Wait for `item(id:, language:, versionLabel: published)` to show the new content first. -**Re-run `igniteDiscoApi` after every change to vocabularies or taste entries** — an unindexed change -has no effect and raises no error. Run it **last**, once shapes and published content exist. A task +**Run `igniteDiscoApi` the first time, and again after every `upsertVocabulary`** — an unindexed vocabulary +change has no effect and raises no error. A taste change needs no ignition on a tenant already served for +ranking: publishing the item re-indexes it. Run it **last**, once shapes and published content exist. A task that ends in `error` is blocking: `bulkTask(id:) { ... on BulkTask { status info } }` carries the reason -in `info.traces`. Probe Discovery with `{ __typename }` before reporting success. Omitting `stacks: opensearch` likewise fails silently: the index -rebuilds, but without vectors. Full guidance, including vocabulary design, positional weights and -key validation, is in the [[vector-ranking]] skill. +in `info.traces`. Probe Discovery with `{ __typename }` before reporting success. On a tenant not yet served +for ranking, omitting `stacks: opensearch` fails silently: the index rebuilds, but without vectors. On a +tenant already served for ranking, the task ends in `error`. Full guidance, including vocabulary design, +positional weights and key validation, is in the [[vector-ranking]] skill. ## Only in the legacy PIM API diff --git a/use-crystallize/skills/query/references/discovery-api.md b/use-crystallize/skills/query/references/discovery-api.md index d5c2bb0..ea07604 100644 --- a/use-crystallize/skills/query/references/discovery-api.md +++ b/use-crystallize/skills/query/references/discovery-api.md @@ -348,6 +348,9 @@ component fields available directly. This is the recommended approach for storef } ``` +**With no `sorting`, hits come back by `itemId` descending, not by relevance**, even with a `term`. Send +`sorting: { score: desc }` to order by relevance. + **Always add a deterministic secondary field** (such as `itemId`) so pagination stays stable across pages. Note that `sorting` does **not** compose predictably with ranking — see below. From c720fe011dffb75aff707db78f7bca35fb05fdc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 15:58:51 -0700 Subject: [PATCH 11/29] fix(skills): correct client auth, shop pipeline ids and helper examples Bearer tokens only work on Core APIs and cannot fetch Shop API tokens, and tenant-scoped tokens are refused on /@me. Shop pipelines take ids, and examples no longer chain writes that race. --- use-crystallize/skills/js-api-client/SKILL.md | 187 +++++++++++++----- 1 file changed, 139 insertions(+), 48 deletions(-) diff --git a/use-crystallize/skills/js-api-client/SKILL.md b/use-crystallize/skills/js-api-client/SKILL.md index 3bbaccb..d758c1d 100644 --- a/use-crystallize/skills/js-api-client/SKILL.md +++ b/use-crystallize/skills/js-api-client/SKILL.md @@ -1,6 +1,6 @@ --- name: js-api-client -description: Use the @crystallize/js-api-client package to interact with Crystallize APIs in JavaScript/TypeScript. Use when setting up the Crystallize API client, configuring credentials (including bearer tokens for plugins and delegated calls), calling catalogueApi/discoveryApi/pimApi/nextPimApi/meApi or the Shop API callers (shopCartApi, shopOrderApi, shopCustomerApi, shopLockApi, shopSubscriptionContractApi, shopBookingApi), configuring Shop API token scopes, working with high-level helpers for catalogue fetching, cart management, Shop API orders (createFromCart, addPayments, setPayments, pipeline stages), Shop API customers, distributed locks, Core orders, customers, subscriptions, navigation, or using any helper from the @crystallize/js-api-client npm package (createCartManager, createShopOrderManager, createShopCustomerManager, createShopLock, createOrderManager, createCustomerManager, …). +description: Use the @crystallize/js-api-client package to interact with Crystallize APIs in JavaScript/TypeScript. Use when setting up the Crystallize API client, configuring credentials (including the bearer token of a plugin), calling catalogueApi/discoveryApi/pimApi/nextPimApi/meApi or the Shop API callers (shopCartApi, shopOrderApi, shopCustomerApi, shopLockApi, shopSubscriptionContractApi, shopBookingApi), configuring Shop API token scopes, working with high-level helpers for catalogue fetching, cart management, Shop API orders (createFromCart, addPayments, setPayments, pipeline stages), Shop API customers, distributed locks, Core orders, customers, subscriptions, navigation, or using any helper from the @crystallize/js-api-client npm package (createCartManager, createShopOrderManager, createShopCustomerManager, createShopLock, createOrderManager, createCustomerManager, createBinaryFileManager, createSignatureVerifier, …). metadata: author: Crystallize version: "2.1" @@ -20,7 +20,7 @@ Before writing code, understand the context. Ask clarifying questions: Shop API for checkout: carts (`/cart`), orders made from them (`/order`), customers (`/customer`), locks (`/lock`). Orders and customers exist on both sides — pick the helper for the side you are on (see [Shop API](#shop-api-carts-orders-customers-locks)). -4. **What auth do you have?** `staticAuthToken` is enough for read-only. PIM/Shop operations need `accessTokenId` + `accessTokenSecret`. For plugins or delegated calls where the caller already holds a JWT, pass it as `bearerToken` — it becomes `Authorization: Bearer `. +4. **What auth do you have?** `staticAuthToken` is enough for read-only. PIM/Shop operations need `accessTokenId` + `accessTokenSecret`. A plugin passes its `backendToken` JWT as `bearerToken` — it becomes `Authorization: Bearer ` and works on the Core APIs only (see [Authentication](#authentication)). 5. **Is this server-side or client-side?** The client works in both, but credentials should only live server-side. ## Installation @@ -42,7 +42,7 @@ const api = createClient({ // accessTokenId: '…', // accessTokenSecret: '…', // staticAuthToken: '…', // for read-only catalogue/discovery - // bearerToken: '…', // JWT sent as `Authorization: Bearer ` (e.g. plugin backendToken) + // bearerToken: '…', // JWT sent as `Authorization: Bearer ` (plugin backendToken) }); // Call any GraphQL API with string queries @@ -68,26 +68,28 @@ createClient(configuration, options?) ### Configuration Options -| Option | Description | -| ------------------------------------- | ----------------------------------------------------------------------------- | -| `tenantIdentifier` | **Required**. Your tenant name | -| `tenantId` | Optional tenant ID | -| `accessTokenId` / `accessTokenSecret` | For PIM/Shop operations | -| `sessionId` | Alternative to token-based auth | -| `staticAuthToken` | For read-only catalogue/discovery | -| `bearerToken` | JWT forwarded as `Authorization: Bearer ` (e.g. a plugin `backendToken`) | -| `shopApiToken` | A Shop API token to use as is; auto-fetched when omitted | -| `shopApiStaging` | Use staging Shop API | -| `origin` | Custom host suffix (default: `.crystallize.com`) | +| Option | Description | +| ------------------------------------- | ------------------------------------------------------------------------ | +| `tenantIdentifier` | **Required**. Your tenant name | +| `tenantId` | Optional tenant ID | +| `accessTokenId` / `accessTokenSecret` | For PIM/Shop operations | +| `sessionId` | Alternative to token-based auth | +| `staticAuthToken` | For read-only catalogue/discovery | +| `bearerToken` | JWT forwarded as `Authorization: Bearer ` (a plugin `backendToken`) | +| `shopApiToken` | A Shop API token; auto-fetched when omitted | +| `shopApiStaging` | Use staging Shop API | +| `origin` | Custom host suffix (default: `.crystallize.com`) | ### Client Options -| Option | Description | -| -------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `useHttp2` | Enable HTTP/2 transport | -| `profiling` | Profiling callbacks for debugging | -| `extraHeaders` | Extra request headers for all calls | -| `shopApiToken` | `{ scopes?, expiresIn?, doNotFetch? }` — Shop API token fetching, see [Shop API](#shop-api-carts-orders-customers-locks) | +| Option | Description | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `useHttp2` | Enable HTTP/2 transport | +| `http2IdleTimeout` | HTTP/2 idle timeout in milliseconds (default 5 minutes) | +| `timeout` | Abort requests that take longer than this many milliseconds | +| `profiling` | Profiling callbacks for debugging | +| `extraHeaders` | Extra request headers for all calls except the Shop API callers | +| `shopApiToken` | `{ scopes?, expiresIn?, doNotFetch? }` — Shop API token fetching (`expiresIn` in seconds, default 12 hours), see [Shop API](#shop-api-carts-orders-customers-locks) | ## API Callers @@ -122,13 +124,20 @@ Available helpers: | `createProductHydrater` | `@crystallize/js-api-client` | Fetch products/variants by path or SKU | | `createOrderFetcher` | `@crystallize/js-api-client` | **Core**: fetch orders with type-safe field selection | | `createOrderManager` | `@crystallize/js-api-client` | **Core**: register/update orders, set payments, pipelines | +| `createCustomerFetcher` | `@crystallize/js-api-client` | **Core**: fetch customers by identifier or reference | | `createCustomerManager` | `@crystallize/js-api-client` | **Core**: create and update customers | | `createCustomerGroupManager` | `@crystallize/js-api-client` | **Core**: manage customer groups | +| `createSubscriptionContractFetcher` | `@crystallize/js-api-client` | **Core**: fetch subscription contracts | | `createSubscriptionContractManager` | `@crystallize/js-api-client` | **Core**: create and manage subscription contracts | +| `createBinaryFileManager` | `@crystallize/js-api-client` | **Core**: upload images and files (server-side only) | | `createCartManager` | `@crystallize/js-api-client` | **Shop**: hydrate, fetch, place, fulfill and abandon carts | | `createShopOrderManager` | `@crystallize/js-api-client` | **Shop**: order from a cart, payments, pipelines (7.5+) | | `createShopCustomerManager` | `@crystallize/js-api-client` | **Shop**: upsert customers, addresses, meta (7.5+) | | `createShopLock` | `@crystallize/js-api-client` | **Shop**: acquire/release a distributed lock (7.5+) | +| `createSignatureVerifier` | `@crystallize/js-api-client` | Verify the signature of a webhook | +| `createPluginPayloadDecrypter` | `@crystallize/js-api-client` | Decrypt a plugin payload | +| `pricesForUsageOnTier` | `@crystallize/js-api-client` | Prices for a usage on volume or graduated tiers | +| `createMassCallClient` | `@crystallize/js-api-client` | Deprecated: batched calls, use `p-limit` instead | ## Shop API: carts, orders, customers, locks @@ -136,11 +145,17 @@ The Shop API is one GraphQL endpoint per concern at `https://shop-api.crystalliz its own `shop*Api` caller. Since **7.5.0** (which needs `@crystallize/schema` 6.15.0) there are helpers for `/order`, `/customer` and `/lock` next to the cart manager. -**One token for all of them.** The client fetches a Shop API token with your access token pair (or `sessionId` / -`bearerToken`), asking for `shopApiToken.scopes` (default `['cart']`). When a caller needs an endpoint the token does -not cover, the token is refetched with the union of the scopes; it is also refreshed 5 minutes before it expires. List -every endpoint you use up front to get a single token. A `shopApiToken` passed in the configuration is used as is -when it carries no `scopes` claim; with `doNotFetch: true` nothing is ever fetched. +**One token for all of them.** The client fetches a Shop API token with your access token pair (or `sessionId`), +asking for `shopApiToken.scopes` (default `['cart']`). When a caller needs an endpoint the token does not cover, the +token is refetched with the union of the scopes; it is also refreshed 5 minutes before it expires. List every endpoint +you use up front to get a single token. A `shopApiToken` passed in the configuration goes through the same checks, +except that a token with no `scopes` claim is trusted for any endpoint; with `doNotFetch: true` it is used as is and +nothing is ever fetched. + +**The fetch needs an access token pair or a session.** The token endpoint does not read a `bearerToken`, and it checks +who you are on `/@me`, which refuses a tenant-scoped access token. A bearer-only client, a `bearerToken` set next to an +access token pair (it is sent instead of the pair) and a tenant-scoped pair all fail with +`Could not fetch shop api token: …`. ```typescript import { createCartManager, createClient, createShopOrderManager } from "@crystallize/js-api-client"; @@ -152,31 +167,72 @@ const api = createClient( const carts = createCartManager(api); const orders = createShopOrderManager(api); -const placed = await carts.place(cartId, { total: { gross: true, currency: true } }); // frozen: charge this total +const cart = await carts.hydrate( + { + items: [{ sku: "SKU-1", quantity: 2 }], + context: { + language: "en", + ttl: 3600 * 24 * 30, + price: { + currency: "eur", + decimals: 4, + selectedVariantIdentifier: "default", + pricesHaveTaxesIncludedInCrystallize: false, + discountOnNetPrices: false, + markets: [], + }, + }, + }, + { total: { gross: true, currency: true } }, +); +const placed = await carts.place(cart.id, { total: { gross: true, currency: true } }); // frozen: charge this total // … the payment provider confirms the payment (webhook) … -const order = await orders.createFromCart(cartId, { +const order = await orders.createFromCart(cart.id, { type: "standard", paymentStatus: "paid", // set once: the Shop API has no mutation to change it later payments: [{ provider: "stripe", transactionId: "pi_123", amount: 100, method: "card" }], - pipelines: [{ identifier: "fulfilment", stage: "new" }], + pipelines: [{ identifier: pipelineId, stage: stageId }], // ids from Core, not names }); +// … later, each write on its own, once the previous one shows in a read … await orders.addPayments(order.id, [ { provider: "stripe", transactionId: "re_1", amount: 20, meta: [{ key: "type", value: "refund" }] }, ]); await orders.setPayments(order.id, [/* the whole list */]); // replaces every payment -await orders.addToStage(order.id, "fulfilment", "shipped"); +await orders.addToStage(order.id, pipelineId, shippedStageId); const read = await orders.fetch(order.id, { payments: { provider: true, transactionId: true, meta: true } }); ``` -- **The order id is the cart id**, and `createFromCart` only accepts a `placed` cart. +- **`hydrate` takes a whole `context`.** Every `context` field above is required, or the client throws before calling. + With no `context` at all, which only plain JavaScript allows, it sends the defaults: `en`, 30 days, `eur`, 0 decimals, the + `default` price variant. The schema silently drops what it does not know, so `type`, `name`, `context.catalogue` and + `context.wishlist` never reach the API: use `shopCartApi` for those. +- The cart manager also has `addSkuItem(id, item)`, `removeItem(id, { sku, quantity })`, `setMeta(id, { meta, merge })` + (`merge` is required) and `setCustomer(id, customer)`. The rest of `/cart` (`addExternalItem`, `bookSkuItem`, + `markAsWishlist`, `remove`, `orderIntent`, `carts`) has no helper: call it through `shopCartApi`. +- A cart or order customer (`hydrate`, `carts.setCustomer`, `orders.setCustomer`) needs `isGuest`, `type` and `addresses` + (`[]` when there are none). `birthDate` is a `Date`, on Shop customers too. +- **The order id is the cart id**, and `createFromCart` only accepts a `placed` cart. It moves the cart to `ordered` + itself, so `carts.fulfill` is refused after it: `fulfill` is for an order made another way. Always pass an intent: + `createFromCart(cartId)` alone is refused, `{}` is enough. +- **`pipelines`, `addToStage` and `removeFromPipeline` take Core ids, not names.** In `pipelines`, `identifier` is the + pipeline id and `stage` the stage id, required even though the client's type makes it optional. The Shop API stores + any string, and a name such as `fulfilment` then keeps the order from reaching Core. Without `pipelines`, Core uses + the pipelines the tenant set up for new orders. +- **A Shop write also moves the Core stages**: every `/order` write sends the whole order to Core, which moves the order + to match its `pipelines` list. That list does not follow stage moves made in the App or on Core, so a bare + `addPayments` can undo them: see [the Shop order and the Core order](../payments/SKILL.md#the-shop-order-and-the-core-order). - **Payments are generic records**: `provider` is any string, nothing is charged. `meta` is written as `[{ key, value }]` but read back as a JSON object. -- **Writes persist just after the response**: a read right after `createFromCart`, `addPayments` or `setPayments` - can still see the previous state. `fetch` rejects (`JSApiClientCallError`) when the order does not exist. +- **Writes persist just after the response**: a read right after any `/order` write can still see the previous state, + and so can the next write, which loads the order first. `addPayments` right after `createFromCart` can fail with + "not found", and of two quick writes the first can be lost. `fetch` rejects (`JSApiClientCallError`) when the order + does not exist. - **Optional fields reject `null`** (the `@crystallize/schema/shop` inputs are `.optional()`, not nullable, and the - customer input is strict): strip `null`s from data you read back before sending it. + customer input is strict): strip `null`s from data you read back before sending it. An explicit `undefined` fails + too: the client prints it into the query, so leave the key out. - `listByCustomer(identifier, { limit, skip })`, `setMeta(id, { meta, merge })`, `setCustomer(id, customer)` and - `removeFromPipeline(id, pipeline)` complete the order manager. + `removeFromPipeline(id, pipeline)` complete the order manager. The `/order` `create` mutation (an order with no cart) + has no helper: call it through `shopOrderApi`. ```typescript import { createShopCustomerManager, createShopLock } from "@crystallize/js-api-client"; @@ -186,21 +242,35 @@ const exists = await customers.fetch("jane@example.com").then( () => true, () => false, ); // rejects when missing -if (!exists) await customers.upsert({ identifier: "jane@example.com", email: "jane@example.com", firstName: "Jane" }); -await customers.addAddress("jane@example.com", { type: "delivery", street: "Main St 1", city: "Oslo" }); +const address = { type: "delivery", street: "Main St 1", city: "Oslo" } as const; +if (exists) await customers.addAddress("jane@example.com", address); +else + await customers.upsert({ + identifier: "jane@example.com", + email: "jane@example.com", + firstName: "Jane", + addresses: [address], + }); // also: setAddress(identifier, index, address), removeAddress(identifier, index), setMeta(identifier, { meta }) const lock = createShopLock(api); // `lock` scope -if (await lock.acquire(`order:${cartId}`, 30)) { +if (await lock.acquire(`order-${cartId}`, 30)) { // ttl in seconds (default 60); false when already held try { // … work that must not run twice at once } finally { - await lock.release(`order:${cartId}`); + await lock.release(`order-${cartId}`); } } ``` +- **`upsert` replaces the whole customer**: what you leave out is cleared, addresses, meta and external references + included. To change one part, use `addAddress`, `setAddress`, `removeAddress` or `setMeta`. +- Customer writes persist just after the response too, so `addAddress` right after `upsert` can fail with "not found": + pass `addresses` to `upsert`. +- **Lock keys keep only `a-z`, `0-9` and `-`**: any other character is dropped, so `Order:1` and `rder1` are the same + lock. + **Shop or Core?** An order made from a cart lives on the Shop API: create it with `createShopOrderManager` (`createFromCart`) and keep recording its payments there. `createOrderManager` writes Core orders (`register`, `update`, `setPayments`) — for orders that never had a cart (imports, POS, back office). A Core write on a Shop order @@ -213,19 +283,29 @@ needs the five new callers. Cart and Shop customer `birthDate` is now sent as an ## Authentication -| Auth Type | Header sent | Use Case | -| ------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| `staticAuthToken` | `X-Crystallize-Static-Auth-Token: ` | Read-only catalogue/discovery | -| `accessTokenId` + `accessTokenSecret` | `X-Crystallize-Access-Token-Id` + `X-Crystallize-Access-Token-Secret` | PIM/Shop operations | -| `sessionId` | `Cookie: connect.sid=` | Alternative to token pair | -| `bearerToken` | `Authorization: Bearer ` | JWT-based auth — plugins forward `envelope.backendToken`; delegated/per-user calls | -| `shopApiToken` | `Authorization: Bearer ` (Shop API only) | Optional; fetched with the auth above (never `staticAuthToken`) if omitted | +| Auth Type | Header sent | Use Case | +| ------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `staticAuthToken` | `X-Crystallize-Static-Auth-Token: ` | Read-only catalogue/discovery | +| `accessTokenId` + `accessTokenSecret` | `X-Crystallize-Access-Token-Id` + `X-Crystallize-Access-Token-Secret` | PIM/Shop operations | +| `sessionId` | `Cookie: connect.sid=` | Alternative to token pair | +| `bearerToken` | `Authorization: Bearer ` | Plugins forward `envelope.backendToken`, the only JWT accepted; Core APIs only | +| `shopApiToken` | `Authorization: Bearer ` (Shop API only) | Optional; fetched with the access token pair or `sessionId` if omitted | **Priority** when multiple are set on the same client: `sessionId` > `bearerToken` > `staticAuthToken` > `accessTokenId`/`accessTokenSecret`. Only one is sent per request. Per-caller restrictions: -- `discoveryApi` only honors `bearerToken` and `staticAuthToken`. -- `catalogueApi` honors `bearerToken`, `staticAuthToken`, and `accessTokenId`/`accessTokenSecret`. -- `pimApi`, `nextPimApi`, and `meApi` honor `sessionId`, `bearerToken`, and `accessTokenId`/`accessTokenSecret`. +- `discoveryApi` sends `bearerToken` or `staticAuthToken`, but Discovery reads only the static token: a bearer token is + ignored. +- `catalogueApi` sends `bearerToken`, `staticAuthToken`, or `accessTokenId`/`accessTokenSecret`, but Catalogue reads only + the credential the tenant requires, the static token or the pair: a bearer token is ignored. +- `pimApi` and `nextPimApi` honor `sessionId`, `bearerToken`, and `accessTokenId`/`accessTokenSecret`. +- `meApi` sends the same three, but `/@me` answers 403 to a tenant-scoped token: a plugin `backendToken`, or an access + token created with a tenant scope. Use a session or an access token with no tenant scope. + +**A higher-priority credential hides the others.** With a `bearerToken` set, `staticAuthToken` is never sent, so +Discovery and Catalogue reject the call on a tenant that protects them, and the Shop API token cannot be fetched. With +`staticAuthToken` next to an access token pair, Catalogue gets only the static token, which fails when the tenant +requires the pair. Use one client per credential. A plugin payload carries the tenant's static token, when it has one, +as `envelope.staticAuthToken`. Generate access tokens in the Crystallize App: **Settings → Access Tokens**. For plugin-issued JWTs, use the `backendToken` from the decrypted iframe/webhook payload — see the [plugins skill](../plugins/SKILL.md). @@ -244,6 +324,17 @@ const { tenant } = await api.nextPimApi<{ tenant: { id: string; name: string } } ); ``` +## Errors + +- A failed call rejects with `JSApiClientCallError` (`code` and `statusCode`, `statusText`, `message`, `errors`, `query`, + `variables`). That covers a non-2xx response, a GraphQL `errors` array (`code` is then 400), and a Core Next error + union: any field of `data` that carries an `errorName`. +- The client only sees a union error the query selects. In raw `nextPimApi` calls add + `... on BasicError { errorName message }`, or the failure comes back as an empty object and nothing throws. +- Most helpers validate their input with the `@crystallize/schema` Zod schemas: a bad input throws a `ZodError` before + any request is sent. +- A failed Shop API token fetch throws a plain `Error` (`Could not fetch shop api token: …`). + ## References - [Official Documentation](https://crystallize.com/docs/developer/sdk/js-api-client) - High-level helpers and utilities (GraphQL Builder, Signature Verification, Binary File Manager, Pricing Utilities, Request Profiling) From 1f90f8b86984ce40e3328b316425102a90f1e591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 16:01:50 -0700 Subject: [PATCH 12/29] fix(skills): correct stale booking race, availability and capacity claims Two bookings racing on one cart now refuse the loser instead of losing it silently, and availability, hydrate pairing and refusal names follow the current API. Adds the /booking/admin reference and the gotchas around plain lines, capacity counting and unit renames. --- .../skills/bookable-resources/SKILL.md | 29 ++-- .../references/booking-admin.md | 78 ++++++++++ .../references/booking-flow.md | 135 ++++++++++++------ .../references/modelling.md | 7 +- .../references/policies-and-pools.md | 45 ++++-- 5 files changed, 228 insertions(+), 66 deletions(-) create mode 100644 use-crystallize/skills/bookable-resources/references/booking-admin.md diff --git a/use-crystallize/skills/bookable-resources/SKILL.md b/use-crystallize/skills/bookable-resources/SKILL.md index b4b9926..27b0d26 100644 --- a/use-crystallize/skills/bookable-resources/SKILL.md +++ b/use-crystallize/skills/bookable-resources/SKILL.md @@ -3,13 +3,15 @@ name: bookable-resources description: > Sell Crystallize products that are held in time rather than counted in stock — rentals, services, rooms, courses, equipment, appointments. Covers booking policies and bookable pools on the Core API, - and the booking flow on the Shop API /cart endpoint: availability, holds, checkout and confirmation. + the booking flow on the Shop API /cart endpoint (availability, holds, checkout and confirmation), and + the Shop API /booking/admin endpoint (maintenance blocks, reservation calendars, staff cancellations). Use when the user wants to rent something out, take bookings or reservations, sell time slots or - by the day/weekend/week, show a booking calendar, manage a fleet of machines, rooms or seats, or - handle cancellations. Trigger on "bookable", "booking", "reservation", "rental", "rent", "hire", - "availability", "time slot", "calendar", "hold", "setBookable", "bookSkuItem", - "createBookingPolicy", "confirmCartBooking", "cancelReservation", "rebookReservation", - "nearestAvailability", "checkBooking", "booking policy", "cancellation window", "pool", "units". + by the day/weekend/week, show a booking calendar, manage a fleet of machines, rooms or seats, take a + unit out of service, or handle cancellations. Trigger on "bookable", "booking", "reservation", + "rental", "rent", "hire", "availability", "time slot", "calendar", "hold", "setBookable", + "bookSkuItem", "createBookingPolicy", "confirmCartBooking", "cancelReservation", "rebookReservation", + "nearestAvailability", "checkBooking", "booking/admin", "booking policy", "cancellation window", + "pool", "units". metadata: author: Crystallize version: "1.0" @@ -80,26 +82,31 @@ for the policies to find out whether this session can manage them: A connection means yes. `FORBIDDEN` means the role lacks the `bookingPolicies` permission. That usually happens on a custom role created before bookings existed, and it is fixed on the role, not in -the query. +the query. Putting a pool on a product is a second permission: `setBookable`, `reapplyBookablePolicy` +and `clearBookable` need `productBookable` on top of the right to edit the item. ## Failure modes Three of these are silent — a paid booking whose reservation has no order, a pool or policy change that -has no effect, and a booking that vanishes when two are taken at once. They are the reason this skill -exists; the rest name themselves. +has no effect, and a bookable SKU sold with nothing held. They are the reason this skill exists; the +rest name themselves. | Symptom | Cause | Fix | | ------------------------------------------------------------ | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Admin shows "No order — not checked out" on a paid booking | `confirmCartBooking` ran before the cart had its `orderId` | Confirm **again** after `createFromCart` — see [booking-flow](references/booking-flow.md) | +| An order for a bookable product with nothing reserved | The SKU went through `addSkuItem` or `hydrate` with no `booking`: a plain line | Book with `bookSkuItem`, or send `booking` on the `hydrate` line | | `NotBookable` on a product you just made bookable | The item is not published, or the query's `language` is wrong | Publish it; pass the language the item exists in | | A pool or policy change has no effect in the Shop | Bookable edits are drafts; products keep the `policySnapshot` they were set with | `reapplyBookablePolicy` for a policy change, then **publish** the product | | `FORBIDDEN` from `createBookingPolicy` | The role has no `bookingPolicies` permission | Grant it on the role | | Holds vanish within seconds, or `InvalidRange` on every date | A duration was sent in minutes, hours or days | Every policy duration is **seconds**; read `humanized` back to check | | `hydrate` throws "A placed cart cannot be hydrated" | The cart is placed; its contents are frozen | Change bookings before `place`, or through `/booking/admin` after | | `ReservationConflict` on a unit that looked free | Someone took it between the availability query and the booking | Walk the other `freeUnitIds` and retry | -| Two bookings taken at once, one of them missing | Concurrent `bookSkuItem` on one cart — both answered `Cart` | Serialize booking writes per cart, then read the cart back | +| `ReservationConflict` whose `message` says the cart changed | Another write landed on the same cart first, and nothing was booked | Read the cart back, then retry; serialize booking writes per cart | +| `ReservationConflict` on a capacity pool with seats free | Capacity counts every booking overlapping the window, not the busiest moment | Keep capacity windows uniform, or use units | +| `Cannot place this cart: ()` | `place` or `hydrate` could not keep a booking line; see the reason | Nothing was saved: `hydrate` with another window or without the line | | `InvalidRange` on a date far ahead | The window is past the policy's `advanceWindow` | Widen the policy, or cap the calendar at the shortest `advanceWindow` in the basket | | `CancellationWindowClosed` when removing a basket line | The window applies to holds that were never bought | Re-hydrate the cart without that line instead | +| `Unexpected error.` from `addCartItem` or `removeCartItem` | They refuse a booking line | `hydrate` with the new quantity, or without the line | | `BookablePoolKindChangeError` | Capacity → units while a capacity pool is published | `clearBookable`, publish, cancel or wait out open reservations, then set units | | `BookingPolicyInUseError` on delete | Products still reference the policy (`stats.referencingProductCount`) | Move those products to another policy and publish them | @@ -109,6 +116,8 @@ exists; the rest name themselves. capacity pools, snapshots and reapplying, deleting and clearing. - [references/booking-flow.md](references/booking-flow.md) — Shop API `/cart`: availability, holds, checkout, the double confirmation, cancelling and moving a booking. +- [references/booking-admin.md](references/booking-admin.md) — Shop API `/booking/admin`: maintenance + blocks, the reservation calendar and list, moving a reservation to another unit. - [references/modelling.md](references/modelling.md) — periods as variants, units as real machines, what Discovery serves, and how a rental relates to the product it is a rental of. diff --git a/use-crystallize/skills/bookable-resources/references/booking-admin.md b/use-crystallize/skills/bookable-resources/references/booking-admin.md new file mode 100644 index 0000000..47a47ba --- /dev/null +++ b/use-crystallize/skills/bookable-resources/references/booking-admin.md @@ -0,0 +1,78 @@ +# Booking administration (Shop API `/booking/admin`) + +For back-office tools: taking a unit out of service, reading who holds what, and changing what a shopper +cannot. Run it server-side only. + +The token needs both the `booking` and `booking:admin` scopes, so ask for +`"scopes": ["booking", "booking:admin"]` when you mint it (see [[mutation]]). Post to +`https://shop-api.crystallize.com/@{tenant}/booking/admin` yourself: `client.shopBookingApi` in +`@crystallize/js-api-client` posts to `/booking`, which serves nothing and answers 404. + +**Refusals are GraphQL errors here, not union members.** Branch on `extensions.reason`, under +`extensions.code: "BOOKING_REJECTED"`: `NotBookable`, `NotFound`, `Conflict`, `InvalidRange` or +`InvalidUnitId`. + +## Take a unit out of service + +Do not fake a cart hold, and do not remove the unit from the pool. Block it: + +```graphql +mutation { + block( + input: { + productId: "6ab3…" + sku: "RENT-GAS55-DAY" + start: "2026-10-05T00:00:00Z" + end: "2026-10-07T00:00:00Z" + unitId: "GAS55-OSL-1" + source: MAINTENANCE + sourceReason: "annual service" + language: "en" + } + ) { + id + unitId + state + } +} +``` + +- `source` is `MAINTENANCE`, `STAFF_HOLD`, `PARTNER_ALLOCATION` or `OTHER`. `sku` and `language` are + required because the pool is read through the variant, in one language. +- Without a `unitId`, `quantity` (default 1) takes that many of the pool, all or none. With a `unitId` it + must be 1. The answer is a list with one reservation per unit of quantity, and each needs its own + `cancel`. +- The `advanceWindow` does not apply to a block; the policy's buffers do. +- **A block never expires.** It reads `state: PENDING` with `expiresAt: null` and holds its window until + you `cancel` it. +- `Conflict` means something already holds that window. Move it with `assignUnit`, or cancel it. +- `bulkBlock(inputs:)` takes several and answers `results`, one `{ ok, reservations, reason }` per input + in request order. It is not atomic, so reissue only the failed ones. + +`checkBooking(input:)` is the dry run for a block. It takes the same fields as the `/cart` one plus a +required `source`, and refuses `source: CART`. + +## Read the reservations + +| Query | Use it for | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| `calendar(filter:)` | A range, grouped by product. `rangeStart` and `rangeEnd` required; refused past 5,000 rows | +| `reservations(filter:, limit:, after:)` | A flat list, paged by `nextCursor`. `limit` defaults to 50, at most 5,000. Range optional | +| `reservationSummary(productId:, range:, states:)` | `total`, `byState` and `bySource` for one product | +| `reservation(id:)` | One row, with the `customerId`, `frozenPolicy` and `meta` the `/cart` query does not serve | + +- Filters take `productIds`, `states`, `sources`, `unitId`, `orderId`, `cartId` and `customerId`. +- Omit `states` and you get every state, finished ones included. Pass `[PENDING, CONFIRMED]` for what + actually holds the calendar. +- `orderId` only finds rows confirmed after the cart had its order. For the others, filter by `cartId`: + the order id is the cart id. +- Rows carry `productId` and `variantSku`, never names. Hydrate those from the Catalogue or Discovery. +- These queries serve open reservations and 90 days of finished ones. An older booking is on its order, + in the `reservations` meta. See [booking-flow](booking-flow.md#after-the-order). + +## Change a reservation + +`cancel` and `bulkCancel` are in [booking-flow](booking-flow.md#after-the-order). +`assignUnit(id:, unitId:, language:)` moves a reservation to another unit of the pool, keeping its window, +and is refused with `Conflict` if that unit is taken. `setMeta(id:, meta:, merge:)` writes the +reservation's own `meta`. All of these update the record on the order that bought the reservation. diff --git a/use-crystallize/skills/bookable-resources/references/booking-flow.md b/use-crystallize/skills/bookable-resources/references/booking-flow.md index dcee233..cd80b05 100644 --- a/use-crystallize/skills/bookable-resources/references/booking-flow.md +++ b/use-crystallize/skills/bookable-resources/references/booking-flow.md @@ -3,6 +3,10 @@ Everything the storefront does. All of it is on the `/cart` endpoint except the order itself, which is `/order`. See [[mutation]] for tokens and endpoints. +The Shop API reads products from the Catalogue with the credentials on the request, and the Shop token is +not one of them. On a tenant whose Catalogue requires authentication, also send its +`X-Crystallize-Static-Auth-Token`, or its access token pair, on every call that reads a product. + ## 1. What is free ```graphql @@ -18,20 +22,32 @@ query Availability($productId: String!, $sku: String!, $range: CartTimeRangeInpu } ``` -One slot per `granularitySec` across the range: `86400` for a day view, `3600` for hours. `free` is how -many units are open, `freeUnitIds` names them, and `reason` says why a slot is closed. - -- **`language` is required and it must be a language the item exists in.** The wrong one reads as - `NotBookable` rather than as an error. +One slot per `granularitySec` across the range: `86400` for a day view, `3600` for hours. A query is +refused past 10,000 slots, or past 100,000 slots × units. `free` is how many units or seats are open. +`freeUnitIds` names the units you may pin, and is always `[]` on a capacity pool. `bookable` is the time +check alone (not elapsed, inside the `advanceWindow`), and `reason` explains a `false`. So a sold-out +slot reads `{ free: 0, bookable: true, reason: null }`: a calendar needs both. + +- **`language` is required and it must be a language the item exists in.** The wrong one reads like an + unbookable product: a GraphQL error with `extensions.reason: "NotBookable"` from `availability` and + `nearestAvailability`, and `{ ok: false, reason: "NotBookable" }` from `checkBooking`. +- **`bookSkuItem` has no `language`.** It reads the product in the cart's `context.language`, and a + `hydrate` that sends no `context` means `en`. Hydrate the cart in the language you pass here. - Ask for the range the calendar shows, plus enough tail that the longest bookable period starting on the last visible day still fits. +- **A run of open days does not prove the whole window is open.** Each slot is counted alone, so one unit + can be free on Monday and another on Tuesday. Ask `checkBooking` before booking across several slots. `nearestAvailability(productId, sku, around, durationSec, n, language)` answers "the next `n` windows of -this length near this time" — the right query behind a "next free slot" button. +this length from this time" as `{ start, end }` — the right query behind a "next free slot" button. `n` +defaults to 5, at most 100. It only walks forward, up to 30 days, in steps of `durationSec`: a 7-day +window is tried once a week, not every day. A `durationSec` under 260 is refused. `checkBooking(input: CartCheckBookingInput!)` is a dry run of one exact booking and returns -`{ ok, reason }`, not a union. It takes `productId`, `sku`, `start`, `end`, `language`, and optionally -`unitId` and `quantity`. Debounce it: it runs the same gates the booking itself does. +`{ ok, reason }`, not a union. `reason` is `NotBookable`, `Conflict`, `InvalidRange` or `InvalidUnitId`: +`Conflict` here, where `bookSkuItem` says `ReservationConflict`. It takes `productId`, `sku`, `start`, +`end`, `language`, and optionally `unitId` and `quantity`. Debounce it: it runs the same gates the booking +itself does. ## 2. Hold it @@ -84,12 +100,12 @@ mutation Book($id: UUID, $input: CartBookingItemInput!) { This is the single thing most likely to produce a wrong price. Measured on a unit pool of 5, booking a **two-day** window on a **one-day** SKU priced at 39 net: -| Sent | Result | -| --------------------------------- | ------------------------------------------------------------- | -| `quantity: 1` | one reservation over the whole window, line **39** — not 78 | -| `quantity: 2` | **two** reservations, both over the whole window, line **78** | -| `quantity: 6` (pool holds 5) | `ReservationConflict` | -| `quantity: 2` **with** a `unitId` | `Output validation error` (`OUTPUT_VALIDATION_ERROR`) — a bug | +| Sent | Result | +| --------------------------------- | ------------------------------------------------------------------------------------------- | +| `quantity: 1` | one reservation over the whole window, line **39** — not 78 | +| `quantity: 2` | **two** reservations, both over the whole window, line **78** | +| `quantity: 6` (pool holds 5) | `ReservationConflict` | +| `quantity: 2` **with** a `unitId` | `OUTPUT_VALIDATION_ERROR`, with `booking.unitId requires quantity 1` in `extensions.errors` | So `quantity: 2` means _two machines_, or _two rooms_, for the same window — never "two days" or "two nights". **Nothing in the booking inputs prices by duration.** A window twice as long costs the same @@ -108,19 +124,25 @@ Two ways out, and the first is the one to reach for: **A `unitId` and `quantity: 1` belong together.** To hold two named units, book twice — once per unit. +**A bookable SKU with no window is a plain line.** `addSkuItem`, or a `hydrate` line with no `booking`, +reserves nothing, and `place` and `createFromCart` let it through: the order exists and the calendar never +heard of it. Nothing warns you, so every line of a bookable product needs a `booking`. + **Put the customer on the cart before booking.** The reservation records who holds it at `bookSkuItem`/`hydrate` time, and only when the cart's customer has an identifier. Nothing sets it later. Create the cart with `hydrate(input: { customer, items: [] })`, or `setCustomer` before the first booking. **On `ReservationConflict`, walk the other `freeUnitIds`.** Between the availability query and the -booking someone else may have taken that unit. Any other refusal is final — stop and tell the shopper. +booking someone else may have taken that unit. The exception is a `message` that says the cart changed: +see [below](#one-write-at-a-time-per-cart). Any other refusal is final — stop and tell the shopper. **A window past the policy's `advanceWindow` is refused as `InvalidRange`**, with nothing to say the policy is what stopped it. Products that are booked together need the same window: a room on a 120-day policy and a boat trip on a 60-day one means a summer booking can hold the room but not the trip. Cap the calendar at the shortest `advanceWindow` in the basket. (Observed in the Boutique Universe build.) -**The reservation id comes back on the line's `meta`**, not on the cart's: +**The reservation id comes back on the line's `meta`**, not on the cart's, comma-separated when the line +holds several: ```json "meta": { "reservationIds": "18a6b9d4-…", "booking.window": "2026-10-01T08:00:00.000Z/2026-10-01T16:00:00.000Z" } @@ -129,36 +151,37 @@ the calendar at the shortest `advanceWindow` in the basket. (Observed in the Bou `booking.window` is written for you. **Write the `unitId` into the line's `meta` yourself.** `CartItem` has no booking field, and a pinned line re-hydrated without its `unitId` does not match its hold: it is rebooked, possibly onto another unit. `reservation(cartId:, id:) { unitId }` can recover a lost one. +It is also how a storefront learns the unit of an unpinned booking, which takes the first free units in +pool order. `bookSkuItem` ignores `group` and `type` on a new line. To group a booking with its services, set `group` when you next `hydrate`. Read one hold with `reservation(cartId:, id:)`: -`{ id, productId, variantSku, unitId, start, end, state, source, cartLineId, orderId, expiresAt }`. +`{ id, productId, variantSku, unitId, start, end, state, source, cartLineId, orderId, expiresAt }`. It +answers `null` for a row this cart does not hold. **Look holds up one at a time.** Several `reservation(cartId:, id:)` fields aliased into a single query sometimes never answer — 3 of 10 attempts in the Boutique Universe build. One query per hold, with a timeout. -### One hold at a time per cart +### One write at a time per cart -Two `bookSkuItem` calls in flight against the same cart lose one of them, and **both answer `Cart`**. -Measured on a unit pool of 5, two different windows, no `unitId`, 3 of 3 attempts: +Every booking write is conditional on the version of the cart it read. Two `bookSkuItem` calls in flight +against one cart land one booking. The other answers `ReservationConflict`, its hold is released, and its +`message` says the cart changed and nothing was booked. A slot conflict has the bare `message` +`ReservationConflict`. `hydrate`, `place`, `cancelReservation` and `rebookReservation` lose the same race +with a GraphQL error: "The cart was changed by another request…". -- one line survives; the other booking is simply not in the cart -- the lost hold is orphaned — `reservation(cartId:, id:)` answers `null` while `availability` still counts - its unit as taken (`free: 4` of 5) -- emptying the cart releases the surviving line only. The orphan lets go by itself after - `pendingHoldDuration` (15 minutes on that policy), and `free` goes back to 5 - -So **serialize the booking writes for one cart** — a queue or a lock per cart id — and never read a -`Cart` reply as proof the line is there: read the cart back and find the line by your own `meta` key. -Reported to Crystallize engineering on 2026-09-28. +So **serialize the booking writes for one cart** — a queue or a lock per cart id — and when a write loses +that race, read the cart back before retrying: the other request may have made this same booking. ## 3. Keep it while they shop **`hydrate` is the whole cart.** A booking line you leave out is cancelled with it, and re-hydrating with -the line slides its expiry forward. There is no "extend the hold" mutation. +the line slides its expiry forward. There is no "extend the hold" mutation. Every hydrate gives all the +holds of the cart one deadline, set by the shortest `pendingHoldDuration` among them. `bookSkuItem` slides +nothing, not even the other holds on that cart. That cuts both ways, and the second half is the useful one: @@ -168,29 +191,39 @@ That cuts both ways, and the second half is the useful one: { "sku": "RENT-GAS55-DAY", "quantity": 1, - "lineId": "…", "booking": { "start": "2026-10-01T08:00:00Z", "end": "2026-10-01T16:00:00Z", "unitId": "GAS55-OSL-1" }, "meta": [{ "key": "unitId", "value": "GAS55-OSL-1" }] } ``` - A line is matched to its hold by window and unit, so resend both unchanged. `lineId` (selectable on - `CartItem`) is the line's server-minted handle; send it back when two lines share a SKU. + A line is matched to its hold per SKU, by window and unit, so resend both unchanged. Lines that match + nothing are paired in payload order and moved, so changing one date moves that line. `hydrate` does + not use `lineId` for this. `lineId` (selectable on `CartItem`) is the line's server-minted handle for + the mutations that target one line, such as `changeCartItemPricing`, which refuse a SKU shared by + several lines without it. + +- **To change a booking**, send it changed. A new line with a `booking` is reserved, and a new `quantity` + takes or releases the difference. A refusal is not a union member here: the whole hydrate fails with + `Cannot place this cart: (ReservationConflict)` and saves nothing. Use `bookSkuItem` for a new + booking when the shopper needs to know why. + +- **To remove one from the basket**, re-hydrate without it. `removeCartItem` refuses a booking line. Do + **not** use `cancelReservation`: the policy's cancellation window applies to a hold that was never + bought, so a rental starting tomorrow under a two-day window answers `CancellationWindowClosed` and the + shopper cannot empty their own basket. Verified against a live tenant. -- **To remove one from the basket**, re-hydrate without it. Do **not** use `cancelReservation`: the - policy's cancellation window applies to a hold that was never bought, so a rental starting tomorrow - under a two-day window answers `CancellationWindowClosed` and the shopper cannot empty their own - basket. Verified against a live tenant. +`abandon(id)` gives back every hold of a draft cart at once, rather than at their expiry. `cancelReservation(cartId:, reservationId:)` succeeds only while the start is at least `cancellationWindow` seconds away. It is right for a line that is far enough out, and `rebookReservation(cartId:, reservationId:, newBooking:)` moves a hold to a new window atomically. It -moves every reservation on that line, so a quantity-3 line moves as one. `ReservationConflict` is the -only outcome worth retrying. +moves every reservation on that line, so a quantity-3 line moves as one, each keeping its unit if that +unit is free in the new window. A `unitId` on such a line answers `InvalidUnitId`. Both mutations answer a +union, so check `__typename`. `ReservationConflict` is the only outcome worth retrying. **All of this is for a draft cart.** Once the cart is placed, `hydrate` throws "A placed cart cannot be -hydrated", and `cancelReservation`/`rebookReservation` throw `InvalidStateError`. Finish every change to -the bookings before `place`. +hydrated", and `cancelReservation`/`rebookReservation` fail with `INPUT_STATE_ERROR`. Finish every change +to the bookings before `place`. ## 4. Place, pay, order, confirm — in that order @@ -229,7 +262,9 @@ async function linkReservations(cartId: string) { it blocks the calendar until its window ends. It is what proves the holds still stand before the shopper is charged. If you keep it, cancel the reservations through `/booking/admin` when the payment fails, or the machine stays blocked with no order behind it. If you drop it, `place` is still the -last check before payment, and the holds live for `placedHoldDuration` while payment runs. +last check before payment, and the holds live for `placedHoldDuration` while payment runs. If that payment +fails, `remove(id)` deletes the cart and gives its unconfirmed holds back at once. It leaves `CONFIRMED` +rows alone, and `abandon` is refused on a placed cart. `confirmCartBooking` answers `Cart`, `NotPlaced`, `NothingToConfirm` or `ReservationNoLongerHeld`. The last one means a hold expired while payment was in flight; select its `missing` field for the ids. It is all or nothing: the surviving holds stay `PENDING`, and the shopper has to pick another window for @@ -257,12 +292,22 @@ cart id. } ``` - `bulkCancel(ids:, reason:)` takes up to 100 ids and answers per id; it is not atomic. + `bulkCancel(ids:, reason:)` takes up to 100 ids and answers per id; it is not atomic. See + [booking-admin](booking-admin.md) for the rest of that endpoint, including why + `client.shopBookingApi` cannot reach it. - **A hold lost after `place` cannot be re-picked on that cart.** A placed cart cannot be hydrated or rebooked. On `ReservationNoLongerHeld`, cancel the survivors (above), refund if the money has moved, and start a new cart for the new window. - A reservation's `state` is a string: `PENDING`, `CONFIRMED`, `COMPLETED`, `CANCELLED` or `EXPIRED`. A hold that runs out becomes `EXPIRED`, up to a minute late, while its line stays in the cart. The next - `hydrate` takes it again if the window is still free and throws `BookingNoLongerAvailable` if not; - `place` refuses it with `HoldNoLongerHeld`. + `hydrate` takes it again if the window is still free. If not it fails with + `Cannot place this cart: (ReservationConflict)`, saving nothing, so send the next one with another + window or without that line. +- `place` refuses a cart with `Cannot place this cart: ()`. `HoldNoLongerHeld` is a hold that + ran out or was cancelled, `UnitNoLongerInPool` a pinned unit that left the published pool, and + `NotBookable` a product no longer published or bookable. The cart stays a draft, so it can be fixed. +- `/order` carries what was booked: `items { lineId booking { start end unitId } }`, where `unitId` is the + pinned unit only. The order's `meta` adds `reservations`, a JSON string with one record per reservation, + real unit included, which outlives the reservation itself. A `reservations.unconfirmed` key, there only + when something went wrong, lists the ids no longer held. Watch for it: the order is created either way. diff --git a/use-crystallize/skills/bookable-resources/references/modelling.md b/use-crystallize/skills/bookable-resources/references/modelling.md index 5403663..60f12b9 100644 --- a/use-crystallize/skills/bookable-resources/references/modelling.md +++ b/use-crystallize/skills/bookable-resources/references/modelling.md @@ -44,7 +44,8 @@ meta, and show one line per location. Use a **capacity** pool instead when the units are interchangeable and nobody needs to know which one they got — seats on a course, bikes in a rack. You lose per-unit meta, so if the storefront must say -_which_ one, use units. +_which_ one, use units. Capacity is also only exact for bookings that share a window: see +[policies-and-pools](policies-and-pools.md#the-pool) before using it for hires of varying length. ## What Discovery serves, and what it does not @@ -87,6 +88,10 @@ Discovery carries the **static** bookable configuration on the item: } ``` +`bookable` also serves `policySnapshot { advanceWindow cancellationWindow … }`, so a calendar can be capped +at the `advanceWindow` without a Shop call. Product shapes can filter, facet and sort on `bookable_kind` +and `bookable_poolSize`, as in `filters: { bookable_kind: { equals: "units" } }`. + **Discovery never serves availability.** Listing pages can say "5 machines, 3 depots" from `poolSize` and the pool; anything about a date is a Shop API call. Design the page so the calendar loads after the product, not as part of the listing query — one `availability` call per visible card is a lot of calls. diff --git a/use-crystallize/skills/bookable-resources/references/policies-and-pools.md b/use-crystallize/skills/bookable-resources/references/policies-and-pools.md index 10731c8..db63e2b 100644 --- a/use-crystallize/skills/bookable-resources/references/policies-and-pools.md +++ b/use-crystallize/skills/bookable-resources/references/policies-and-pools.md @@ -60,11 +60,16 @@ mutation CreatePolicy($input: CreateBookingPolicyInput!) { A `placedHoldDuration` of `0`, or none, gives a placed cart a fresh `pendingHoldDuration`. Set it when payment takes longer than a live hold, such as an invoice or a bank transfer. +`pendingHoldDuration` must be at least 1, or the policy is refused with `InvalidNumberRangeError`. Do not +send `advanceWindow: 0` to mean "no limit": `bookSkuItem` then refuses every future window as +`InvalidRange` while `availability` reports them bookable. Send a long window instead. + **Read `humanized` back after writing.** It returns the same values as `{ value, unit }` in days, hours, minutes or seconds, which is the cheapest way to catch a duration that was sent in the wrong unit. The mistake is silent otherwise: a `cancellationWindow` of `2` is two seconds, not two days. -`updateBookingPolicy(id, input)` takes the same fields, all optional, and bumps `version`. +`updateBookingPolicy(id, input)` takes the same fields, all optional, and bumps `version`, even for a +rename, so a product's `policySnapshot.version` behind the policy's does not prove the terms differ. `bookingPolicies(first:)` returns a **connection** (`edges { node { … } }`), not a list. `bookingPolicy(id:)` reads one. `BookingPolicy.stats.referencingProductCount` says how many products use @@ -72,9 +77,10 @@ it, and `deleteBookingPolicy` refuses with `BookingPolicyInUseError` while that ## The pool -`setBookable` attaches a policy and says what can be booked. One product, one pool. `language` is -required, but the pool is not per language: every language of the product shares it, so set it and -reapply it once per product. +`setBookable` attaches a policy and says what can be booked. One product, one pool, and every SKU of the +product draws on it. `language` is required, but the pool is not per language: every language of the +product shares it, so set it and reapply it once per product. Mass operations have no intent for this: +it is one `setBookable` per product. ```graphql mutation SetBookable($id: String!, $language: String!, $input: GraphqlBookableInputInput!) { @@ -104,6 +110,18 @@ Two kinds, and a product has exactly one of them: storefront reads it back from Discovery and can show "the machine in Oslo". There is no other place to put it. +**A capacity pool counts every booking that overlaps the window, not the busiest moment.** With capacity +6 and 4 seats booked on each of seven nights in a row, a 7-night request is refused with +`ReservationConflict` although 2 seats are free every night, and `availability` undercounts the same way. +It never oversells, and it is exact when bookings share one window, like a class at 09:00. When windows +vary in length, use units, even for things nobody tells apart. + +**Do not rename or remove a unit that holds live reservations.** They keep the old id and still hold +their window. While one does, every pin on that window is refused with `ReservationConflict` and +`freeUnitIds` is empty even when `free` is not. A cart pinned to a unit that left the published pool fails +at `place` with `UnitNoLongerInPool`. Add the new id, publish, move the reservations with `assignUnit` (see +[booking-admin](booking-admin.md)), and only then drop the old id. + Units → capacity is allowed. Capacity → units answers `BookablePoolKindChangeError` while the **published** pool is a capacity pool, because capacity-era reservations carry no unit. Clear it with `clearBookable`, publish that, cancel or wait out the open reservations, then set the units. @@ -111,11 +129,13 @@ Units → capacity is allowed. Capacity → units answers `BookablePoolKindChang `clearBookable(id, language)` removes the pool, but only from the draft: the Shop keeps taking bookings until the product is published again. Unpublish to stop bookings at once. `bookableProducts` lists products with a **published** bookable configuration only. A product configured but never published -is not in it. +is not in it. `bookableProducts(filter: { policyId: "…" })` is how to find the products to reapply; each +node has `productId`, `poolSize`, `policySnapshot` and `product(language:)`. ## The snapshot -`setBookable` stamps the policy onto the product as a `policySnapshot` with its `version`: +`setBookable` stamps the policy onto the product as a `policySnapshot` with its `version`, on every call, +so a pool edit also takes the policy's current terms: ```graphql { @@ -158,10 +178,15 @@ terms. This is the step that makes "we changed the cancellation window and nothi Reservations already taken keep the terms they were admitted under. A policy edit never changes a booking a shopper already holds. +`Product.bookable` on the Core API always shows the draft, whatever `versionLabel` you ask for. A bookable +edit creates no item version either, so read `bookableHasUnpublishedChanges` on the product to see whether +a publish is still owed. `bookableProducts`, the Catalogue API and Discovery show what the Shop serves. + ## Publish The Shop reads the **published** bookable configuration only. A bookable product that is not -published answers `NotBookable` on every Shop API call, with no hint that publishing is what is -missing. `setBookable`, `reapplyBookablePolicy` and `clearBookable` all write to the draft, so each one -needs a publish before the Shop sees it. Publish per item and language with `publishItem`. See -[[mutation]]. +published answers `NotBookable` on every Shop API call. `setBookable`, `reapplyBookablePolicy` and +`clearBookable` all write to the draft, so each one needs a publish before the Shop sees it. Publish per +item and language with `publishItem`. See [[mutation]]. The publish of any one language promotes the +shared pool, but the Shop reads the product per language, so publish every language you sell in. +`publishItem` only queues the work: the Shop sees the change a moment after it answers. From 5c347c96c797ac914a6752de51ab71870a3f250d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 16:01:50 -0700 Subject: [PATCH 13/29] fix(skills): say shopBookingApi cannot reach the booking admin API The client calls /booking, which has no GraphQL endpoint. The admin API is at /booking/admin. --- use-crystallize/skills/js-api-client/SKILL.md | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/use-crystallize/skills/js-api-client/SKILL.md b/use-crystallize/skills/js-api-client/SKILL.md index d758c1d..8ecd122 100644 --- a/use-crystallize/skills/js-api-client/SKILL.md +++ b/use-crystallize/skills/js-api-client/SKILL.md @@ -99,19 +99,19 @@ All callers share the same signature: (query: string, variables?: Record) => Promise; ``` -| Caller | Purpose | -| ----------------------------- | --------------------------------------------------------------------- | -| `catalogueApi` | Catalogue GraphQL | -| `discoveryApi` | Discovery GraphQL (search/browse) | -| `pimApi` | PIM GraphQL (legacy — prefer `nextPimApi`) | -| `nextPimApi` | PIM Next GraphQL (scoped to tenant, recommended) | -| `meApi` | Per-user GraphQL (`@me`) — call on behalf of the authenticated viewer | -| `shopCartApi` | Shop API `/cart` GraphQL (token auto-handled) | -| `shopOrderApi` | Shop API `/order` GraphQL (7.5+) | -| `shopCustomerApi` | Shop API `/customer` GraphQL (7.5+) | -| `shopLockApi` | Shop API `/lock` GraphQL (7.5+) | -| `shopSubscriptionContractApi` | Shop API `/subscription-contract` GraphQL (7.5+) | -| `shopBookingApi` | Shop API `/booking` GraphQL (7.5+) | +| Caller | Purpose | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `catalogueApi` | Catalogue GraphQL | +| `discoveryApi` | Discovery GraphQL (search/browse) | +| `pimApi` | PIM GraphQL (legacy — prefer `nextPimApi`) | +| `nextPimApi` | PIM Next GraphQL (scoped to tenant, recommended) | +| `meApi` | Per-user GraphQL (`@me`) — call on behalf of the authenticated viewer | +| `shopCartApi` | Shop API `/cart` GraphQL (token auto-handled) | +| `shopOrderApi` | Shop API `/order` GraphQL (7.5+) | +| `shopCustomerApi` | Shop API `/customer` GraphQL (7.5+) | +| `shopLockApi` | Shop API `/lock` GraphQL (7.5+) | +| `shopSubscriptionContractApi` | Shop API `/subscription-contract` GraphQL (7.5+) | +| `shopBookingApi` | Shop API `/booking` (7.5+). That path has no GraphQL endpoint and answers 404; the booking admin API is at `/booking/admin`, which this caller does not reach. See [[bookable-resources]] | ## High-Level Helpers From 874df7670957308a30f761a3775f33d6868007f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 16:02:50 -0700 Subject: [PATCH 14/29] fix(skills): correct subscription renewals, shop sync and stubbed endpoints Crystallize renews and activates contracts on schedule and fires due webhooks, while billing stays with the merchant, and Shop and Core contracts stay in sync. Corrects the lifecycle, webhook, plan and pricing claims. --- use-crystallize/skills/subscriptions/SKILL.md | 93 ++++++++------- .../subscriptions/references/contracts.md | 110 ++++++++++++------ .../references/plans-and-pricing.md | 53 +++++---- .../references/usage-and-renewals.md | 92 ++++++++++----- 4 files changed, 220 insertions(+), 128 deletions(-) diff --git a/use-crystallize/skills/subscriptions/SKILL.md b/use-crystallize/skills/subscriptions/SKILL.md index 6986c46..c84b0f4 100644 --- a/use-crystallize/skills/subscriptions/SKILL.md +++ b/use-crystallize/skills/subscriptions/SKILL.md @@ -4,11 +4,11 @@ description: > Sell and run Crystallize subscriptions — memberships, plans with trials, metered usage, standing orders and recurring baskets. Covers subscription plans and periods (legacy PIM API), subscription pricing on variants, subscription contracts and their lifecycle on the Core API and the Shop API - /subscription-contract endpoint, usage tracking, and renewal orders. Use when the user wants a + /subscription-contract endpoint, usage tracking, renewals and renewal orders. Use when the user wants a monthly or yearly plan, a free trial, tiered or metered billing, pause/resume, upgrade or downgrade, cancel at period end, a recurring delivery, or a "my subscription" account page. Trigger on "subscription", "subscriptions", "abonnement", "recurring", "membership", "plan", "trial", - "metered", "usage-based", "standing order", "renewal", "subscriptionPlan", + "metered", "usage-based", "standing order", "renewal", "renewalDue", "subscriptionPlan", "createSubscriptionContract", "subscriptionContract", "renewSubscriptionContract", "pauseSubscriptionContract", "trackSubscriptionContractUsage", "createFromSubscriptionContract", "orderIntent", "meteredVariables", "churn", "pause subscription", "cancel subscription". @@ -26,39 +26,45 @@ agreed, the dates it runs on, and the meter readings. The contract is the whole model. It is not a state machine that bills for you. -> Verified on 2026-09-28 against the live Core API (`screen-universe`) and the public Discovery API, and -> against two builds that ran the whole lifecycle: **Screen Universe** (memberships with a 14-day trial -> and metered downloads) and **Lab Universe** (B2B standing orders). Claims that come from those builds -> rather than from the schema are marked. +> Reviewed on 2026-10-30 against the API implementation, on top of two builds that ran the whole +> lifecycle: **Screen Universe** (memberships with a 14-day trial and metered downloads) and **Lab +> Universe** (B2B standing orders). Claims that come only from those builds are marked. ## Two things to know before you write any code -**1. Nothing happens by itself.** There is no billing engine behind a contract. At `renewAt` nothing is -charged, no order appears, and no state changes. A contract whose `activeUntil` has passed simply reads -`cancelled`. Renewal is **your** job: a scheduled task that finds contracts due, prices the period, -writes an order, and calls `renewSubscriptionContract`. Plan for that before you model anything. - -**2. Contracts live in two stores that barely sync.** Core and the Shop API +**1. Crystallize moves the dates; it never bills.** There is no billing engine behind a contract: +nothing is charged and no order appears, at `renewAt` or ever. What does run by itself is the calendar. +Within minutes of `renewAt` the platform renews every contract that has one: `renewAt` and `activeUntil` +move one recurring period on, a trial moves to its recurring phase, and the `renew` webhook fires, +whether or not anyone paid and even if the contract is paused. A contract with `activateAt` is activated +the same way. A contract with no `renewAt` runs out and reads `cancelled` once `activeUntil` passes. +Billing is **your** job, hung on those events: price the period, charge, write an order. Calling +`renewSubscriptionContract` on top moves the contract a second period; keep it for renewing ahead of +`renewAt`. Plan for that before you model anything. + +**2. Contracts live in two stores that sync loosely.** Core and the Shop API `/subscription-contract` endpoint both hold contracts, and they are not one store with two doors: | | Core API | Shop API `/subscription-contract` | | ---------------------- | ------------------------------------------------------------- | ---------------------------------------------- | | Meant for | back office, seeding, batch, renewals | a storefront acting for the signed-in customer | -| Ids | 24-hex ObjectId | `UUID!` | +| Ids | 24-character hex id | `UUID!` | | Usage tracking | `trackSubscriptionContractUsage`, `usage(startDate, endDate)` | not available — Core only | | Needs a customer first | yes (`CustomerNotFoundError`) | no, it creates one from `customer { … }` | | Status | labelled stable | labelled **EXPERIMENTAL** | A Shop contract is copied to Core about 7 seconds later with a new Core id and the Shop UUID in -`meta._shopApiId`. **After that copy, nothing flows from the Shop to Core**: pause, resume, renew, -cancel and phase changes on the Shop side leave the Core copy at its creation state. In the other -direction a Core contract is copied into the Shop store **with its Core ObjectId as `id`** — which is -not a UUID, so `subscriptionContracts(customerIdentifier:)` then fails on that element and the contract -cannot be addressed through the Shop API at all. +`meta._shopApiId`. **After that the two copies follow each other, asynchronously**: Shop `pause`, +`resume`, `renew`, `cancel` and `deactivate` are replayed on the Core copy, any other Shop edit +overwrites it, and every Core change, scheduled renewals included, overwrites the Shop copy. Those +overwrites lose data — see [contracts](references/contracts.md). In the other direction a Core contract +is copied into the Shop store **with its 24-character Core id as `id`** — which is not a UUID, so +`subscriptionContracts(customerIdentifier:)` then fails on that element and the contract cannot be +addressed through the Shop API at all. **So pick one store per tenant and stay in it.** Both builds landed on that rule the hard way: -- **Core for everything** when the lifecycle is driven server-side — seeding, renewal jobs, usage, +- **Core for everything** when the lifecycle is driven server-side — seeding, billing jobs, usage, back-dated history, and account pages rendered by your own server. This is what Screen Universe does. Core is rate limited and is not meant for storefront traffic, so put it behind your own server actions or API routes — never in a browser call, and never once per page view. @@ -76,10 +82,13 @@ Core createCustomer Core contracts need a customer; the Shop en Core createSubscriptionContract the agreement: item, phases, dates, meta Core registerOrder the receipt for the first period (0 for a trial) Core trackSubscriptionContractUsage every metered event, with an idempotency key -… then, on a schedule, per contract due ……………………………………………………… -Core usage(startDate, endDate) the meter for the period that is ending +… then, every period, per contract ……………………………………………………………………… +Crystallize renewalDue webhook a week and a day before renewAt +Crystallize renews the contract at renewAt: dates move one period on, renew webhook fires +Core usage(startDate, endDate) the meter for the period that just ended +you charge your payment provider; Crystallize takes no money Core registerOrder the invoice: plan line + overage line, priced by you -Core renewSubscriptionContract moves renewAt one period on +Core updateSubscriptionContract meta.periodStart, so the next meter window starts here ``` ## States @@ -93,29 +102,33 @@ Core renewSubscriptionContract moves renewAt one period on | now before `activeUntil`, `renewAt` set | `active` | | `activeUntil` in the future, no `renewAt` | `pendingDeactivation` | | no `activeUntil`, or it has passed | `cancelled` | -| paused explicitly | `paused` | +| paused explicitly (wins over the dates) | `paused` | Two rules follow, and both bite: - **`activateAt` is exclusive of `renewAt` and `activeUntil`** (`ActivateAtMustBeExclusiveError`; the Shop endpoint only says "Unexpected error"). Send either a future start, or a running contract's dates. -- **Send `renewAt` and `activeUntil` together.** A contract created with only `renewAt` is born - `cancelled`. +- **Send `renewAt` and `activeUntil` together.** `createSubscriptionContract` fills a missing + `activeUntil` from `renewAt`, but `updateSubscriptionContract { status }` and the Shop `create` do not: + there, `renewAt` alone reads `cancelled`. ## Failure modes -| Symptom | Cause | Fix | -| --------------------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| Nothing is charged and no order appears at `renewAt` | There is no billing engine | Run your own renewal job — see [usage-and-renewals](references/usage-and-renewals.md) | -| A brand-new contract reads `cancelled` | `activeUntil` missing, or only `renewAt` was sent | Send both; for a trial set both to the trial's end | -| `subscriptionContracts` errors on one element, the rest is data | A Core-created contract was copied into the Shop store with a Core id | Use one store; read with partial results in the meantime | -| A Shop `pause`/`renew` never reaches Core | The Shop → Core copy happens once, at creation | Use one store | -| Usage tracked but the invoice has no overage | Nothing prices usage for you | Read `usage(…)`, price the tiers yourself, add an overage line | -| A trial shows the full price on the plan card | Discovery's `initial` repeats `recurring` | Read the trial from the plan (PIM, server-side) or your own config | -| `MeteredVariableNotFoundError` when tracking | The metered variable's **id** was sent | Track by `identifier` | -| Variant prices or contracts stop resolving after a plan edit | `subscriptionPlan.update` without ids mints new period ids | Always pass the existing `id`s back | -| A paused contract goes `cancelled` on resume | Pause freezes nothing; the dates kept running | On resume, push `renewAt`/`activeUntil` by the time that was paused | -| `InvalidActiveUntilDateError` while seeding history | Core refuses dates in the past | Back-date `signedAt` and the orders instead | +| Symptom | Cause | Fix | +| --------------------------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Nothing is charged and no order appears at `renewAt` | There is no billing engine | Bill from the `renew` webhook — see [usage-and-renewals](references/usage-and-renewals.md) | +| `renewAt` moved two periods | A job called `renewSubscriptionContract` after the platform renewed | Never renew what is already due; only bill it | +| A paused customer gets charged | `renew` and `renewalDue` fire for paused contracts too | Check `status.state` before charging | +| A brand-new contract reads `cancelled` | `activeUntil` missing (Shop: also when only `renewAt` was sent) | Send both; for a trial set both to the trial's end | +| `subscriptionContracts` errors on one element, the rest is data | A contract reached the Shop store under its Core id | Use one store; read with partial results in the meantime | +| A Shop `pause`/`renew` is not on the Core copy yet | The copies sync asynchronously | Don't read one store to confirm a write to the other | +| Usage tracked but the invoice has no overage | Nothing prices usage for you | Read `usage(…)`, price the tiers yourself, add an overage line | +| A trial shows the full price on the plan card | Discovery's `initial` repeats `recurring` | Read the trial from the plan (PIM, server-side), Catalogue or your own config | +| `MeteredVariableNotFoundError` when tracking | The metered variable's **id** was sent | Track by `identifier` | +| Variant prices or contracts stop resolving after a plan edit | `subscriptionPlan.update` without ids mints new period ids | Always pass the existing `id`s back | +| A resumed contract lost the time it spent paused | Pause freezes nothing; renewals kept rolling the dates | On resume, set `renewAt`/`activeUntil` to now plus what was left | +| A `state` filter misses contracts that ran out | The filter reads the state stored at the last write | Compare the dates yourself | +| `InvalidActiveUntilDateError` while seeding history | Core refuses dates in the past (`InvalidRenewAtDateError` too) | Back-date `signedAt` and the orders instead | ## References @@ -124,8 +137,8 @@ Two rules follow, and both bite: - [references/contracts.md](references/contracts.md) — creating contracts, the date rules, the whole lifecycle with what each call actually changes, and the Shop endpoint's differences. - [references/usage-and-renewals.md](references/usage-and-renewals.md) — metered usage, period windows, - renewal orders, `orderIntent`, and what to do about the missing billing engine. + scheduled renewals, renewal orders, `orderIntent`, webhooks, and how to bill on top of them. Related: [[pricing]] for price variants and markets, [[mutation]] and [[query]] for the APIs themselves, -[[bookable-resources]] for the other "sold over time" model — booking holds a calendar, a subscription -repeats a charge. +[[js-api-client]] for the TypeScript helpers, [[bookable-resources]] for the other "sold over time" +model — booking holds a calendar, a subscription repeats a charge. diff --git a/use-crystallize/skills/subscriptions/references/contracts.md b/use-crystallize/skills/subscriptions/references/contracts.md index 60dd399..d666f0f 100644 --- a/use-crystallize/skills/subscriptions/references/contracts.md +++ b/use-crystallize/skills/subscriptions/references/contracts.md @@ -60,12 +60,19 @@ mutation Create($input: CreateSubscriptionContractInput!) { ``` - `subscriptionPlan.periodName` is **required** on Core, so carry the period's name alongside its id. +- A phase needs only `price` and `currency`: `period` and `unit` default to the plan period's. - A Core contract needs an existing customer (`CustomerNotFoundError`) and an existing SKU - (`SkuNotFoundError`). Upsert the customer with `createCustomer` first. + (`SkuNotFoundError`). Create the customer with `createCustomer` first; it is not an upsert, and answers + `CustomerIdentifierAlreadyExistsError` on a returning customer. - `payment` here records how it will be paid; it does not take money. -- **Core refuses dates in the past** (`InvalidActiveUntilDateError`). To seed a subscriber "since 2025", - back-date `signedAt` (accepted) and the orders (`registerOrder { createdAt }`), and keep your own - history in `meta`. +- **Core refuses dates in the past** (`InvalidActiveUntilDateError`, `InvalidRenewAtDateError`). To seed + a subscriber "since 2025", back-date `signedAt` (accepted) and the orders + (`registerOrder { createdAt }`), and keep your own history in `meta`. +- In TypeScript, `createSubscriptionContractManager` ([[js-api-client]]) wraps create, update and the + lifecycle calls. Its `createTemplateBasedOnVariant(variant, planIdentifier, periodId, priceVariantIdentifier)` + builds `item`, `subscriptionPlan`, `initial` and `recurring` from a variant read with its plans; add + `customerIdentifier` and `status`. `createTemplateBasedOnVariantIdentity` fetches the variant itself, but + from Catalogue `variants`, which can be empty — see [plans-and-pricing](plans-and-pricing.md). ### The date rules, which decide the state @@ -73,11 +80,15 @@ mutation Create($input: CreateSubscriptionContractInput!) { field. See the table in [SKILL.md](../SKILL.md). - **`activateAt` cannot be combined with the other two** (`ActivateAtMustBeExclusiveError`). A contract - that starts later is created with `activateAt` alone, and becomes `cancelled` when that moment passes - unless you have activated it first with `updateSubscriptionContract { status: { renewAt, activeUntil } }`, - which replaces the whole status and clears `activateAt`. + that starts later is created with `activateAt` alone, and the platform activates it within minutes of + that moment: `activateAt` clears, `renewAt` and `activeUntil` become now plus one **recurring** period, + and the `activate` webhook fires. An `initial` phase does not shorten that: `status.phase` reads the + trial while the dates read the recurring length, so correct them from the webhook if the trial is + shorter. `updateSubscriptionContract { status: { renewAt, activeUntil } }` replaces the whole status + and clears `activateAt`, so it also activates early. - **`renewAt` and `activeUntil` travel together.** For a trial, both are the trial's end. For a running - period, both are the end of the paid period. + period, both are the end of the paid period. Because `update { status }` replaces the whole status, + sending `{ renewAt }` alone clears `activeUntil` and the contract reads `cancelled`. - A contract **with** an `initial` phase stays on it until the first renewal, so "is this a trial?" is `status.phase` equal to the initial phase. Leave `initial` out and it starts on `recurring` at once — which is what you want for an add-on, or for seeding an existing subscriber. @@ -100,62 +111,89 @@ Recorded step by step on a trial contract (`renewAt = activeUntil = T+14d`): | pause again | error | `SubscriptionContractIsAlreadyPausedError` | | `resumeSubscriptionContract` | `active` | dates unchanged | | `updateSubscriptionContract { item, recurring }` | `active` | item and recurring phase now; `status.phase` only at the next renew | -| `renewSubscriptionContract` | `active` | `renewAt` = **old** `renewAt` + one period; `activeUntil` follows | +| `renewSubscriptionContract` | `active` | `activeUntil` = **old** `activeUntil` + one period; `renewAt` too | | `cancelSubscriptionContract { deactivate: false }` | `pendingDeactivation` | `renewAt` → `null`, `activeUntil` kept (runs to the paid end) | | `renewSubscriptionContract` after that | `active` | back from the dead, `renewAt` = `activeUntil` + one period | | `cancelSubscriptionContract { deactivate: true }` | `cancelled` | `renewAt` and `activeUntil` → `null` | Consequences worth designing around: -- **Renew adds a period to the previous `renewAt`, not to now.** Renewing a long-expired contract - revives it on its old dates. If you renew late, decide whether the customer gets the lost time. -- **Pause freezes nothing.** A contract paused over the summer keeps running down, stays `paused` past - `activeUntil`, and `resume` then yields `cancelled`. Record what was left at the pause +- **The platform runs the same renew for you.** When `renewAt` comes due it renews the contract, so a + `renewSubscriptionContract` on top moves it a second period. Call it only to renew ahead of `renewAt`. +- **Renew adds a period to the previous `activeUntil`, not to now** (to now only if there is none), and + `renewAt` lands on the same date. Renewing a long-expired contract revives it on its old dates, and the + platform then keeps renewing it until `renewAt` is back in the future. +- **Pause freezes nothing.** The dates keep their values, and a paused contract that has a `renewAt` + keeps being renewed: it stays `paused` while the dates and the `renew` and `renewalDue` webhooks roll + on, so check `status.state` before you charge. Only one with no `renewAt` runs out, and `resume` then + yields `cancelled`. To give back the paid time, record what was left at the pause (`meta.pauseRemainingMs`) and on resume set `renewAt`/`activeUntil` to now plus that. Note that `updateSubscriptionContract { status }` **also un-pauses**, so a resume is: `resume` (tolerating `SubscriptionContractIsNotPausedError`), then the date update. - **Undo a cancel with `updateSubscriptionContract { status: { renewAt: activeUntil, activeUntil } }`**, not `renewSubscriptionContract` — renew reactivates too, but silently moves the customer a whole period on without an invoice. +- **A contract cancels once.** A second `cancelSubscriptionContract` answers + `SubscriptionContractAlreadyCancelledError`, so `deactivate: true` after `deactivate: false` fails unless + the contract was reactivated in between. To end it at once, `updateSubscriptionContract { status: {} }` + clears the dates. - **`updateSubscriptionContract { meta }` replaces the whole list.** Keys you leave out are deleted; always send all of it. - **Plan and period cannot change.** `UpdateSubscriptionContractInput` has no `subscriptionPlan`, so - monthly ↔ yearly is cancel plus a new contract. A tier change is - `update { item, recurring }` and applies from the next renewal — there is no proration. Keep the tier - in force and the coming one apart (`meta.tier`, `meta.nextTier`) and swap them when you renew. + monthly ↔ yearly is cancel plus a new contract. A tier change is `update { item, recurring }`, with no + proration: a contract on its trial shows it at the first renewal, one already on `recurring` shows it + in `status.phase` at once. Keep the tier in force and the coming one apart (`meta.tier`, + `meta.nextTier`) and swap them when you bill the renewal. - `deleteCustomer(identifier, deleteSubscriptionContracts: true)` removes a customer and their contracts in one call; orders go separately with `deleteOrder`. ## Reading contracts -`subscriptionContracts(filter: { customerIdentifier, state, sku, subscriptionPlanIdentifier })` is a -connection (`edges { node { … } }`), sortable by `createdAt`. One contract is -`subscriptionContract(id:)`. Useful fields: `status { state renewAt activeUntil phase }`, `item`, -`initial`, `recurring { price currency period unit meteredVariables { tiers } productVariants }`, -`subscriptionPlan { identifier periodId periodName }`, `meta`, and -`usage(startDate:, endDate:)`. +`subscriptionContracts(filter: { customerIdentifier, state, sku, subscriptionPlanIdentifier, signedAt, +activateAt })` is a connection (`edges { node { … } }`), sortable by `createdAt`; the two date filters +take `{ min, max }`, and there is none on `renewAt`. One contract is `subscriptionContract(id:)`. Useful +fields: `status { state activateAt renewAt activeUntil phase }`, `item`, `initial`, +`recurring { price currency period unit meteredVariables { tiers } productVariants }`, +`subscriptionPlan { identifier periodId periodName }`, `meta`, and `usage(startDate:, endDate:)`. `status.phase` is the phase in force — the trial before the first renewal, the recurring phase after it. +**The `state` filter is not the `state` you read back.** `status.state` in a result is derived from the +dates at read time, while the filter matches the state stored at the last write. A contract that ran out +with no write since reads `cancelled` but still matches `state: pendingDeactivation`. + ## The Shop API endpoint -`/subscription-contract` (scopes `subscription-contract`, `subscription-contract:admin`) is the -storefront-facing store, and it is labelled **EXPERIMENTAL**. What differs: +`/subscription-contract` (scope `subscription-contract`) is the storefront-facing store, and it is +labelled **EXPERIMENTAL**. What differs: - `create` needs **no** Core customer: it creates one from `customer { identifier, email, firstName, -lastName }`. Ids are `UUID!`. Prices are `{ gross, net }` per phase instead of Core's single `price`, - and the currency comes from `context.price.currency` (EUR if omitted). `plan` is - `{ identifier, periodId }` — no `periodName`. + lastName }`. Ids are `UUID!`. Prices are `{ gross, net }` per phase instead of Core's single `price`, + and the currency comes from `context.price.currency` (`eur` if omitted). `plan` is + `{ identifier, periodId }` — no `periodName`. `period` and `unit` have no default here: send them. +- `create` checks the SKU, plan, period and metered variables against the **published** Catalogue in + `context.language` (`en` if omitted). A draft-only product fails, with no more than "Unexpected error". - The lifecycle behaves as above (`pause`, `resume`, `renew`, `cancel`, `deactivate`, `changeDates`), with three differences: `resume` after a cancel is silently ignored, `renew` on a paused contract makes - it `active`, and the only way to change what is billed is - `updateRecurringPhase(phase: { price, period, unit, productVariants, meteredVariables })` — there is no - item update. `status.phase` is always `null`. + it `active` (on Core it stays `paused`), and the only way to change what is billed is + `updateRecurringPhase(phase: { price, period, unit, productVariants, meteredVariables })`, which + replaces the whole phase — there is no item update. `changeDates` takes only the dates to change, + unlike Core's `update { status }`. +- `status.phase` is `null` until a Core change syncs back. `status.state` is stored at each write, not + derived, so it does not flip when a date passes: on a "my subscription" page compare `activeUntil` with + now. - `subscriptionContracts(customerIdentifier:)` right after `create` can answer `[]`; the contract shows up seconds later. Don't list to confirm a write. -- **`subscriptionContractTemplate` was unusable** on the tenant it was tried on: zero prices, no - `initial`, and `currency: "eur"` regardless — most likely because it reads the empty Catalogue - `variants`. Build the create input yourself from Discovery plus the plan. -- There is no delete. Cancelled test contracts stay in the list. -- Usage is Core-only, and a Core id is required for it, so a Shop-store tenant has no metered billing - path today. Plan flat periods there, or keep the whole thing in Core. +- **Each sync overwrites.** A Core change replaces the Shop contract with Core's view: `gross` and `net` + both become Core's single `price`, and `customer` and `context` are dropped. A Shop edit other than + `pause`, `resume`, `renew`, `cancel` and `deactivate` replaces the Core copy, `meta` included, which + loses `_shopApiId`; the next Core change, a scheduled renewal included, then reaches the Shop store + under the Core id, and `subscriptionContracts` fails on that element. +- **`subscriptionContractTemplate` is a stub.** It reads nothing: it echoes your arguments with a + `recurring` of 1 month at 0, no `initial`, and `currency: "eur"`. Build the create input yourself from + Discovery plus the plan. +- There is no delete here. `deleteSubscriptionContract` on the Core copy removes the Shop contract too; + otherwise cancelled test contracts stay in the list. +- Usage is Core-only and needs the Core id, which the Shop API does not expose. A Shop-store tenant can + only meter on the Core copy: find it on Core by `meta._shopApiId` once it has appeared, and keep the + id. If metering matters, keep the whole thing in Core. diff --git a/use-crystallize/skills/subscriptions/references/plans-and-pricing.md b/use-crystallize/skills/subscriptions/references/plans-and-pricing.md index 1a54f5f..7de0ade 100644 --- a/use-crystallize/skills/subscriptions/references/plans-and-pricing.md +++ b/use-crystallize/skills/subscriptions/references/plans-and-pricing.md @@ -6,8 +6,8 @@ per plan period and per price variant. ## Plans live in the legacy PIM API -There is no plan surface on the Core API. Create and edit plans on `https://pim.crystallize.com/graphql`, -which takes the tenant **id**, not the identifier: +There is no plan surface on the Core API. Create and edit plans in the App's settings or on +`https://pim.crystallize.com/graphql`, which takes the tenant **id**, not the identifier: ```graphql mutation CreatePlan($input: CreateSubscriptionPlanInput!) { @@ -59,16 +59,19 @@ mutation CreatePlan($input: CreateSubscriptionPlanInput!) { ``` **The ids are generated, and everything downstream points at them.** Period ids and metered-variable ids -come back from `create` as 24-hex ObjectIds. Variant prices reference a period id; contracts reference a +come back from `create` as 24-character hex ids. Variant prices reference a period id; contracts reference a period id and a metered-variable identifier. Store them in your seed or config. **`update` mints new ids for anything you send without one.** `update(identifier, tenantId, input: { periods: [{ id, … }], meteredVariables: [{ id, … }] })` keeps them; the same lists without `id`s replace -them, and every variant price and contract pointing at the old ids is orphaned. A name-only update +them, and every variant price and contract pointing at the old ids is orphaned: +`updateSubscriptionContract` on those contracts answers `SubscriptionPlanPeriodNotFoundError`, and a +metered variable dropped from the list is also stripped from every variant. A name-only update (`input: { name }`) leaves periods and meters alone. `delete(identifier, tenantId)` succeeds even while -variants still reference the plan. +variants still reference the plan; contract updates and usage tracking then answer +`SubscriptionPlanNotFoundError`. -## Prices go on the variant (write on Core, read on PIM) +## Prices go on the variant (written on Core) `createProduct`, `updateProduct` and `updateProductVariant` take `subscriptionPlans` per variant: @@ -92,7 +95,7 @@ variants still reference the plan. { "threshold": 0, "price": 0, "priceVariants": [{ "identifier": "default", "price": 0 }] }, { "threshold": 10, - "price": 0, + "price": 0.5, "priceVariants": [{ "identifier": "default", "price": 0.5 }] } ] @@ -104,19 +107,18 @@ variants still reference the plan. } ``` -Four things worth knowing, all from the Screen Universe spike: +Four things worth knowing: -- **Reading `ProductVariant.subscriptionPlans` back on Core answers "Not implemented"** — one error per - variant, even for `subscriptionPlans { identifier }`. The write itself succeeds, so a `createProduct` - that _selects_ the plans throws **after** creating the product, and a naive retry duplicates it. - Select only `id` on the write, and read the plans back from PIM - (`product { get(id, language) { variants { subscriptionPlans { … } } } }`). -- **A tier's own `price` is ignored.** It is required by the input, but the stored tier price is the one - in `priceVariants`. `price: 99` with `default: 0.1` stores 0.1, and an empty `priceVariants` stores - `null`. +- **`ProductVariant.subscriptionPlans` reads back on Core**, merged with the plan: names and period + lengths from the plan, prices from the variant, in `periods { id name initial { period unit price + priceVariants meteredVariables { tiers } } recurring { … } }`. +- **A tier's own `price` is not the price most reads show.** It is required by the input, but Core, PIM + and Discovery take the tier price from `priceVariants`: `price: 99` with `default: 0.1` reads 0.1, and + an empty `priceVariants` reads `null`. Catalogue's tier `price` field does answer the tier's own value, + so set it to the default price and every read agrees. - **Price variants are optional per period.** Leave `nok` out and only `default` is stored; Discovery then answers `nokPrice: null`. A period omitted from the variant is stored as `initial: null, -recurring: null` and Discovery drops it. + recurring: null` and Discovery drops it. - **Meters are addressed two ways.** On a variant's tiers, by metered-variable **`id`**. On contracts and when tracking usage, by **`identifier`**. Mixing them up gives `MeteredVariableNotFoundError`. @@ -163,15 +165,16 @@ Discovery serves the plan cards, and this is the query that works (verified on a - `period` comes back as a **string** (`"1"`), `priceVariants` as a hash (`{ default: { price, currency }, nok: { … } }`), and `meteredVariables` as `[]` when there are none. -- **Do not trust `initial`.** It repeats `recurring`: on a tenant whose Monthly period is "14 days free, - then 1 month at 7.99", Discovery answered `initial { period: "1", unit: "month", defaultPrice: 7.99 }`, - and a re-check on 2026-09-28 again returned `initial` byte-identical to `recurring`. Take the - introductory period from the plan (PIM, server-side) or from your own config, and price the trial - yourself. +- **Do not trust `initial`.** It repeats `recurring`'s period, unit and prices (only its + `meteredVariables` are its own): on a tenant whose Monthly period is "14 days free, then 1 month at + 7.99", Discovery answered `initial { period: "1", unit: "month", defaultPrice: 7.99 }`. Its presence + is reliable, since it is `null` on a period with no initial phase, but take the introductory period + from the plan (PIM, server-side), Catalogue or your own config, and price the trial yourself. - `hasSubscriptionPlans` is the cheap filter for "is this a plan variant". -- On one tenant the **Catalogue API's `variants` came back empty** for every product while - `defaultVariant` worked and carried the correct plans, including the trial. If a plan card looks empty, - read `defaultVariant`, or use Discovery. +- **The Catalogue API's `variants` can come back empty** for products whose variants were created + through Core, while `defaultVariant` and `productVariants(skus:, language:)` still answer, with the + correct plans, including the trial. If a plan card looks empty, read `defaultVariant`, or use + Discovery. Remember that a plan variant is an ordinary variant: markets, price variants, VAT and volume tiers behave as they do everywhere else — see [[pricing]]. diff --git a/use-crystallize/skills/subscriptions/references/usage-and-renewals.md b/use-crystallize/skills/subscriptions/references/usage-and-renewals.md index a45d4d3..33d4376 100644 --- a/use-crystallize/skills/subscriptions/references/usage-and-renewals.md +++ b/use-crystallize/skills/subscriptions/references/usage-and-renewals.md @@ -1,7 +1,8 @@ # Metered usage and renewals -Two jobs Crystallize does not do for you: counting what a customer used, and charging them for the next -period. The API gives you a meter and an order writer; the schedule and the arithmetic are yours. +Two jobs Crystallize does not do for you: pricing what a customer used, and charging them for the next +period. The platform keeps the calendar and gives you a meter, webhooks and an order writer; the charge +and the arithmetic are yours. ## Tracking usage @@ -32,8 +33,8 @@ mutation Track($id: ID!, $input: TrackSubscriptionContractUsageInput!) { - **By `identifier`, never by id.** The metered variable's id answers `MeteredVariableNotFoundError`. - **The `idempotencyKey` is the retry guard.** A reused key answers `IdempotencyKeyExistsError` and - records nothing, which makes it safe to call from a route that may run twice. Build the key from what - makes the event unique — contract, thing, day. + records nothing, which makes it safe to call from a route that may run twice. Keys are unique across + the tenant, so build the key from what makes the event unique — contract, thing, day. - Fractional quantities are accepted (`0.5`). - **Usage is Core-only and needs the Core contract id.** A Shop UUID gives `InvalidIdError`, and usage tracked on the Core copy of a Shop contract is invisible to the Shop API. @@ -47,24 +48,42 @@ mutation Track($id: ID!, $input: TrackSubscriptionContractUsageInput!) { window over the **tracking time** — there is no notion of "the current period", and usage cannot be back-dated. -**So keep the period's start yourself**, in `meta.periodStart`: set it at sign-up and again at every -renewal, and read the meter as `usage(periodStart, now)`. If you renew early — a demo button, a manual +**So keep the period's start yourself**, in `meta.periodStart`: set it at sign-up and again each time +you bill, and read the meter as `usage(periodStart, now)`. If you renew early — a demo button, a manual run — the next period starts at the moment of renewal, otherwise events between the real `renewAt` and your early run fall outside every window. ## Renewals -Nothing renews itself. Run a scheduled task (a cron, a queue worker, or a button in a demo) that takes -every contract with `state: active` and `renewAt <= now` and does this: +**The platform renews; you bill.** Every few minutes it picks up each contract whose `renewAt` is due +and runs the same operation as `renewSubscriptionContract`: `renewAt` and `activeUntil` move one +recurring period on and `status.phase` leaves the trial. It looks at neither payment nor `status.state`, +so paused contracts renew too, and it can run a few minutes ahead of `renewAt`. Only clearing `renewAt` +stops it, which is what `cancelSubscriptionContract` does. + +So do not poll for `renewAt <= now`: the platform has already moved it, and renewing again skips a whole +period. Hang billing on the `renew` webhook and, for each contract: ```text -usage(periodStart, renewAt) the meter for the period that is ending +read the contract stop here if status.state is paused +usage(periodStart, now) the meter for the period that just ended price the tiers yourself graduated: (usage − threshold) × the tier's price, per tier +charge your payment provider; Crystallize takes no money registerOrder the invoice: plan line + an overage line so the total adds up -renewSubscriptionContract moves renewAt one period on -update meta.periodStart (and tier) the new period's start, and nextTier → tier if it changed +update meta.periodStart (and tier) that same now, and nextTier → tier if it changed ``` +- **A failed charge does not undo the renewal.** The dates have already moved, so decide what it means: + `cancelSubscriptionContract { deactivate: true }` ends the contract now, `deactivate: false` lets the + new period run out. +- **To charge before the period starts**, bill from the day-ahead `renewalDue` instead (compare its + `renewAt` with now): charge, then call `renewSubscriptionContract` yourself, which moves `renewAt` on so + the platform finds nothing due. If the charge fails, `cancelSubscriptionContract { deactivate: false }` + clears `renewAt` and the contract simply runs out. The usage of the ending period is not complete yet + at that point, so this suits flat plans. +- **For a safety net against a missed webhook**, keep the `renewAt` you last billed in `meta` and sweep + the contracts for any that have moved past it. There is no `renewAt` filter, so page through them. + `registerOrder` writes the invoice. The shape that worked: ```json @@ -99,26 +118,45 @@ update meta.periodStart (and tier) the new period's start, and nextTier `subscription.meteredVariables` are a record, not a charge: nothing in Crystallize prices them. - `pipelines: [{ pipelineId, stageId }]` drops the order straight into a stage (Trial, Active). The ids come from the PIM API, so resolve them in your seed rather than at runtime. -- Make it idempotent: put `renewalOf = :` in the order's `meta` and look for it with - `orders(filter: { customer: { identifier }, meta: [{ key, value }] })` before writing. +- Make it idempotent: put `renewalOf = :` in the order's `meta`, with the `renewAt` the + contract has after renewing, and look for it with + `orders(filter: { subscriptionContractId, meta: [{ key, value }] })` before writing. ### What `orderIntent` gives you -`orderIntent(id, format: shop | core | coreWithOrderV2 | legacy)` on the Shop API returns the **next** -period's order — `type: recurring`, the recurring price, and -`subscription { start: renewAt, end: renewAt + period, meteredVariables: [{ identifier, price, usage }] }`. -It is a preview, not a charge, and on the tenant it was tried on it reported `usage: 0` even with usage -tracked in Core, and answered the same shape during a trial. Treat it as a template to render, not as a -source of truth for money. +`orderIntent(id, format: shop | core | coreWithOrderV2 | legacy)` on the Shop API returns a draft of the +next order — `type: recurring`, the price of `status.phase` (the recurring phase while that is `null`), +and `subscription { start: renewAt, end: renewAt + period, meteredVariables: [{ identifier, price, usage }] }`. +It is a preview, not a charge. **`usage` is always 0** and `price` is the first tier's, whatever was +tracked in Core, and `format` only labels the result: the shape is the same. Treat it as a template to +render, not as a source of truth for money. -**`createFromSubscriptionContract` on the Shop `/order` endpoint failed on every attempt** in the Screen -Universe spike — trial, renewed, cancelled and flat contracts, before and after re-igniting — always -"Unexpected error". A Shop `/order` `create` with a `type: subscription` line does work, but the metered -`price` is **not** added to the order total. For renewal invoices, Core `registerOrder` is the path that -held up. +**`createFromSubscriptionContract` on the Shop `/order` endpoint is not implemented**: it is in the +schema, and every call fails with "Unexpected error". A Shop `/order` `create` with a `type: subscription` +line does work, but the metered `price` is **not** added to the order total. For renewal invoices, Core +`registerOrder` is the path that held up. ## Webhooks -`createWebhook` accepts any `concern`/`event` string — `nope`/`nope` is accepted — so the API cannot tell -you which contract events exist. Do not design a renewal pipeline around webhook events you have not seen -arrive at a receiver. +The concern is `subscriptionContracts`, and its events are `create`, `update`, `delete` and: + +| Event | Fires | Payload | +| --------------- | -------------------------------------------------------------------- | -------------------------------------------------- | +| `renewalDue` | a week and again a day before `renewAt` (once a day, by UTC date) | `subscriptionContractId`, `renewAt`, `tenantId` | +| `renew` | on every renewal, scheduled or called | `id`, `tenantId` | +| `activationDue` | a week and again a day before `activateAt` | `subscriptionContractId`, `activateAt`, `tenantId` | +| `activate` | when the platform activates a contract | `id`, `tenantId` | +| `pause` | on `pauseSubscriptionContract` | `id`, `tenantId` | +| `resume` | on `resumeSubscriptionContract` | `id`, `tenantId` | +| `cancel` | on every `cancelSubscriptionContract` | `id`, `tenantId` | +| `deactivate` | on `cancelSubscriptionContract { deactivate: true }`, after `cancel` | `id`, `tenantId` | +| `deactivated` | minutes before `activeUntil` on a contract with no `renewAt` | `subscriptionContractId`, `tenantId` | + +- **The id does not always have the same name.** Those fields are also the variables of the webhook's + `graphqlQuery`, so `renewalDue`, `activationDue` and `deactivated` need `$subscriptionContractId`, not + `$id`. The query runs on the PIM API unless `graphqlQueryTarget: next` sends it to Core. +- `renewalDue` and `renew` fire for paused contracts too. A contract created less than a week before + `renewAt` misses the week-ahead `renewalDue`. Handle all of them idempotently. +- Lifecycle calls on the Shop endpoint fire these too, once they have reached the Core copy. +- `createWebhook` accepts any `concern`/`event` string — `nope`/`nope` is accepted — so a typo creates a + webhook that never fires, without an error. From 15103a4e4c2b6b639d554002abadcddf58208379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 16:04:52 -0700 Subject: [PATCH 15/29] fix(skills): correct agent-tenants rate limit and add capacity, invite and deleted-tenant behaviour Provisioning has its own rate limit and can answer 503 at capacity. Invites work once, a past expiresAt gives a dead link, promoting a human claims the tenant, and calls after deletion answer 403. --- use-crystallize/skills/agent-tenants/SKILL.md | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/use-crystallize/skills/agent-tenants/SKILL.md b/use-crystallize/skills/agent-tenants/SKILL.md index 2c7f327..2824a68 100644 --- a/use-crystallize/skills/agent-tenants/SKILL.md +++ b/use-crystallize/skills/agent-tenants/SKILL.md @@ -62,7 +62,7 @@ curl -s -X POST https://api.crystallize.com/agents/tenants \ | ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | yes | String, 1 to 100 characters once trimmed. The tenant's display name. | | `identifier` | no | String, 1 to 100 characters. Lowercased; anything outside `a-z 0-9 -` becomes `-`. Without it (or if nothing usable remains) it comes from `name`, else `agent-<8 hex>`. | -| `source` | no | String, 1 to 100 characters. Who is provisioning (`claude-code`, `cursor`). Shown in Crystallize's Slack and as the agent user's name. | +| `source` | no | String, 1 to 100 characters. Who is provisioning (`claude-code`, `cursor`). Shown as the agent user's name. | Any other key is ignored, `omitDefaultShapes` included. Values that are not plain strings are refused with 400. @@ -168,12 +168,14 @@ EOF - **The claim invite's role must be `tenantAdmin`**, for whoever will own the tenant: the user, or someone they name. Only a human `tenantAdmin` claims the tenant. An invite with any other role lets a human in and keeps the - tenant from deletion, but never claims it: send one on top of the admin invite, never instead of it. + tenant from deletion, but does not claim it: send one on top of the admin invite, never instead of it. - **`expiresAt` is optional in the schema; send it anyway**, no later than the tenant's. An invite that - outlives the tenant is useless. + outlives the tenant is useless. A date in the past is accepted and gives a dead link: if a human has kept the + tenant past its deadline, send a future date. - **Errors come back in the result, not as HTTP errors.** An unknown role gives `RoleNotFoundError` instead of `InviteToken`. -- You can create a new invite at any time, for instance when one has expired. +- **An invite works once.** A second human needs a second invite, and a used link only shows an "already + redeemed" error. You can create a new invite at any time, for instance when one has expired or been used. ## 4 · Hand the invite to the human @@ -198,9 +200,8 @@ What happens next needs no call from you: 1. The App redeems the token (`redeemInviteToken`) and the human becomes `tenantAdmin` on the tenant. 2. At the next cleanup run, within 2 hours, Crystallize sees a human `tenantAdmin` and the tenant is claimed: - its provisioning record is deleted, so it can never be removed automatically. -3. Crystallize's Slack gets a "claimed" message, and the human is subscribed to the newsletter under the usual - signup rules. + from then on it can never be removed automatically. +3. The human is subscribed to the newsletter under the usual signup rules. ## 5 · Check whether the human joined (optional) @@ -236,17 +237,18 @@ they join, a second member appears with `role: "tenantAdmin"` and `kind: "human" | Topic | Rule | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Lifetime | `expiresAt` = provisioning + 72 h. A cleanup runs every 2 hours, so an unclaimed tenant goes between 72 h and about 74 h later, with all its content, the agent user and its token. | -| What keeps a tenant | Any human with a role on it. A human `tenantAdmin` claims it for good. A human with a lesser role keeps it from deletion but never claims it: it waits for a human admin. | +| What keeps a tenant | Any human with a role on it. A human `tenantAdmin` claims it for good. A human with a lesser role keeps it from deletion but does not claim it: it waits for a human admin. | +| Promoting a human | Promoting a lesser-role human to `tenantAdmin` claims the tenant: `setUserRole(userId: "", role: "tenantAdmin")` on the Core API, or the user list in the App. | | Nothing else | No call extends the 72 hours. Only a human joining keeps the tenant. | | After the claim | The agent stays `tenantAdmin` and its token keeps working. The human can remove the agent user in the App; the cleanup then deletes it and its tokens. | | One tenant per call | An agent cannot create tenants any other way (`createTenant` on `/@me`, PIM `tenant.create`), copy a tenant, or redeem invites. Another tenant is another `POST /agents/tenants`. | | The agent user | Named `Agent `, no email, and no one can log in as it. It acts only through its token, which works on its own tenant only. | -| Rate limit | None specific to provisioning. The API firewall's per-IP limit applies to every call (429). | +| Rate limit | Provisioning has its own limit, per IP and across all callers, on top of the per-IP limit on every call (429). On a shared cloud IP you can hit it with few calls of your own. | ## Errors -Provisioning answers plain HTTP errors with a JSON body, `{ "message": "…", "statusCode": 400 }`. GraphQL calls -answer HTTP 200 with the error inside the result union (`__typename`, `errorName`, `message`). +Provisioning answers plain HTTP errors with a JSON body, `{ "message": "…", "statusCode": 400 }`. Authenticated +GraphQL calls answer HTTP 200 with the error inside the result union (`__typename`, `errorName`, `message`). | Call | Status | Message | What to do | | ---------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | @@ -256,8 +258,10 @@ answer HTTP 200 with the error inside the result union (`__typename`, `errorName | Provision | 400 | `source must be a string of 1 to 100 characters.` | Fix or drop `source`. | | Provision | 409 | `That tenant identifier was just taken. Please retry.` | Retry the same call once: a new identifier is picked. Nothing was created. | | Provision | 500 | `Provisioning failed (trace ).` | Retry later; give the trace id to Crystallize support if it repeats. Half-created is cleaned. | +| Provision | 503 | `Crystallize cannot provision more agent tenants right now. …` | At capacity. Retry later, or have the user create a tenant and give you an access token. | | Provision | 404 | none | The endpoint is not deployed on that host. Tell the user. | -| Any | 429 | none | Too many requests from this IP: back off and retry. | +| Any | 429 | none | Too many requests. Provisioning is also limited across all callers: wait minutes, then retry. | +| Any call, once the tenant is deleted | 403 | `Invalid access token pair provided` | The tenant, its agent user and its token are gone. Provision again and replay your build. | | `createInviteToken` | 200 | `RoleNotFoundError`: `Role not found: (tenant id )` | The claim invite takes `tenantAdmin`; any other role must exist on the tenant. | | Agent-forbidden: `/@me` `createTenant`, invite redemption, tenant copy | 200 | `UnauthorizedError`: `This operation is not available to agent users.` | Do not retry. Create tenants with step 1 only, and let humans redeem invites. | @@ -270,7 +274,7 @@ answer HTTP 200 with the error inside the result union (`__typename`, `errorName | Calling `/@` | The calls fail when the identifier was taken and got a suffix | Use the returned `identifier` and `apiUrl` | | Provisioning again to start over while the tenant is alive | A second tenant, agent user and token; the first lingers for 72 h | Fix or recreate the shapes in the tenant you have | | Building only with one-off mutations | An expired tenant takes the whole build with it | Keep the build as a mass operations file and replay it into a new tenant | -| Sending only an invite with another role | The human gets in and keeps the tenant, but it is never claimed | Always create a `tenantAdmin` invite for whoever will own it; others go on top | +| Sending only an invite with another role | The human gets in and keeps the tenant, but it is not claimed | Always create a `tenantAdmin` invite for whoever will own it; others go on top | | An invite with no `expiresAt`, or one past the tenant's | A link that still looks valid after the tenant is gone | Pass the tenant's `expiresAt` | | Giving the link without the deadline | The user opens it on day four and finds nothing | Always state the date and time with the link | | Saying "it's yours, nothing to transfer" after provisioning | The user never opens the invite, and the tenant is deleted | The tenant is the agent's until a human `tenantAdmin` joins | From e6a7e8562beecd2f0d83d3c87f67d2d0f36d5278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 16:07:00 -0700 Subject: [PATCH 16/29] fix(skills): correct plugin placements, install flow, payload claims and token reach Documented placements never rendered in the App, installs omitted required terms flags, and examples trusted unverified payloads. Adds private installs, reinstall, the real payload claims, where the Backend Token works, and the plugin-signal channel. --- use-crystallize/skills/plugins/SKILL.md | 240 +++++++--- .../plugins/references/plugin-contract.md | 436 ++++++++++++------ 2 files changed, 452 insertions(+), 224 deletions(-) diff --git a/use-crystallize/skills/plugins/SKILL.md b/use-crystallize/skills/plugins/SKILL.md index 83e27da..953b18c 100644 --- a/use-crystallize/skills/plugins/SKILL.md +++ b/use-crystallize/skills/plugins/SKILL.md @@ -3,11 +3,13 @@ name: plugins description: > How to build Crystallize Plugins — vendor-hosted apps that extend the Crystallize App UI via iframes. Use when the user wants to create, register, install, or develop a Crystallize plugin: scaffolding the - upstream server, generating the plugin keypair, writing the configuration schema (JSON Schema), declaring - entrypoints / placements / scopes, handling the post-installation webhook, decrypting the iframe payload - and Backend Token, encrypting installation secrets, or testing a plugin locally with `issuePluginPayload`. - Also use when the user mentions plugins, plugin revisions, plugin store, plugin installations, plugin - payload, or plugin permissions. + upstream server with `crystallize plugin create`, generating the plugin keypair, writing the configuration + schema (JSON Schema), declaring entrypoints / placements / scopes, private installs before approval, + reinstalling or upgrading an installation, handling the post-installation webhook, decrypting the iframe + payload and Backend Token, encrypting installation secrets, talking to the App from the iframe with + `@crystallize/plugin-signal`, or testing a plugin locally with `issuePluginPayload`. + Also use when the user mentions Crystallize plugins, plugin revisions, plugin store, marketplace, plugin + installations, plugin payload, or plugin permissions. metadata: author: Crystallize version: "1.0" @@ -23,8 +25,8 @@ The full normative contract — entities, fields, JWE/JWT specs, request shapes, Before writing code, get the lay of the land: -1. **What does the plugin do?** Pure UI widget, server-side action on a Crystallize entity (order, customer…), or tenant-wide dashboard? This drives the entrypoint **placements**. -2. **Does it need to call Crystallize APIs?** If yes, you'll use the `backendToken` from the decrypted payload as a `Bearer` credential. +1. **What does the plugin do?** Pure UI widget, server-side action on a Crystallize entity (order, catalogue item…), or tenant-wide dashboard? This drives the entrypoint **placements**. +2. **Does it need to call Crystallize APIs?** If yes, you'll use the `backendToken` from the decrypted payload as a `Bearer` credential on the Core and PIM APIs. Other APIs need other credentials: see [Backend Token rules](#backend-token-rules). 3. **Does it need secrets?** API keys, webhook URLs, third-party credentials — these go in `secrets[]` and are encrypted in the installer's browser. Crystallize never sees plaintext. 4. **Where does it run?** Plugins are server-hosted (Cloudflare Worker, Vercel/Netlify edge, Lambda, plain Node/Bun server). Pure static hosting is **not** sufficient — the upstream must accept POST requests and decrypt JWE payloads. 5. **Which tenant(s)?** Plugins are installed per-tenant. One plugin can be installed on many tenants, each with its own configuration. @@ -47,9 +49,18 @@ Key invariants: ## End-to-End Workflow -### 1. Generate the keypair +### 1. Scaffold the upstream and generate the keypair -The vendor needs an RSA key (RSA-OAEP-256 / A256GCM, JWE compact). Use the Crystallize CLI: +`crystallize plugin create` downloads a skeleton, generates the keypair, and fills in the plugin name, identifier and keys: + +```bash +crystallize plugin create ./my-plugin hono-plugin-skeleton +# headless: add --no-interactive --name "My Plugin" --author "Acme" [--identifier com.acme.my-plugin] [--plugin-url https://plugin.acme.com] +``` + +Skeletons: `hono-plugin-skeleton`, `cloudflare-hono-plugin-buddy`, `hello-world-plugin`. + +To add a keypair to an existing server: the vendor needs an RSA key (RSA-OAEP-256 / A256GCM, JWE compact). Use the Crystallize CLI: ```bash crystallize plugin keygen @@ -59,7 +70,7 @@ This emits a `private.jwk.json` and a `public.jwk.json`. **Keep `private.jwk.jso ### 2. Register the plugin -Run on the Me API (`https://api.crystallize.com/@me`): +Run on the Me API (`https://api.crystallize.com/@me`), or create the plugin in the App under **Settings → Apps**. `/@me` answers 403 to tenant-bound credentials, including access tokens scoped to a tenant. ```graphql mutation { @@ -83,7 +94,9 @@ mutation { } ``` -The plugin starts in `pending` state — it can't be installed until approved. +`identifier` is lowercase reverse-DNS with at least three dot-separated segments, each starting with a letter. The CLI is laxer: `com.acme` passes `--identifier` but `createPlugin` rejects it. `logo` and `icon` must be absolute URLs. No mutation updates these fields afterwards. + +The plugin starts in `pending` state — until Crystallize approves a revision, only its owner can install it, as a private install (step 5). ### 3. Create the first revision @@ -114,7 +127,7 @@ Variables (the `$input`): "scopes": [], "entryPoints": [ { - "placement": "order/view/toolbar-button", + "placement": "orders/order/top/inline", "target": "/order/preview", "label": "Preview Order", "icon": "https://acme.com/btn.png" @@ -146,22 +159,41 @@ Variables (the `$input`): #### Field cheat sheet -| Field | Notes | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `upstream` | Base HTTPS URL of your origin. Crystallize POSTs to `$upstream/$tenantIdentifier/$path`. | -| `entryPoints[]` | Each: `placement` (`$concern/$view/$placement(/$type?)`), `target` (path under upstream), optional `label` + `icon`. | -| `scopes` | Permissions the plugin needs. Installer must hold them; can grant a subset. (V1: not yet enforced on the token — declare honestly anyway.) | -| `configurationSchema` | JSON Schema **draft 2020-12**. Crystallize renders this as the install form. Validates submissions (with secret fields excluded). | -| `secrets` | Names of properties from the schema to treat as secrets. Each becomes `` and is JWE-encrypted in the browser. | -| `publicKey` | JWK from `crystallize plugin keygen`. Must be `kty=RSA`, `use=enc`, `alg=RSA-OAEP-256`, `enc=A256GCM`. | -| `postInstallationUri` | Path Crystallize POSTs to on install / reinstall / uninstall events. **Fire-and-forget, no retries** — make your handler idempotent. | -| `version` | Vendor-declared semver. Cosmetic — does not drive any Crystallize behavior. | - -See the contract's [Entrypoints](references/plugin-contract.md) section for the full placement convention and the `dashboard` (non-entity-scoped) variant. +| Field | Notes | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `upstream` | Base HTTPS URL of your origin. `http://`, `localhost`, private IPs and IPv6 literals are rejected. Crystallize POSTs to `$upstream/$tenantIdentifier/$path`. | +| `entryPoints[]` | Each: `placement` (from the [table below](#placements)), `target` (path under upstream, starting with `/`), optional `label` + `icon` (absolute URL). | +| `scopes` | Permissions the plugin needs, as `:` (`orders:read`, never `order:read`): anything else is rejected. See [Scopes](references/plugin-contract.md#scopes) for the values. Recorded on the installation but not enforced on the token yet — declare honestly anyway. | +| `configurationSchema` | JSON Schema **draft 2020-12**. Crystallize renders this as the install form. Validates submissions (with secret fields excluded). Without a schema, `configuration` must be empty. | +| `secrets` | Names of **top-level** properties from the schema to treat as secrets. Each becomes `` and is JWE-encrypted in the browser. | +| `publicKey` | JWK from `crystallize plugin keygen`. Must be `kty=RSA` with `n` and `e`. Keep the `alg` and `enc` the CLI sets: the API treats them as optional, but the App needs both to encrypt secrets. | +| `postInstallationUri` | Path Crystallize POSTs to on install / reinstall / uninstall events. **Fire-and-forget, no retries** — make your handler idempotent. | +| `version` | Vendor-declared semver. Cosmetic — does not drive any Crystallize behavior. | + +#### Placements + +The API accepts any `placement` string, but the App only renders these. Anything else, such as `order/view/toolbar-button`, is stored without error and never appears. + +| Placement | Where the App renders it | +| ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `dashboard/home/top-card` | Home dashboard, replacing the default top card | +| `layout/sidebar-right` | Right sidebar, on every page | +| `layout/bottom-right/inline` | Fixed bottom-right bar, on every page | +| `orders/order/top/inline`, `orders/order/bottom/inline` | Order detail page | +| `catalogue/item/above-components/inline`, `catalogue/item/below-components/inline` | Item editor, around the components. Not on archived versions | +| `catalogue/variant/above-components/inline`, `catalogue/variant/below-components/inline` | Variant editor, around the components. Not on archived versions | +| `catalogue/item/split-view/inline` | An extra view in the item's view switcher, using the entry point's `label` and `icon` | +| `catalogue/folder-nerdy/nerdy-action/all`, `catalogue/folder-nerdy/nerdy-action/products`, `catalogue/folder-nerdy/nerdy-action/variants` | Bulk action in the catalogue data grid and Lightbox, opening a dialog. The suffix is the grid's items type | +| `plugins/page/full` | Full page at `/plugins/` | +| `apps/plugin/content` | The plugin's page under Apps, replacing its config page | + +The App renders only the first entry point of a revision for a given placement, so declare each placement once. Users whose role lacks read access to `pluginInstallations` see no plugin UI. + +Sizes, entity context and signals per placement are in the contract's [Entrypoints](references/plugin-contract.md#entrypoints) section. ### 4. Get approval -Plugins must be approved before installation. Query state: +Approval is what lets other tenants install the plugin. To try the plugin on your own tenant first, use a private install (step 5). Query state: ```graphql query { @@ -177,15 +209,24 @@ query { } ``` -Contact Crystallize on Slack to move it from `pending` → `active`. Once approved, `approvedRevision.id` is populated and the plugin appears in the Plugin Store. +Contact Crystallize on Slack to move it from `pending` → `active`: only Crystallize staff can approve a revision. Once approved, `approvedRevision.id` is populated and the plugin can be installed from any tenant. Each later revision needs its own approval. The marketplace page in the App is a separate listing, so ask for it when you request approval: without a listing, an approved plugin can only be installed through the API. ### 5. Install on a tenant -Either via the App UI (renders the schema, encrypts secrets in the browser) or via the API. For API installs, you must encrypt secrets yourself: +An installation resolves to one of two modes, and the mutation fails unless the matching terms flag is `true`: + +| Mode | Who | Revision | Required flag | +| ------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------ | --------------------------- | +| `marketplace` | A user with create access to `pluginInstallations` | The approved revision of an `active` plugin | `acceptMarketplaceTerms` | +| `private` | The plugin's owner, holding the tenantAdmin role on the target tenant | The latest revision, when it is not the approved one. Works on `pending` | `acceptPrivateInstallTerms` | + +Any other combination is refused, and an `inactive` plugin can't be installed at all. To check first, query `pluginInstallability(identifier: "com.acme.order-preview") { ... on PluginInstallability { mode } }` on the Core API: it returns `marketplace`, `private` or `notInstallable`. + +In the App, a marketplace install happens under **Apps** (renders the schema, encrypts secrets in the browser). A private install happens on the plugin's page under **Settings → Apps**, and there you paste the configuration as JSON and the secrets already encrypted. For private installs and API installs, you must encrypt secrets yourself: ```bash crystallize plugin encrypt-secret --public-key /path/to/public.jwk.json -# paste the plaintext secret; outputs a JWE compact string +# paste the plaintext secret, or pass --value / pipe it; outputs a JWE compact string ``` Then call (on `https://api.crystallize.com/@`): @@ -199,6 +240,7 @@ mutation { grantedScopes: [] configuration: { theme: "dark" } # non-secret fields encryptedSecrets: { apiKey: "" } # one entry per secret + acceptMarketplaceTerms: true # or acceptPrivateInstallTerms: true } ) { ... on BasicError { @@ -211,8 +253,16 @@ mutation { } ``` +`grantedScopes`, `configuration` and `encryptedSecrets` are all required: pass `[]` or `{}` when empty. Every name in the revision's `secrets` needs an entry, even when the schema does not mark it `required`. A missing one fails with `Encrypted secret for "" is not tagged with the target revisionId.` A tenant holds one installation per plugin, so a second `createPluginInstallation` fails: use `reinstallPluginInstallation`. + On install, Crystallize POSTs the post-install body to `$upstream/$tenantIdentifier/$postInstallationUri`. See **Develop the upstream** below. +#### Reinstall, upgrade, uninstall + +`reinstallPluginInstallation` takes the same input with `installationId` in place of `pluginIdentifier`. It replaces the revision, scopes, configuration and secrets, and keeps the same `installationId`. The modes and terms flags above apply again, so a private install pinned to a superseded revision can only move to the latest one. `deletePluginInstallation(id:)` uninstalls. See [Re-install and Uninstall](references/plugin-contract.md#re-install-and-uninstall) for both mutations. + +Send every secret again, because a blank one is rejected rather than kept. To keep a value, resend its stored `ciphertext`. That only works while the key is unchanged: Crystallize can't tell which key encrypted a ciphertext, so a stale one is accepted, and then `decrypter` throws on every payload of that installation. + ## Develop the Upstream The upstream is your server. Two endpoint shapes to handle — both POST with the same wire format: @@ -238,25 +288,26 @@ const decrypter = createPluginPayloadDecrypter({ Always read the private JWK from a secret env var — never commit it. +**The decrypter does not reject a forged payload.** It only throws when a payload can't be decrypted. A bad or expired signature still returns the envelope, with `signatureStatus.verified: false`. Your public key is not secret, so anyone can build a payload that your private key decrypts: refuse the request unless `signatureStatus.verified` is `true`, as both handlers below do. + ### Post-install handler ```ts app.post("/:tenantIdentifier/post-install", async (c) => { const tenantIdentifier = c.req.param("tenantIdentifier"); try { - // Form-encoded. Crystallize may send the JWE under either field name — - // accept both for forward compatibility. - const body = await c.req.parseBody<{ payload?: string; encryptedPayload?: string }>(); - const jwe = typeof body?.payload === "string" ? body.payload : body?.encryptedPayload; - if (typeof jwe !== "string") return c.text("invalid body", 400); + const body = await c.req.parseBody(); // form-encoded + if (typeof body?.payload !== "string") return c.text("invalid body", 400); - const decoded = await decrypter(jwe); + const decoded = await decrypter(body.payload); + if (!decoded.signatureStatus.verified) return c.text("invalid signature", 403); if (decoded.envelope?.tenantIdentifier !== tenantIdentifier) { return c.text("tenant mismatch", 403); // path tenant must match payload tenant } // decoded.envelope.event is "install" | "reinstall" | "uninstall" // decoded.envelope.configuration carries non-secret config; decoded.secrets - // carries plaintext secrets (already decrypted by the decrypter). + // carries plaintext secrets (already decrypted by the decrypter). Neither is + // delivered on "uninstall", and its backendToken no longer works. // Persist tenant-scoped state. Handler MUST be idempotent — no retries. return c.text("ok"); } catch { @@ -277,6 +328,7 @@ app.post("/:tenantIdentifier/order/preview", async (c) => { if (typeof body?.payload !== "string") return c.text("invalid body", 400); const decoded = await decrypter(body.payload); + if (!decoded.signatureStatus.verified) return c.text("invalid signature", 403); if (decoded.envelope?.tenantIdentifier !== tenantIdentifier) { return c.text("tenant mismatch", 403); } @@ -303,42 +355,74 @@ app.post("/:tenantIdentifier/order/preview", async (c) => { The `decrypter` returns a structured `DecryptedPluginPayload` (see [`@crystallize/js-api-client`](https://www.npmjs.com/package/@crystallize/js-api-client)): -| Field | Description | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| `envelope.tenantIdentifier` | Always check against the path param. | -| `envelope.tenantId` | Numeric tenant ID — needed by some Crystallize APIs. | -| `envelope.installationId` | Stable across reinstalls of the same `(tenant, plugin)`. | -| `envelope.pluginIdentifier` | Sanity-check it's yours. | -| `envelope.revisionId` | Which revision this installation is pinned to. | -| `envelope.configuration` | Non-secret configuration values (plaintext). | -| `envelope.encryptedSecrets` | Per-field JWE ciphertext (raw). The decrypter already plaintexts these into `secrets` — only touch this for re-encryption. | -| `envelope.entityContext` | e.g. `{ orderId: "..." }` for entity-scoped placements; omitted for `dashboard`. | -| `envelope.event` | **Webhook variant only**: `"install"` \| `"reinstall"` \| `"uninstall"`. Absent on iframe payloads. | -| `envelope.backendToken` | RS256 JWT (string) — pass as `Authorization: Bearer …` to Crystallize APIs. | -| `envelope.signatureSecret` | _(optional)_ Shared secret for verifying inbound Crystallize webhooks via `createSignatureVerifier`. Present when applicable. | -| `envelope.staticAuthToken` | _(optional)_ Long-lived bearer for Discovery / non-iframe contexts. Present when applicable. | -| `secrets` | **Plaintext** map of secret values, already decrypted by the decrypter. Use this — don't re-decrypt `encryptedSecrets`. | -| `signatureStatus.verified` | `true` if the outer payload signature checks out. | -| `backendTokenStatus.verified` | `true` if the JWKS verification of the Backend Token passed (when `verifyBackendToken: true`). | -| `backendTokenStatus.claims` | Decoded claims (`sub` = userId, `aud` = your plugin identifier, `act = { pluginId, installationId, revisionId }`). | +| Field | Description | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `envelope.tenantIdentifier` | Always check against the path param. | +| `envelope.tenantId` | Tenant ID (string). | +| `envelope.installationId` | Stable across reinstalls of the same `(tenant, plugin)`. An uninstall followed by an install creates a new one. | +| `envelope.pluginIdentifier` | Sanity-check it's yours. | +| `envelope.revisionId` | Which revision this installation is pinned to. | +| `envelope.sub` | The viewing user's ID. On webhooks, the user who first installed the plugin, whoever triggered the event. | +| `envelope.configuration` | Non-secret configuration values (plaintext). Absent on `uninstall`. | +| `envelope.encryptedSecrets` | Per-field JWE ciphertext (raw). The decrypter already plaintexts these into `secrets` — only touch this for re-encryption. Absent on `uninstall`. | +| `envelope.entityContext` | The page behind the iframe at load time, e.g. `{ entity: "orders", orderId: "...", mode: "view", placement, ... }`. The App always sends it, even on the dashboard. | +| `envelope.event` | **Webhook variant only**: `"install"` \| `"reinstall"` \| `"uninstall"`. Absent on iframe payloads. | +| `envelope.installMode` | **Webhook variant only**: `"marketplace"` \| `"private"`. Absent on `uninstall`. | +| `envelope.acceptedTermsAt` | **Webhook variant only**: ISO 8601 timestamp of the terms acceptance. Absent on `uninstall`. | +| `envelope.backendToken` | RS256 JWT (string) — pass as `Authorization: Bearer …` to the Core and PIM APIs. | +| `envelope.signatureSecret` | _(optional)_ The tenant's secret for verifying inbound Crystallize webhooks via `createSignatureVerifier`. Present when the tenant has one. | +| `envelope.staticAuthToken` | _(optional)_ The tenant's static auth token for protected Catalogue / Discovery APIs. Pass it as `staticAuthToken`, not as a bearer. Present when the tenant has one. | +| `secrets` | **Plaintext** map of secret values, already decrypted by the decrypter. Use this — don't re-decrypt `encryptedSecrets`. | +| `signatureStatus.verified` | `true` if the signature of the JWT inside the JWE checks out. `false` when it fails, has expired, or `verify` was omitted; see `signatureStatus.reason`. | +| `backendTokenStatus.verified` | `true` if the JWKS verification of the Backend Token passed (when `verifyBackendToken: true`). | +| `backendTokenStatus.claims` | Decoded claims (`sub` = userId, `aud` = your plugin identifier, `act = { pluginIdentifier, tenantId, installationId, revisionId }`). | ### Backend Token rules -- TTL: 1 hour. Never cache it across requests — every iframe load mints a fresh one. -- Send as `Authorization: Bearer ` to any Crystallize API. The server verifies signature + plugin state on every call (the `inactive` kill-switch is **live**, not TTL-bounded). -- The token impersonates the **viewing user**, bounded by their permissions. A plugin cannot escalate privileges. +- TTL: 1 hour. Never cache it across requests — every iframe load mints a fresh one. A mounted iframe is not re-POSTed, so there is no refresh until the user reloads the page. +- Send as `Authorization: Bearer ` to the Core API (`nextPimApi`) and the PIM API (`pimApi`). No other API takes it: `/@me` answers 403, Discovery and Catalogue ignore it, and the Shop API token fetch does not read it. +- For a protected Catalogue or Discovery API, build a separate `createClient({ tenantIdentifier, staticAuthToken: decoded.envelope.staticAuthToken })`. A client holding both credentials sends only the Bearer, so those calls fail. +- The server verifies signature, installation and plugin state on every call. Uninstalling, or reinstalling onto another revision, revokes outstanding tokens at once, and the `inactive` kill-switch is **live**, not TTL-bounded. A revoked or expired token gets a 400 `No authentication data provided.`; a plugin in a refused state gets a 403. +- The uninstall webhook carries a `backendToken`, but the installation is already gone, so that token never works. Clean up without calling Crystallize. +- The token impersonates the **viewing user**, bounded by their permissions. A plugin cannot escalate privileges. On webhooks it impersonates the user who first installed the plugin. + +## Talk to the App from the iframe + +Use [`@crystallize/plugin-signal`](https://www.npmjs.com/package/@crystallize/plugin-signal) in the browser code your upstream serves. It is a typed JSON-RPC channel over `postMessage`: + +```ts +import { createChannel, createWindowTransport } from "@crystallize/plugin-signal"; + +const channel = createChannel({ + transport: createWindowTransport(), + handlers: { + setEntityContext: (_channel, context) => render(context), + }, +}); + +channel.notify("ready", undefined); // the App answers with setEntityContext +channel.notify("notifyHeight", { height: document.body.scrollHeight }); +channel.notify("navigateTo", { entity: "order", id: "" }); +``` + +**`envelope.entityContext` goes stale.** The App POSTs the payload once, when the iframe mounts. As the user navigates, a mounted iframe (the sidebar, for one) is not re-POSTed: the App sends each change through `setEntityContext` instead. Treat the payload's value as the initial one. + +Every method, and which placements honour it, is in the contract's [App Channel](references/plugin-contract.md#app-channel) section. ## Local Testing Without the App UI -You don't need to click through the App UI to exercise an endpoint locally. Crystallize exposes the same payload-issuing mutation directly: +You don't need to click through the App UI to exercise an endpoint locally. Crystallize exposes the same payload-issuing mutation directly on the Core API (`https://api.crystallize.com/@`). The `entryPointId` comes from `pluginInstallation(id:) { ... on PluginInstallation { revision { entryPoints { id placement } } } }`: ```graphql mutation { issuePluginPayload( installationId: "" entryPointId: "" - entityContext: { orderId: 42 } # optional, depends on placement + entityContext: { entity: "orders", orderId: "", mode: "view" } # optional ) { + ... on BasicError { + message + } ... on PluginPayload { encryptedPayload url @@ -354,39 +438,45 @@ curl -X POST "$URL" \ --data-urlencode "payload=$ENCRYPTED_PAYLOAD" ``` -For the post-install webhook, the wire format is identical — `curl --data-urlencode "payload=$ENCRYPTED_PAYLOAD" "$URL"`. To re-trigger an install event for testing, re-install the plugin (it's atomic and keeps the same `installationId`). +To inspect a payload, run `crystallize plugin decrypt-payload --private-key ./private.jwk.json --payload "$ENCRYPTED_PAYLOAD" --audience com.acme.order-preview`. + +For the post-install webhook, the wire format is identical — `curl --data-urlencode "payload=$ENCRYPTED_PAYLOAD" "$URL"` — but `issuePluginPayload` sets no `event`. To trigger a real lifecycle event for testing, re-install the plugin (it's atomic, keeps the same `installationId`, and fires `reinstall`). -For local development of the upstream, expose your dev server with a tunnel (e.g. ngrok) and set that URL as the revision's `upstream` — code under `upstream` can change without a new revision. +For local development of the upstream, expose your dev server with a tunnel (e.g. ngrok) and set that URL as the revision's `upstream`. `upstream` is locked in the revision, so use a stable hostname: a new URL means a new revision and a reinstall. Code under `upstream` can change without a new revision. ### Dev-payload Vite plugin (no Crystallize round-trip) -If you're on Vite, the **`crystallize-buddy`** example ships a `vite/dev-payload.ts` plugin that intercepts GETs in dev and mints a fake JWE envelope (encrypted with your local `public.jwk.json`, then re-POSTs it to your handler). You declare the routes you want to mock in `dev-payload.config.jsonc` (entrypoints, post-install events, entity context, plaintext secrets that get JWE-wrapped). This decouples local development from the App UI entirely — open the route in a browser, your handler sees a payload identical to production. +If you're on Vite, the **`crystallize-plugin-skeleton`** and **`crystallize-buddy`** examples ship a `vite/dev-payload.ts` plugin that intercepts GETs in dev and mints a fake JWE envelope (encrypted with your local `public.jwk.json`, then re-POSTs it to your handler). You declare the routes you want to mock in `dev-payload.config.jsonc` (entrypoints, post-install events, entity context, plaintext secrets that get JWE-wrapped). This decouples local development from the App UI entirely — open the route in a browser, your handler sees a payload identical to production. -The plugin uses a `dev-payload://` prefix on the locally-minted `backendToken` as a sentinel; your decrypter middleware should let unsigned dev payloads through when it sees that prefix (production payloads always carry a real RS256 token and a verified signature). Lift this pattern wholesale if your stack permits. +The plugin uses a `dev-payload://` prefix on the locally-minted `backendToken` as a sentinel, and its payloads are unsigned. **Do not copy the examples' bypass as it stands:** their middleware skips the signature check on that prefix alone, and anyone holding your public key can mint such a payload against production. Gate the bypass on something only your dev build sets, such as `import.meta.env.DEV`, so production always requires a verified signature. ## Common Mistakes -| Mistake | What to do | -| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| Storing `PLUGIN_PRIVATE_JWK` in source control | Treat it like any other private key. Env var only, secret manager in production. | -| Forgetting to validate `tenantIdentifier` from the path vs payload | Always compare both. The contract requires the path to match the payload's `tenantIdentifier`. | -| Trusting `pluginIdentifier` from payload without checking | Compare it to your own `PLUGIN_IDENTIFIER`. The decrypter's `audience` check covers the token; do this too. | -| Caching the Backend Token | Never. It's per-load, 1h TTL, and live-revoked when the plugin goes `inactive`. | -| Designing the post-install handler with side effects that aren't idempotent | Webhook is fire-and-forget, **no retries**. Make it idempotent or rely on the next iframe load's payload. | -| Trying to mutate revision fields after creation | A revision is immutable. Create a new revision and re-install to migrate. | -| Putting secrets in plaintext `configurationSchema` properties | List the property name in the top-level `secrets[]` array. The App UI then encrypts it client-side. | -| Auto-migrating users to a new revision | Installations pin a `revisionId`. Users must re-install to consent to the new contract. | +| Mistake | What to do | +| --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Storing `PLUGIN_PRIVATE_JWK` in source control | Treat it like any other private key. Env var only, secret manager in production. | +| Acting on a payload without checking its signature | The decrypter returns the envelope even when the signature fails. Refuse it unless `decoded.signatureStatus.verified` is `true`. | +| Forgetting to validate `tenantIdentifier` from the path vs payload | Always compare both. The contract requires the path to match the payload's `tenantIdentifier`. | +| Trusting `pluginIdentifier` from payload without checking | Compare it to your own `PLUGIN_IDENTIFIER`. The decrypter's `audience` check covers the token; do this too. | +| Inventing a placement | The API accepts any string and the App renders nothing. Use a placement from the [table](#placements). | +| Caching the Backend Token | Never. It's per-load, 1h TTL, and live-revoked on uninstall, on a reinstall onto another revision, or when the plugin goes `inactive`. | +| Designing the post-install handler with side effects that aren't idempotent | Webhook is fire-and-forget, **no retries**. Make it idempotent or rely on the next iframe load's payload. | +| Trying to mutate revision fields after creation | A revision is immutable. Create a new revision and re-install to migrate. | +| Putting secrets in plaintext `configurationSchema` properties | List the property name in the top-level `secrets[]` array. The App UI then encrypts it client-side. | +| Auto-migrating users to a new revision | Installations pin a `revisionId`. Users must re-install to consent to the new contract. | +| Dropping the old private key after rotating keys | Installations on the old revision keep getting payloads encrypted with its key. Keep the old key until they have all moved. | ## Working Examples -The [Crystallize plugins monorepo](https://github.com/CrystallizeAPI/plugins) ships two reference plugins: +The [Crystallize plugins monorepo](https://github.com/CrystallizeAPI/plugins) ships three reference plugins. Check their payload handling against this skill before copying it: `hello-world` omits the signature check, and the other two have the dev-payload bypass described above. +- **[`plugins/crystallize-plugin-skeleton`](https://github.com/CrystallizeAPI/plugins/tree/main/plugins/crystallize-plugin-skeleton)** — the empty starting point behind `crystallize plugin create … hono-plugin-skeleton`: a Node.js server with Vite and Hono. - **[`plugins/hello-world`](https://github.com/CrystallizeAPI/plugins/tree/main/plugins/hello-world)** — minimal Bun + Hono server. Two routes (`post-install` + entrypoint) showing payload decryption, envelope/tenant validation, and a Backend-Token-authenticated Crystallize API call. Mirror this layout (`src/index.ts` for routes, `.env` for `PLUGIN_PRIVATE_JWK` + `PLUGIN_IDENTIFIER`) for any new plugin. - **[`plugins/crystallize-buddy`](https://github.com/CrystallizeAPI/plugins/tree/main/plugins/crystallize-buddy)** — full-featured Cloudflare Workers plugin (Hono + Vite + JSX SSR with React islands, Awilix DI, webhook receiver, SSE channel). Useful when you outgrow a single-file server: shows a reusable `payloadDecrypter` middleware, a per-tenant routing pattern (`/:tenantIdentifier/...`), and a local **dev-payload Vite plugin** that mints fake JWE payloads on GET so you can develop without round-tripping through Crystallize. ## References -- [Plugin Contract](references/plugin-contract.md) — normative spec: entities, fields, JWE/JWT formats, sequence diagrams, security model, V1 vs V2 behavior. +- [Plugin Contract](references/plugin-contract.md) — normative spec: entities, fields, JWE/JWT formats, sequence diagrams, entity context, App channel, security model, V1 vs V2 behavior. - [Official documentation](https://crystallize.com/docs/developer/plugins) - Companion skill: [js-api-client](../js-api-client/SKILL.md) — `createClient`, `createPluginPayloadDecrypter`, all API callers. -- CLI helpers: `crystallize plugin keygen`, `crystallize plugin encrypt-secret --public-key `. +- CLI helpers: `crystallize plugin create`, `crystallize plugin keygen`, `crystallize plugin encrypt-secret --public-key `, `crystallize plugin decrypt-payload --private-key `. diff --git a/use-crystallize/skills/plugins/references/plugin-contract.md b/use-crystallize/skills/plugins/references/plugin-contract.md index 6fc13f3..348fcad 100644 --- a/use-crystallize/skills/plugins/references/plugin-contract.md +++ b/use-crystallize/skills/plugins/references/plugin-contract.md @@ -20,15 +20,15 @@ Three entities manage the lifecycle of a plugin from creation to installation. ### Plugin Collection -Where plugins are **defined**. A developer creates a plugin by calling a mutation on `/@me`, providing the plugin metadata (name, description, logo, etc.) and its first revision. +Where plugins are **defined**. A developer creates a plugin with `createPlugin` on `/@me`, providing the plugin metadata (name, description, logo, etc.), then adds its first revision with `createPluginRevision`. Both can also be done in the App under **Settings → Apps**. Every plugin has a **state**: -- `pending` — submitted, awaiting validation by Crystallize -- `active` — validated, visible in the Plugin Store and installable -- `inactive` — disabled. The plugin is no longer installable and **stops working immediately across all tenants** (live auth rejects any request made with a token whose `act.pluginId` matches an inactive plugin — the kill is not bounded by token TTL). +- `pending` — submitted, awaiting validation by Crystallize. Only its owner can install it, as a private install. +- `active` — validated and installable +- `inactive` — disabled. The plugin is no longer installable and **stops working immediately across all tenants** (live auth rejects any request made with a token whose `act.pluginIdentifier` matches an inactive plugin — the kill is not bounded by token TTL). -A plugin can't be installed until it reaches `active` state. +Apart from the owner's private install (see [Installation Modes](#installation-modes)), a plugin can't be installed until it reaches `active` state. #### Revisions @@ -45,11 +45,11 @@ A plugin evolves through **revisions**. A revision locks the **contract surface* A revision is immutable once submitted. The code running at `upstream` is entirely under the vendor's control — bug fixes, UI changes, and performance improvements can be deployed anytime without a new revision. A new revision is only needed when the contract itself changes. -Plugin-level fields (name, description, logo, etc.) live on the plugin and can be updated independently of revisions. They're cosmetic — they don't affect what the plugin is allowed to do. +Plugin-level fields (name, description, logo, etc.) live on the plugin, not on a revision. They're cosmetic — they don't affect what the plugin is allowed to do. `createPlugin` sets them, and no mutation updates them afterwards. Revision fields are locked per revision because they directly affect the Backend Token: when a user installs a plugin, they consent to a specific `upstream`, a specific set of entrypoints, and a specific set of scopes. The token issued at runtime is bounded by that consent. Locking the contract per revision ensures the token always reflects what was approved at installation. -Each plugin carries an `approvedRevisionId` pointer. This is the revision that the Plugin Store surfaces and that new installations default to. Installations pin a specific `revisionId` and **never auto-migrate** — upgrading an installation to a newer revision requires the user to re-install. +Each plugin carries an `approvedRevisionId` pointer. This is the revision that the Plugin Store surfaces and that new installations default to. Only Crystallize staff can set it. Installations pin a specific `revisionId` and **never auto-migrate** — upgrading an installation to a newer revision requires the user to re-install. The full mutation input is detailed in the [Revision Fields](#revision-fields) section. @@ -59,11 +59,11 @@ Where **installations** live. When a user installs a plugin on their tenant, the One plugin can be installed on many tenants, each with its own configuration and scopes. Each `(tenant, plugin)` pair maps to exactly one installation. -To change configuration, scopes, or revision, the plugin must be re-installed. Re-install is atomic and keeps the same `installationId`. +To change configuration, scopes, or revision, the plugin must be re-installed with `reinstallPluginInstallation`. Re-install is atomic and keeps the same `installationId`. ### Plugin Store -The **public-facing catalog** of available plugins. Only plugins in `active` state appear, enriched with marketing content (descriptions, screenshots, vendor info, etc.). +The **public-facing catalog** of available plugins, shown as the marketplace under **Apps** in the App. Only plugins in `active` state can be installed from it. Its entries carry marketing content (descriptions, screenshots, vendor info, etc.) and are a separate listing: approving a revision does not create one. Users browse the Plugin Store in the App UI to discover and install plugins. @@ -73,9 +73,9 @@ Users browse the Plugin Store in the App UI to discover and install plugins. A valid Plugin must satisfy: -1. **Hosting**: a publicly accessible HTTPS domain, operated by the vendor. The origin must accept POST requests and decrypt payloads with the vendor's private key. Pure static hosting is not sufficient. -2. **Plugin registration**: the developer creates the plugin and its first revision via a mutation on `/@me` in Crystallize Core. -3. **Post-installation endpoint**: a path declared in the revision (`postInstallationUri`) where Crystallize POSTs configuration and encrypted secrets at install / reinstall / uninstall time. +1. **Hosting**: a publicly accessible HTTPS domain, operated by the vendor. The origin must accept POST requests and decrypt payloads with the vendor's private key. Pure static hosting is not sufficient. `upstream` must be `https:` and cannot be `localhost`, a private or link-local IPv4 address, or an IPv6 literal. +2. **Plugin registration**: the developer creates the plugin and its first revision via mutations on `/@me` in Crystallize Core. +3. **Post-installation endpoint**: a path declared in the revision (`postInstallationUri`) where Crystallize POSTs configuration and encrypted secrets at install / reinstall time, and a final notice at uninstall time. --- @@ -99,23 +99,29 @@ When a user installs, re-installs, or uninstalls a plugin on their tenant, Cryst $upstream/$tenantIdentifier/$postInstallationUri ``` -The body is `application/x-www-form-urlencoded` with a single field `payload=` — the same wire format used for entrypoints. The plaintext body (after decrypting the JWE) is: +The body is `application/x-www-form-urlencoded` with a single field `payload=` — the same wire format used for entrypoints. The JWE wraps a JWT signed by Crystallize, whose claims are: ```ts -type PostInstallBody = { +type PostInstallClaims = { event: "install" | "reinstall" | "uninstall"; + tenantId: string; tenantIdentifier: string; installationId: string; - userId: string; // installer / actor - config?: Record; // omitted for uninstall - encryptedSecrets?: Record; // JWE-per-field, omitted for uninstall pluginIdentifier: string; revisionId: string; - issuedAt: number; // epoch seconds + backendToken: string; // never usable on uninstall: the installation is already gone + configuration?: Record; // omitted for uninstall + encryptedSecrets?: Record; // JWE-per-field, omitted for uninstall + installMode?: "marketplace" | "private"; // omitted for uninstall + acceptedTermsAt?: string; // ISO 8601, omitted for uninstall + signatureSecret?: string; // the tenant's, when it has one + staticAuthToken?: string; // the tenant's, when it has one + // Standard JWT claims: iss, aud (your plugin identifier), iat, nbf, exp, jti, + // and sub (the user who first installed the plugin, whoever triggered the event) }; ``` -**Delivery semantics**: fire-and-forget, best-effort, **no retries**. A 2xx response counts as success; anything else is logged and ignored. The install/reinstall/uninstall mutation does **not** block on the vendor's response — the user-visible operation completes regardless. Vendors should therefore design their post-install handler to be idempotent and to tolerate losing an occasional event. +**Delivery semantics**: fire-and-forget, best-effort, **no retries**. The request times out after 5 seconds. A 2xx response counts as success; anything else is logged and ignored. The install/reinstall/uninstall mutation does **not** block on the vendor's response — the user-visible operation completes regardless. Vendors should therefore design their post-install handler to be idempotent and to tolerate losing an occasional event. ### Plugin UI Endpoints @@ -135,33 +141,33 @@ The vendor chooses the `target` paths — the entrypoints registered in the revi The plugin definition is submitted via a mutation on `/@me`. It lives in the Plugin Collection. -The definition has two layers: **plugin-level fields** describing the plugin (updatable anytime) and **revision-level fields** defining the contract (locked per revision). +The definition has two layers: **plugin-level fields** describing the plugin (set at creation) and **revision-level fields** defining the contract (locked per revision). ### Plugin Fields -| Field | Type | Description | -| ------------- | -------- | ---------------------------------------------------------------------------- | -| `name` | `string` | Display name of the plugin | -| `identifier` | `string` | Reverse-DNS identifier, globally unique, immutable (e.g. `com.acme.invoice`) | -| `author` | `string` | _(optional)_ Author / vendor name | -| `description` | `string` | _(optional)_ Short description | -| `logo` | `string` | _(optional)_ URL to a logo image | -| `icon` | `string` | _(optional)_ URL to an icon image | +| Field | Type | Description | +| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | `string` | Display name of the plugin | +| `identifier` | `string` | Reverse-DNS identifier, globally unique, immutable (e.g. `com.acme.invoice`). Lowercase, at least three dot-separated segments, each starting with a letter | +| `author` | `string` | _(optional)_ Author / vendor name | +| `description` | `string` | _(optional)_ Short description | +| `logo` | `string` | _(optional)_ Absolute URL to a logo image | +| `icon` | `string` | _(optional)_ Absolute URL to an icon image | ### Revision Fields These define the contract surface and are locked per revision. Changing any of them requires a new revision via `createPluginRevision`. -| Field | Type | Description | -| --------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `upstream` | `String!` | Base HTTPS URL of the plugin origin | -| `entryPoints` | `[PluginEntryPointInput!]!` | Where the plugin appears in the UI (see [Entrypoints](#entrypoints)) | -| `scopes` | `[PluginScope!]` | Permissions the plugin requires (see [Scopes](#scopes)) | -| `configurationSchema` | `JSON` | JSON Schema (draft 2020-12) defining the install-time configuration form (see [Configuration](#configuration)) | -| `secrets` | `[String!]` | Configuration property names to treat as secrets (see [Secrets](#secrets)) | -| `publicKey` | `PluginPublicKeyInput!` | JWK public key used to encrypt secrets at install time and the iframe payload at runtime (see [Cryptography](#cryptography)) | -| `postInstallationUri` | `String!` | Path where Crystallize POSTs installation data (see [Post-Installation Endpoint](#post-installation-endpoint)) | -| `version` | `String!` | Semver string. The plugin's "current version" is the `version` of `approvedRevisionId` | +| Field | Type | Description | +| --------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `upstream` | `String!` | Base HTTPS URL of the plugin origin | +| `entryPoints` | `[PluginEntryPointInput!]!` | Where the plugin appears in the UI (see [Entrypoints](#entrypoints)) | +| `scopes` | `[PluginScope!]` | Permissions the plugin requires (see [Scopes](#scopes)) | +| `configurationSchema` | `JSON` | JSON Schema (draft 2020-12) defining the install-time configuration form (see [Configuration](#configuration)) | +| `secrets` | `[String!]` | Configuration property names to treat as secrets (see [Secrets](#secrets)) | +| `publicKey` | `PluginPublicKeyRevisionInput!` | JWK public key used to encrypt secrets at install time and the iframe payload at runtime (see [Cryptography](#cryptography)) | +| `postInstallationUri` | `String!` | Path where Crystallize POSTs installation data (see [Post-Installation Endpoint](#post-installation-endpoint)) | +| `version` | `String!` | Semver by convention, not validated. The plugin's "current version" is the `version` of `approvedRevisionId` | ```graphql input CreatePluginRevisionInput { @@ -170,7 +176,7 @@ input CreatePluginRevisionInput { scopes: [PluginScope!] configurationSchema: JSON secrets: [String!] - publicKey: PluginPublicKeyInput! + publicKey: PluginPublicKeyRevisionInput! postInstallationUri: String! version: String! } @@ -178,18 +184,18 @@ input CreatePluginRevisionInput { ### Scopes -The `scopes` field declares the permissions the plugin needs. This is what the installer reviews before granting access. +The `scopes` field declares the permissions the plugin needs. This is what the installer reviews before granting access. Each scope is `:`, and `createPluginRevision` rejects anything else. ```json { - "scopes": ["order:read", "order:write", "customer:read"] + "scopes": ["orders:read", "orders:update", "customers:read"] } ``` -At install time: +- **Actions**: `create`, `read`, `update`, `delete`, `publish`, `unpublish`, `transition`. +- **Resources**: `apps`, `assets`, `bookingPolicies`, `catalogue`, `customers`, `flows`, `fulfillment`, `grids`, `inviteTokens`, `languages`, `markets`, `metaPresets`, `operationLog`, `orders`, `pieces`, `pluginInstallations`, `plugins`, `priceLists`, `priceVariants`, `productBookable`, `reports`, `roles`, `shapes`, `stockLocations`, `subscriptionContracts`, `subscriptionPlans`, `tenant`, `tenantSso`, `topics`, `user`, `users`, `vatGroups`, `vocabularies`, `webhooks`. -1. The installer **must hold all requested scopes** — you can't install a plugin that asks for permissions you don't have. -2. The installer **can restrict** the granted scopes to a subset. For example, if the plugin asks for `order:read` and `order:write`, the installer can grant only `order:read`. +At install time, `grantedScopes` is recorded on the installation as submitted. The App grants everything the revision declares. The API lets the caller pass a subset, and checks the value neither against the revision's scopes nor against the installer's own permissions. > **V1 behavior:** granted scopes are recorded on the installation but are **not** enforced when minting the Backend Token. In V1, the Backend Token carries the current user's full permissions (bounded by the user's own role, as always). Scope intersection enforcement — narrowing the token to `grantedScopes ∩ userPermissions` — is planned for V2. Declare scopes honestly now so that the installer's consent is meaningful and so that V2 will "just work" for your plugin. @@ -204,12 +210,14 @@ Only the vendor holds the matching private key. Crystallize never holds plaintex The key must be an RSA key with: -| JWK field | Required value | -| --------- | -------------- | -| `kty` | `RSA` | -| `use` | `enc` | -| `alg` | `RSA-OAEP-256` | -| `enc` | `A256GCM` | +| JWK field | Accepted value | +| --------- | ------------------------------------------- | +| `kty` | `RSA`, with non-empty `n` and `e` | +| `use` | `enc`, by convention (not validated) | +| `alg` | `RSA-OAEP-256` (default) or `RSA-OAEP` | +| `enc` | `A256GCM` (default), `A192GCM` or `A128GCM` | + +`crystallize plugin keygen` emits `RSA-OAEP-256` and `A256GCM`. Keep `alg` and `enc` on the key: the API treats them as optional, but the App reads both to encrypt secrets. Reading a key back through GraphQL returns `alg` as an enum (`RSA_OAEP_256`), so convert it to the JWK form (`RSA-OAEP-256`) before using the key. JWE compact serialization is used on the wire. @@ -280,7 +288,7 @@ The `configurationSchema` field must be a valid **JSON Schema, draft 2020-12**. } ``` -At install and re-install, Crystallize validates the submitted `config` against this schema (with secret-field nodes excluded from the validated subset). +At install and re-install, Crystallize validates the submitted `configuration` against this schema, after removing secret fields from its top-level `properties` and `required`. A revision with no `configurationSchema` only accepts an empty `configuration`. ### Secrets @@ -292,99 +300,118 @@ A top-level array of configuration property names to treat as secrets: } ``` -Every name in `secrets[]` must resolve to a property in `configuration`. +Every name in `secrets[]` must be a top-level property of `configurationSchema.properties`, or `createPluginRevision` fails. Nested properties can't be secrets, and a revision with secrets needs a schema. -When a property is listed in `secrets`, the App UI: +When a property is listed in `secrets`, the App UI, on a marketplace install: 1. Renders an `` for that field. 2. **Encrypts the value in the browser** using the plugin's `publicKey`, producing a JWE compact string. -3. Submits `{ config, encryptedSecrets }` to Crystallize. The plaintext secret value never leaves the user's browser. +3. Submits `{ configuration, encryptedSecrets }` to Crystallize. The plaintext secret value never leaves the user's browser. + +A private install in the App does not encrypt anything: the developer pastes values already encrypted with `crystallize plugin encrypt-secret`. API callers do the same. -Crystallize stores the ciphertext verbatim. Each stored ciphertext is tagged with the `revisionId` it was encrypted against. +Crystallize stores the ciphertext verbatim, tagged with the `revisionId` of the installation it was submitted for. That tag records where the value was submitted, not which key encrypted it: Crystallize can't read the ciphertext, so it can't detect the wrong key. **Re-install rules:** -- If the user re-installs against the **same revision** and leaves a secret field blank, the existing ciphertext is preserved (passthrough). -- If the user re-installs against a **new revision**, every secret must be re-encrypted against the new revision's `publicKey`. Crystallize rejects stale-tagged ciphertexts to prevent silent leaks if a vendor rotates keys. +- `encryptedSecrets` must hold an entry for every name in the revision's `secrets`, on every re-install. A missing entry is rejected, and the App's marketplace form never prefills secrets, so they must be re-entered there. +- To keep a value through the API, read `encryptedSecrets` from `pluginInstallation` (stored as `{ ciphertext, revisionId }`) and resend each `ciphertext` as a bare string. +- If the new revision uses a **different `publicKey`**, every secret must be re-encrypted against it. A stale ciphertext is accepted, but `createPluginPayloadDecrypter` then throws on every payload of that installation. -At runtime, Crystallize passes the stored ciphertexts through into the iframe payload as-is. The payload itself is then wrapped in an outer JWE using the same `publicKey`. The plugin's backend: +At runtime, Crystallize passes the stored ciphertexts through into the iframe payload as-is. The payload itself is signed as a JWT and then wrapped in an outer JWE using the same `publicKey`. The plugin's backend: -1. Decrypts the outer envelope to retrieve the payload plaintext. +1. Decrypts the outer envelope to retrieve the signed JWT, and verifies its signature. 2. Reads `encryptedSecrets[field]` — a per-field JWE — and decrypts it a second time with the same private key. -Only the vendor can decrypt secrets. Crystallize never holds plaintext. +`createPluginPayloadDecrypter` does both, and exposes the result as `secrets`. Only the vendor can decrypt secrets. Crystallize never holds plaintext. ### Entrypoints Entrypoints declare where the plugin appears in the Crystallize App UI. Each entrypoint defines a unique placement. -Placements follow the convention: - -``` -$CONCERN/$VIEW/$PLACEMENT(/$TYPE?) -``` - -- **concern** — the domain entity: `order`, `customer`, `subscription-contract`, `dashboard`, … -- **view** — which view on that entity: `view`, `edit`, `nerdy`, `developer`, `create` -- **placement** — where on that page: `toolbar-button`, `main`, `side-widget`, `main-widget` -- **type** _(optional)_ — how the UI renders it: `dialog`, `widget`, `link` - -Examples: - -``` -orders/nerdy/toolbar-button (dialog by design) -orders/nerdy/toolbar-action/link (redirect by design) -orders/nerdy/toolbar-action/button (dialog by design) -orders/nerdy/main-widget (inline by design) -order/view/main (widget by design) -order/view/toolbar-button (dialog by design) -order/view/sidebar (widget by design) -``` - -In V1, `placement` is a free string (no enum enforcement). Stick to the convention so your plugin lines up with where the App UI actually renders entrypoints. +`placement` is a free string for the API, which stores any value without error. The App only renders the placements below, so anything else never appears. It renders only the first entrypoint of a revision for a given placement. + +| Placement | Rendering | Height (px) | Width (px) | +| ---------------------------------------------- | --------------------------------------------------------------------- | ----------- | ---------- | +| `dashboard/home/top-card` | Home dashboard. Replaces the default top card | 120–1200 | Fixed | +| `layout/sidebar-right` | Right sidebar, on every page | 80–viewport | 240–640 | +| `layout/bottom-right/inline` | Fixed bottom-right bar, on every page | 120–320 | 120–320 | +| `orders/order/top/inline` | Order detail page, top | 0–1200 | Fixed | +| `orders/order/bottom/inline` | Order detail page, bottom | 0–1200 | Fixed | +| `catalogue/item/above-components/inline` | Item editor, above the components. Not on archived versions | 0–1200 | Fixed | +| `catalogue/item/below-components/inline` | Item editor, below the components. Not on archived versions | 0–1200 | Fixed | +| `catalogue/variant/above-components/inline` | Variant editor, above the components. Not on archived versions | 0–1200 | Fixed | +| `catalogue/variant/below-components/inline` | Variant editor, below the components. Not on archived versions | 0–1200 | Fixed | +| `catalogue/item/split-view/inline` | An extra view in the item's view switcher, labelled by the entrypoint | 80–2000 | Fixed | +| `catalogue/folder-nerdy/nerdy-action/all` | Bulk action in the catalogue data grid and Lightbox, for all items | Dialog | Dialog | +| `catalogue/folder-nerdy/nerdy-action/products` | Same, when the grid shows products | Dialog | Dialog | +| `catalogue/folder-nerdy/nerdy-action/variants` | Same, when the grid shows variants | Dialog | Dialog | +| `plugins/page/full` | Full page at `/plugins/` | Full page | Full page | +| `apps/plugin/content` | The plugin's page under Apps, replacing its config page | Full page | Full page | + +Heights and widths are the ranges the App clamps `notifyHeight` and `notifyWidth` to. Each entrypoint has the following fields: -| Field | Type | Description | -| ----------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | `string` | _Server-assigned_ at revision creation. Used by the App UI in `issuePluginPayload(installationId, entryPointId)`. | -| `placement` | `string` | The `$CONCERN/$VIEW/$PLACEMENT(/$TYPE?)` identifier | -| `target` | `string` | URI path suffix appended to `$upstream/$tenantIdentifier/`. Must start with `/`, must not contain `://`, must not contain `..` segments. | -| `label` | `string` | _(optional)_ Display label | -| `icon` | `string` | _(optional)_ URL to an icon | +| Field | Type | Description | +| ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | `string` | _Server-assigned_ at revision creation. Used by the App UI in `issuePluginPayload(installationId, entryPointId)`. | +| `placement` | `string` | One of the placements above | +| `target` | `string` | URI path suffix appended to `$upstream/$tenantIdentifier/`. Must start with `/`, must not start with `//`, must not contain `://`, must not contain `..` segments. | +| `label` | `string` | _(optional)_ Display label | +| `icon` | `string` | _(optional)_ Absolute URL to an icon | #### Entity Context -Most concerns (`order`, `customer`, `subscription-contract`, …) are entity-scoped — the plugin appears on a page tied to a specific resource. For entity-scoped entrypoints, the App UI passes the entity context (at minimum the entity ID) in the payload so the plugin knows which resource is currently on screen. +Every payload the App issues carries an `entityContext` describing the page behind the iframe, including on the dashboard. It follows the page, not the placement: a `layout/sidebar-right` iframe gets the context of whatever is open. + +```ts +{ entity, pathname, searchParams, placement, tenantIdentifier, language, ...fields } +``` -Some concerns, such as `dashboard`, are not entity-scoped — the plugin operates at the tenant level. For those, `entityContext` is omitted from the payload. +| `entity` | Fields | +| --------------------------- | ----------------------------------------------------------------------------------------- | +| `dashboard`, `unknown` | — | +| `catalogue` | `itemType`, `itemId`, `variantSku`, `itemLanguage`, `view`, `itemsType`, `selectedRowIds` | +| `orders` | `orderId`, `subscriptionId`, `mode` | +| `customers` | `identifier`, `mode` | +| `subscription-contracts` | `contractId`, `mode` | +| `fulfilment` | `pipelineId`, `mode` | +| `flows` | `identifier`, `flowType`, `mode` | +| `topics` | `topicId`, `topicLanguage`, `mode` | +| `grids` | `gridId`, `gridLanguage`, `mode` | +| `assets` | `imageKey`, `assetLanguage` | +| `lightbox` | `area`, `lightboxLanguage`, `selectedRowIds` | +| `insight`, `special-prices` | `section` | +| `changelog` | `documentId` | +| `marketplace` | `path` | +| `apps`, `plugins` | `identifier` | +| `settings` | `section` | + +All entity fields are optional. The `EntityContext` type and `zEntityContext` schema are exported by `@crystallize/plugin-signal`. + +The payload holds the context at load time. A mounted iframe is not re-POSTed as the user navigates: the App sends changes through `setEntityContext` (see [App Channel](#app-channel)). A direct `issuePluginPayload` call passes whatever `entityContext` the caller provides, and the claim is omitted when there is none. #### Examples -A plugin adding a "Preview Order" button on both the Order view and edit pages, plus a dashboard widget: +A plugin adding a "Preview Order" panel to the order page, a variant panel, and a dashboard card: ```json { "entryPoints": [ { - "placement": "order/view/toolbar-button", + "placement": "orders/order/top/inline", "target": "/order/preview", "label": "Preview Order", "icon": "https://example.com/icon.png" }, { - "placement": "order/edit/toolbar-button", - "target": "/order/preview", - "label": "Preview Order", - "icon": "https://example.com/icon.png" - }, - { - "placement": "order/edit/main-widget", - "target": "/order/edit-widget", - "label": "Order Edition Customer documentation" + "placement": "catalogue/variant/below-components/inline", + "target": "/variant/widget", + "label": "Variant Details" }, { - "placement": "dashboard/view/main", + "placement": "dashboard/home/top-card", "target": "/dashboard/main", "label": "Tenant Overview" } @@ -392,13 +419,26 @@ A plugin adding a "Preview Order" button on both the Order view and edit pages, } ``` -Note that `order/edit/main-widget` and `order/edit/toolbar-button` are two distinct positions on the same page. Without `view` in the placement, there would be no way to distinguish `order/edit/main` from `order/view/main`. - --- ## Installation Flow -End-to-end sequence when a user installs a plugin on their tenant. The same shape applies to re-install (`event: "reinstall"`) and uninstall (`event: "uninstall"`, no `config` / `encryptedSecrets` in the webhook body). +### Installation Modes + +Every install and re-install resolves to one mode, or is refused. The mutation fails unless the terms flag for that mode is `true`, and the flag is required again on every re-install. + +| Mode | Who | Revision | Required flag | +| ------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------ | --------------------------- | +| `marketplace` | A user with create access to `pluginInstallations` | The approved revision of an `active` plugin | `acceptMarketplaceTerms` | +| `private` | The plugin's owner, holding the tenantAdmin role on the target tenant | The latest revision, when it is not the approved one. Works on `pending` | `acceptPrivateInstallTerms` | + +`inactive` plugins are refused in both modes. A marketplace installation requires the plugin to stay `active` to keep running; a private installation also runs while the plugin is `pending`. + +`pluginInstallability(identifier:)` on the Core API returns the mode that applies to the current user on the current tenant: `marketplace`, `private` or `notInstallable`. `availablePlugin(identifier:)` returns an `active` plugin with its approved revision, and `null` otherwise. + +### Sequence + +End-to-end sequence when a user installs a plugin on their tenant. The same shape applies to re-install (`event: "reinstall"`) and uninstall (`event: "uninstall"`, no `configuration` / `encryptedSecrets` in the webhook body). ```mermaid sequenceDiagram @@ -412,26 +452,26 @@ sequenceDiagram AppUI->>Core: Fetch plugin + approved revision Core-->>AppUI: upstream, configuration schema,
secrets[], scopes[], publicKey,
revisionId, postInstallationUri AppUI->>Installer: Render form from JSON Schema
(password inputs for secret fields) - Installer->>AppUI: Fill config, choose granted scopes + Installer->>AppUI: Fill configuration, accept terms Note over AppUI: For each field in secrets[]:
encrypt its value with revision.publicKey
(RSA-OAEP-256 + A256GCM, JWE compact).
Plaintext secret never leaves the browser. - AppUI->>Core: createPluginInstallation(
pluginId, revisionId, grantedScopes,
config, encryptedSecrets) + AppUI->>Core: createPluginInstallation(
pluginIdentifier, revisionId, grantedScopes,
configuration, encryptedSecrets,
acceptMarketplaceTerms) - Note over Core: Validate:
• plugin.state = active
• revisionId = approvedRevisionId
• config vs schema (secret fields stripped)
• encryptedSecrets well-formed JWE
and tagged with this revisionId + Note over Core: Validate:
• install mode is marketplace or private
• terms flag matches that mode
• configuration vs schema (secret fields stripped)
• encryptedSecrets well-formed JWE,
one per name in secrets[] - Core->>Core: Persist installation
(config plaintext, encryptedSecrets
stored verbatim, tagged with revisionId) + Core->>Core: Persist installation
(configuration plaintext, encryptedSecrets
stored verbatim, tagged with revisionId) - Note over Core: Wrap post-install body in outer JWE
using revision.publicKey.
encryptedSecrets passed through
(already per-field encrypted). + Note over Core: Sign the post-install claims as a JWT, wrap it
in an outer JWE using revision.publicKey.
encryptedSecrets passed through
(already per-field encrypted). par Webhook (fire-and-forget) - Core-)Vendor: POST $upstream/$tenantIdentifier/$postInstallationUri
Content-Type: application/x-www-form-urlencoded
body: payload=<outer JWE of>
{ event:"install", tenantIdentifier,
installationId, userId, config,
encryptedSecrets, pluginIdentifier,
revisionId, issuedAt } + Core-)Vendor: POST $upstream/$tenantIdentifier/$postInstallationUri
Content-Type: application/x-www-form-urlencoded
body: payload=<outer JWE of signed JWT>
{ event:"install", tenantId, tenantIdentifier,
installationId, pluginIdentifier, revisionId,
backendToken, configuration, encryptedSecrets,
installMode, acceptedTermsAt } and Mutation returns - Core-->>AppUI: { installationId, ... } + Core-->>AppUI: { id, ... } AppUI-->>Installer: Installation complete end - Note over Vendor: 1. Decrypt outer JWE → post-install body
2. Decrypt each encryptedSecrets[field]
a second time (same private key)
3. Persist tenant-scoped state
Handler MUST be idempotent — no retries. + Note over Vendor: 1. Decrypt outer JWE, verify the JWT signature
2. Decrypt each encryptedSecrets[field]
a second time (same private key)
3. Persist tenant-scoped state
Handler MUST be idempotent — no retries. Vendor--)Core: 2xx (logged, not blocking) ``` @@ -439,8 +479,50 @@ sequenceDiagram - Crystallize never sees plaintext secret values. The only party that can decrypt them is the vendor, who holds the private key matching `revision.publicKey`. - The webhook is delivered once, best-effort. If the vendor is down, the install still completes — the authoritative state is always re-delivered on the next iframe load (see below). -- Re-install with a new `revisionId` requires the browser to re-encrypt **all** secret fields against the new revision's `publicKey`. Core rejects stale-tagged ciphertexts. -- Re-install against the **same** `revisionId` may leave secret fields blank; existing ciphertexts are preserved (passthrough). +- Re-install requires an entry for **all** secret fields, encrypted against the `publicKey` of the target revision. See the [re-install rules](#secrets). +- The uninstall webhook fires after the installation is deleted, so its `backendToken` can't call Crystallize APIs. + +### Re-install and Uninstall + +On the Core API (`https://api.crystallize.com/@`): + +```graphql +mutation { + reinstallPluginInstallation( + input: { + installationId: "" + revisionId: "" + grantedScopes: [] + configuration: { theme: "dark" } + encryptedSecrets: { apiKey: "" } + acceptMarketplaceTerms: true # or acceptPrivateInstallTerms: true + } + ) { + ... on BasicError { + message + } + ... on PluginInstallation { + id + revisionId + } + } +} +``` + +```graphql +mutation { + deletePluginInstallation(id: "") { + ... on BasicError { + message + } + ... on DeleteCount { + removed + } + } +} +``` + +The target revision must qualify under [Installation Modes](#installation-modes), and a re-install onto another revision revokes every Backend Token minted for the previous one. An uninstall followed by a fresh install creates a new `installationId`. In the App, an installation whose revision differs from the approved one is offered as an update. --- @@ -461,21 +543,22 @@ The App UI uses a **form-submit-to-named-iframe** pattern to load each entrypoin ### Encrypted Payload -The POST body contains a single encrypted payload, built by Crystallize Core and encrypted with the revision's `publicKey`. After decryption, the plaintext shape is: +The POST body contains a single encrypted payload, built by Crystallize Core and encrypted with the revision's `publicKey`. The JWE wraps a JWT signed by Crystallize (`cty: "JWT"`, RS256, 1 hour TTL), verifiable against the same JWKS as the Backend Token. Its claims are: ```ts -type PluginPayloadPlaintext = { +type PluginPayloadClaims = { backendToken: string; // RS256 JWT — see next section configuration: Record; // plaintext non-secret settings encryptedSecrets: Record; // per-field JWE compact strings - entityContext?: Record; // e.g. { orderId: "..." }, omitted for non-entity concerns - installationId: string; + entityContext?: Record; // see Entity Context + tenantId: string; tenantIdentifier: string; + installationId: string; pluginIdentifier: string; // reverse-DNS; sanity-check that this matches you revisionId: string; - userId: string; // the viewing user; redundant with backendToken.sub - issuedAt: number; // epoch seconds - expiresAt: number; // epoch seconds, matches backendToken.exp + signatureSecret?: string; // the tenant's, when it has one + staticAuthToken?: string; // the tenant's, when it has one + // Standard JWT claims: iss, aud (your plugin identifier), sub (the viewing user), iat, nbf, exp, jti }; ``` @@ -504,14 +587,14 @@ sequenceDiagram participant Vendor as Vendor Backend participant API as Crystallize API - Viewer->>AppUI: Navigate to page
(e.g. order/view/123) + Viewer->>AppUI: Navigate to page
(e.g. an order) AppUI->>Core: List installed entrypoints
matching placement for this page Core-->>AppUI: [{ installationId, entryPointId,
label, icon, ... }] loop For each matching entrypoint AppUI->>Core: issuePluginPayload(
installationId, entryPointId,
entityContext?) - Note over Core: 1. Check plugin.state = active
2. Mint Backend Token (RS256 JWT):
iss = Core base URL
sub = viewing userId
aud = pluginIdentifier
exp = iat + 3600
act = { pluginId, installationId, revisionId }
3. Assemble plaintext payload:
{ backendToken, config,
encryptedSecrets (passthrough),
entityContext, installationId,
tenantIdentifier, pluginIdentifier,
revisionId, userId,
issuedAt, expiresAt }
4. Wrap plaintext in outer JWE
using revision.publicKey + Note over Core: 1. Check the plugin state allows this installation
2. Mint Backend Token (RS256 JWT):
iss = Core base URL
sub = viewing userId
aud = pluginIdentifier
exp = iat + 3600
act = { pluginIdentifier, tenantId,
installationId, revisionId }
3. Sign the claims as a JWT:
{ backendToken, configuration,
encryptedSecrets (passthrough),
entityContext, tenantId, tenantIdentifier,
installationId, pluginIdentifier, revisionId,
signatureSecret, staticAuthToken }
4. Wrap the JWT in an outer JWE
using revision.publicKey Core-->>AppUI: { url: $upstream/$tenantIdentifier/$target,
encryptedPayload } @@ -520,12 +603,12 @@ sequenceDiagram AppUI->>Frame: Auto-submit form Frame->>Vendor: POST $upstream/$tenantIdentifier/$target
body: payload=<encryptedPayload> - Note over Vendor: 1. Decrypt outer JWE → plaintext payload
2. Verify backendToken via JWKS
($CORE/.well-known/jwks.json):
iss, aud, exp, act
3. Decrypt encryptedSecrets[field]
on demand (same private key) + Note over Vendor: 1. Decrypt outer JWE → signed JWT
2. Verify the JWT and backendToken via JWKS
($CORE/.well-known/jwks.json):
iss, aud, exp
3. Decrypt encryptedSecrets[field]
(same private key) opt Vendor calls Crystallize APIs on behalf of the viewer Vendor->>API: Request
Authorization: Bearer <backendToken> - Note over API: • Verify RS256 signature via JWKS
• Load real user by sub = userId
• Live plugin-state check:
reject if plugin not active
(not bounded by token TTL)
• Apply user's permissions + Note over API: • Verify RS256 signature via JWKS
• Load real user by sub = userId
• Live checks, not bounded by token TTL:
installation exists, still on this revision,
plugin state allows it
• Apply user's permissions API-->>Vendor: Response end @@ -540,7 +623,49 @@ sequenceDiagram - The URL is assembled server-side — the App UI treats `url` as opaque and never composes it from parts. - Nothing sensitive appears in the iframe's `src` or in any URL. The encrypted payload rides in the POST body. - The `inactive` kill-switch is enforced at the API boundary on every call, not just at token issue time — a token minted just before a plugin is disabled stops working immediately. -- Vendors who skip token verification can still read `userId` / `pluginIdentifier` / `revisionId` from the plaintext payload for display purposes, but any call back to Crystallize APIs requires the Bearer token — which Crystallize verifies end-to-end. +- `createPluginPayloadDecrypter` returns the claims even when the signature check fails, and the `publicKey` is not secret. Never act on a payload unless `signatureStatus.verified` is `true`. +- The App POSTs the payload once per mount. It does not re-issue it as the user navigates: see [Entity Context](#entity-context). + +--- + +## App Channel + +`@crystallize/plugin-signal` (public npm package) is a typed JSON-RPC 2.0 channel over `postMessage` between the iframe and the App. Both sides validate params, and invalid params fail with a `-32602` error. + +```ts +const channel = createChannel({ + transport: createWindowTransport(), + handlers: { setEntityContext: (_channel, context) => {} }, +}); +channel.notify("ready", undefined); +const result = await channel.call("updateItemComponent", params); // rejects with an RpcError after 5 seconds by default +``` + +`createWindowTransport()` targets the parent window and only accepts messages from `https://app.crystallize.com` and `http://localhost`. Pass `allowedOrigin` to change that. The upstream must allow being framed by the App, so it can't send `X-Frame-Options` or a `frame-ancestors` policy that excludes that origin. + +| Method | Direction | Params | Effect | +| ------------------------------ | ------------ | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `ready` | iframe → App | — | Send once mounted. The App answers with `setEntityContext`, and `setDataGrid` for a bulk action | +| `setEntityContext` | App → iframe | `EntityContext` | Sent after `ready`, then on every change of context | +| `notifyHeight` | iframe → App | `{ height }`, integer ≥ 0 | Resizes the iframe within the [placement's range](#entrypoints). No effect on full-page placements or bulk actions | +| `notifyWidth` | iframe → App | `{ width }`, positive integer | Resizes the iframe on `layout/sidebar-right` and `layout/bottom-right/inline` | +| `promoteToDialog` | iframe → App | `{ width?, height? }` | Shows the iframe as a centered dialog, keeping its state. No effect on full-page placements or bulk actions | +| `demoteFromDialog` | iframe → App | — | Returns it to its slot | +| `navigateTo` | iframe → App | `NavigateToParams` | Navigates the App. See below | +| `updateItemComponent` | iframe → App | `{ itemId, language, sku?, componentId, input }` | `call`, not `notify`. Sets a component value in the open editor. See below | +| `refetchItem` | iframe → App | `{ itemId, language }` | Reloads the base item in the open editor. Dropped without error unless that item and language are open | +| `refetchItemComponents` | iframe → App | `{ itemId, language }` | Reloads the item's components, under the same condition | +| `refetchItemVariantComponents` | iframe → App | `{ itemId, language }` | Reloads the displayed variant's components, under the same condition | +| `setDataGrid` | App → iframe | `DataGridPayload` | Bulk actions: the selected rows, sent after `ready` | +| `updateDataGrid` | iframe → App | `DataGridPayload` | Bulk actions: edited rows, applied to the grid as unsaved changes | +| `closePluginDialog` | iframe → App | — | Bulk actions: closes the dialog | +| `closeSplitView` | iframe → App | — | `catalogue/item/split-view/inline`: closes the split | + +**`navigateTo` targets**, by `entity`: `item` (`id` or `externalReference`, optional `language`), `productVariant` (`sku`, optional `language`), `image` (`key`), `order` (`id`), `fulfilmentPipeline` (`id`), `customer` (`identifier`), `grid` (`id`), `topic` (`id`, optional `language`), `priceList` (`identifier`), `plugin` (`identifier`, for a `plugins/page/full` plugin), and `lightbox` (`area: "catalogue"`, `groups`). All except `lightbox` accept `target: "_blank"` and `splits`, a list of `item` or `productVariant` targets to open alongside. + +**`updateItemComponent`** sets the value in the rendered editor field. It does not save: the user still saves the item, and the change only lands if that item, language and variant are open. It works on leaf components only, so targeting a chunk, choice or piece itself returns `NOT_RENDERED`: address the leaf inside it. `componentId` is the `/`-separated component path, e.g. `summary-chunk/0/summary`, and `input` is a component content input without its own `componentId`, e.g. `{ singleLine: { text: "New title" } }`. The result is `{ applied: true }` or `{ applied: false, reason: "NOT_RENDERED" | "TYPE_MISMATCH", message }`. + +To change data for good, call the API from your upstream with the Backend Token, then send a `refetch*` signal so the open editor catches up. --- @@ -548,7 +673,7 @@ sequenceDiagram The Backend Token is generated by Crystallize Core on every plugin load and delivered inside the encrypted payload. No exchange step is required — the plugin uses it immediately to call Crystallize APIs server-side. -The token **impersonates the visitor** (the user currently viewing the page). +The token **impersonates the visitor** (the user currently viewing the page). On webhooks it impersonates the user who first installed the plugin. ### Format @@ -556,26 +681,29 @@ RS256 JWT, signed by Crystallize Core. ### Claims -| Claim | Value | -| ----- | ----------------------------------------------------------------------------------------------------------------------- | -| `iss` | Crystallize Core base URL | -| `sub` | The viewing user's `userId` | -| `aud` | Your plugin's `identifier` (reverse-DNS) | -| `exp` | `iat + 3600` (1 hour TTL) | -| `iat` | Issue time (epoch seconds) | -| `jti` | Random UUID | -| `act` | `{ pluginId, installationId, revisionId }` — RFC 8693 "actor" claim, identifies the plugin acting on behalf of the user | +| Claim | Value | +| ----- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `iss` | Crystallize Core base URL | +| `sub` | The viewing user's `userId` | +| `aud` | Your plugin's `identifier` (reverse-DNS) | +| `exp` | `iat + 3600` (1 hour TTL) | +| `iat` | Issue time (epoch seconds) | +| `nbf` | Same as `iat` | +| `jti` | Random UUID | +| `act` | `{ pluginIdentifier, tenantId, installationId, revisionId }` — RFC 8693 "actor" claim, identifies the plugin acting on behalf of the user | ### Verification Verify every token you receive: -1. Fetch Crystallize's public JWKS at `$CORE_BASE_URL/.well-known/jwks.json`. +1. Fetch Crystallize's public JWKS at `$CORE_BASE_URL/.well-known/jwks.json` (`https://api.crystallize.com` in production). 2. Verify the RS256 signature. 3. Check `iss` matches the Crystallize Core base URL you expect. 4. Check `aud` matches your plugin's `identifier`. 5. Check `exp` is in the future. -6. Check `act.pluginId` / `act.installationId` / `act.revisionId` match the installation you're handling. +6. Check `act.pluginIdentifier` / `act.tenantId` / `act.installationId` / `act.revisionId` match the installation you're handling. + +`createPluginPayloadDecrypter` with `verify: { audience, verifyBackendToken: true }` covers steps 1 to 5, and reports the result in `backendTokenStatus` without throwing. Send the token as a `Bearer` credential: @@ -583,7 +711,15 @@ Send the token as a `Bearer` credential: Authorization: Bearer ``` -Crystallize's incoming auth pipeline verifies the token against the JWKS, rejects it if the plugin is not currently in `active` state (live check, not TTL-bounded), and produces a normal user context with `act` surfaced as the `via` field. +The Core API (`/@tenant`) and the PIM API accept it. No other API does: + +- `/@me` answers 403 `Tenant bounded tokens cannot access the /@me endpoint.` +- Discovery and Catalogue ignore it. They read the static auth token, and Catalogue also an access token pair. Use `envelope.staticAuthToken` on a protected tenant. +- The Shop API token fetch does not read it, so a Backend Token can't obtain a Shop API token. + +Crystallize verifies the token against the JWKS on every call, then runs live checks that are not bounded by the token's TTL. The installation must still exist, so uninstalling revokes the token. It must still be pinned to the token's `revisionId`, so reinstalling onto another revision revokes every token minted before. The plugin's state must allow the installation: `active`, or `pending` for a private install. + +A token that fails these checks or has expired gets a 400 `No authentication data provided.` A plugin in a refused state gets a 403. --- @@ -592,7 +728,7 @@ Crystallize's incoming auth pipeline verifies the token against the JWKS, reject Plugin permissions follow **least privilege**: 1. The plugin revision declares the permissions the plugin requires (`scopes`). -2. At install time, the installer (who must hold all requested permissions) can restrict the granted scopes to a subset. +2. At install time, the granted scopes are recorded on the installation. An API caller can pass a subset of the declared scopes. 3. At runtime, the effective permissions are — conceptually — the **intersection** of: - The scopes granted to the plugin at installation - The current viewing user's own permissions @@ -605,12 +741,14 @@ This means a plugin can never escalate a user's privileges. If a user has read-o ## Security Considerations -- **Plugin validation**. Plugins must be approved by Crystallize before they become installable. The state model (`pending` → `active`) ensures nothing runs without review. `inactive` is a live kill-switch that invalidates every outstanding Backend Token for the plugin regardless of TTL. +- **Plugin validation**. Plugins must be approved by Crystallize before other tenants can install them. The state model (`pending` → `active`) ensures nothing reaches them without review. The one exception is the private install: a plugin's owner, as tenantAdmin, can run an unapproved revision. `inactive` is a live kill-switch that invalidates every outstanding Backend Token for the plugin regardless of TTL. - **Revision locking**. Each revision locks the contract surface (upstream URL, entrypoints, scopes, configuration, secrets, public key, post-installation URI, version). The contract the user approved at install time is the one that runs. Code changes at `upstream` don't require a new revision; changes to the contract itself do. -- **Pinned installations**. Installations pin a specific `revisionId` and never auto-migrate. The user has to re-install to move to a newer revision — at which point they re-consent to the new contract and re-encrypt all secrets against the new public key. +- **Pinned installations**. Installations pin a specific `revisionId` and never auto-migrate. The user has to re-install to move to a newer revision — at which point they re-consent to the new contract and re-encrypt all secrets if the public key changed. +- **Key rotation**. A rotated key lives in a new revision, and installations on older revisions keep receiving payloads encrypted with the old key. Keep the old private key until every installation has moved. +- **Payload authenticity**. The JWE only proves that the payload was encrypted to your public key, which anyone may hold. The signature on the JWT inside proves it came from Crystallize, so verify it on every request and never bypass it in production. - **End-to-end payload encryption**. Every iframe load delivers a JWE-encrypted payload that only the plugin's backend can decrypt. The Backend Token, configuration, entity context, and secrets are never readable in the DOM or in any intermediate layer. - **Secrets isolation**. Secret configuration values are encrypted in the installer's browser using the revision's `publicKey` and are stored as ciphertext only. The outer payload envelope is then encrypted again with the same public key. Crystallize never sees plaintext secrets — not during install, not during storage, not during runtime delivery, not during the post-install webhook. - **No code execution in the UI**. The App UI only renders iframes, links, and forms. No vendor code runs in the App UI's JavaScript context. -- **Plugin-to-App UI communication**. A defined protocol (postMessage-based) governs iframe ↔ parent communication, with a shared library for common operations (open, close, select). -- **Vendor trust**. Installing a plugin means trusting the vendor with any secrets passed. The Plugin Store surfaces vendor identity clearly. -- **Webhook idempotency**. The post-install webhook is fire-and-forget with no retries. Design your handler to be idempotent and to tolerate occasional lost events — the authoritative state always lives on the next iframe load's payload, which carries full `config` and `encryptedSecrets`. +- **Plugin-to-App UI communication**. A defined protocol (postMessage-based) governs iframe ↔ parent communication, through `@crystallize/plugin-signal`. See [App Channel](#app-channel). +- **Vendor trust**. Installing a plugin means trusting the vendor with any secrets passed, and with the tenant's `signatureSecret` and `staticAuthToken`, which every payload carries when the tenant has them. The Plugin Store surfaces vendor identity clearly. +- **Webhook idempotency**. The post-install webhook is fire-and-forget with no retries. Design your handler to be idempotent and to tolerate occasional lost events — the authoritative state always lives on the next iframe load's payload, which carries full `configuration` and `encryptedSecrets`. From 7355539ba47985ba0c7deecb946850352f6787bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 16:07:00 -0700 Subject: [PATCH 17/29] fix(skills): say bearer tokens reach PIM as well as Core Both APIs accept a plugin Backend Token. --- use-crystallize/skills/js-api-client/SKILL.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/use-crystallize/skills/js-api-client/SKILL.md b/use-crystallize/skills/js-api-client/SKILL.md index 8ecd122..86bb4e8 100644 --- a/use-crystallize/skills/js-api-client/SKILL.md +++ b/use-crystallize/skills/js-api-client/SKILL.md @@ -283,13 +283,13 @@ needs the five new callers. Cart and Shop customer `birthDate` is now sent as an ## Authentication -| Auth Type | Header sent | Use Case | -| ------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `staticAuthToken` | `X-Crystallize-Static-Auth-Token: ` | Read-only catalogue/discovery | -| `accessTokenId` + `accessTokenSecret` | `X-Crystallize-Access-Token-Id` + `X-Crystallize-Access-Token-Secret` | PIM/Shop operations | -| `sessionId` | `Cookie: connect.sid=` | Alternative to token pair | -| `bearerToken` | `Authorization: Bearer ` | Plugins forward `envelope.backendToken`, the only JWT accepted; Core APIs only | -| `shopApiToken` | `Authorization: Bearer ` (Shop API only) | Optional; fetched with the access token pair or `sessionId` if omitted | +| Auth Type | Header sent | Use Case | +| ------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `staticAuthToken` | `X-Crystallize-Static-Auth-Token: ` | Read-only catalogue/discovery | +| `accessTokenId` + `accessTokenSecret` | `X-Crystallize-Access-Token-Id` + `X-Crystallize-Access-Token-Secret` | PIM/Shop operations | +| `sessionId` | `Cookie: connect.sid=` | Alternative to token pair | +| `bearerToken` | `Authorization: Bearer ` | Plugins forward `envelope.backendToken`, the only JWT accepted; Core and PIM APIs only | +| `shopApiToken` | `Authorization: Bearer ` (Shop API only) | Optional; fetched with the access token pair or `sessionId` if omitted | **Priority** when multiple are set on the same client: `sessionId` > `bearerToken` > `staticAuthToken` > `accessTokenId`/`accessTokenSecret`. Only one is sent per request. Per-caller restrictions: From f6d9aa5ef921594135c204f6ed77c2f6e18bebd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 16:08:24 -0700 Subject: [PATCH 18/29] fix(skills): correct the permissions role api, token model and field-level claims Replaces invented role GraphQL with the real Core API calls and corrects the token model: tokens belong to users. Documents conditions, allow-list UI preferences and full-replace updates. --- use-crystallize/skills/permissions/SKILL.md | 253 ++++++++++-------- .../skills/permissions/references/role-api.md | 239 +++++++++++++++++ 2 files changed, 373 insertions(+), 119 deletions(-) create mode 100644 use-crystallize/skills/permissions/references/role-api.md diff --git a/use-crystallize/skills/permissions/SKILL.md b/use-crystallize/skills/permissions/SKILL.md index 094932c..cba82d0 100644 --- a/use-crystallize/skills/permissions/SKILL.md +++ b/use-crystallize/skills/permissions/SKILL.md @@ -1,6 +1,6 @@ --- name: permissions -description: Manage user roles, permissions, and access control in Crystallize. Use when configuring field-level permissions (read-only or hidden fields for specific roles), managing user roles and team access, setting up access tokens with specific scopes, controlling who can view/edit specific content areas or component fields, designing role-based access for multi-team tenants, or planning permission strategies for content workflows. +description: Manage user roles, permissions, and access control in Crystallize. Use when configuring field-level permissions (read-only or hidden fields for specific roles), managing user roles and team access, creating or updating custom roles (createRole, updateRole, setUserRole, createInviteToken), restricting a role to specific folders, languages or stock locations, creating access tokens and scoping them to a tenant, deciding which user and role an integration runs as, debugging a Forbidden error on the Core API, controlling who can view/edit specific content areas or component fields, designing role-based access for multi-team tenants, or planning permission strategies for content workflows. For wiring credentials and Shop API token scopes into client code use js-api-client. metadata: author: Crystallize version: "2.0" @@ -19,16 +19,20 @@ This distinction trips up most users. Crystallize separates permissions into two | **UI permissions** | Crystallize UI only | Hide or lock fields from editors — cosmetic, does not restrict API access | | **CRUD permissions** | API + UI | Actual data access control (read/write/delete) | -Setting a field to "Read-Only" in the UI does **not** prevent API writes to that field. If you need true access restriction, set both UI and CRUD permissions. This is especially important when integrations (ERPs, PIMs) write data via API — you want UI-only locks for editors, but the API token needs write access. +In a role these are the **UI preferences** tab (`interfaceSettings` on the API) and the **Permissions** tab (`resources` on the API). + +Setting a field to "Read-Only" in the UI does **not** prevent API writes to that field, and **no API restriction exists per field**. CRUD permissions stop at the resource, narrowed at most by item and language or by stock location (see [Conditions](#conditions)): a role with `catalogue.update` can write every field of the items it reaches, through any access token. So a hidden field is hidden from the App, not protected. This is especially useful when integrations (ERPs, PIMs) write data via API — UI locks for editors, while the integration keeps write access. + +CRUD permissions apply on the Core and PIM APIs. They do not reach the Catalogue, Discovery or Shop APIs: see [Other credentials](#other-credentials). ## Consultation Approach Before recommending a permission setup, understand the user's situation. Ask clarifying questions: -1. **How many distinct user types do you have?** Editors, contributors, product managers, admins? Each needs its own role since Crystallize uses one-role-per-user. +1. **How many distinct user types do you have?** Editors, contributors, product managers, admins? Each needs its own role since Crystallize uses one role per user on a tenant. 2. **Do you need UI restrictions, API restrictions, or both?** UI-only hides/locks fields in the dashboard. CRUD controls actual data access. -3. **Are there specific folders or shapes that need restricted access?** Multi-team setups often need folder-level isolation. -4. **Do external systems write data?** ERPs, PIMs, and import scripts may need separate API tokens with broader access than human editors. +3. **Are there specific folders or languages that need restricted access, or shapes to hide from the App?** Multi-team setups often need folder-level isolation. +4. **Do external systems write data?** ERPs, PIMs, and import scripts may need their own user and role, with broader access than human editors. 5. **What fields should editors never touch?** Prices synced from ERP, migration metadata, internal tracking fields? Use the answers to design a role structure and recommend the right combination of UI + CRUD permissions. @@ -41,7 +45,7 @@ Crystallize provides granular access control at multiple levels: - **Field-level permissions** — Make specific component fields read-only or hidden for certain roles - **Resource-level permissions** — Control access to features like shapes, price variants, catalogue folders - **UI vs API permissions** — UI permissions only affect the Crystallize UI, not API access -- **Access tokens** — User tokens (follow role permissions) and API tokens (tenant-level) +- **Access tokens** — Belong to a user and act with that user's role ## User roles @@ -49,18 +53,21 @@ Crystallize has two built-in roles and supports unlimited custom roles. ### Built-in roles -| Role | Description | -| ---------------- | ----------------------------------------------------------------------------------------- | -| **Tenant Admin** | Full access to everything including tenant copying, mass operation logs, and all settings | -| **User** | Base role with minimal permissions — typically used as starting point for custom roles | +| Role | Identifier | Description | +| ---------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Tenant Admin** | `tenantAdmin` | Full access to everything and all settings. The only role that can copy the tenant, manage tenant secrets (static auth token, signature secret) and set up SSO | +| **User** | `user` | Legacy role, not a base for custom roles and not minimal: full access to the tenant's data, read-only on `roles` and `users`. The App no longer assigns it | + +Built-in roles cannot be edited, and `updateRole` refuses them. ### Custom roles -Create custom roles in **Settings → Roles & Permissions**. +Create custom roles in **Settings → Roles**. Key characteristics: -- **One-to-one mapping** — Each user can only have one role assigned +- **Empty by default** — A custom role inherits nothing, from `user` or anything else. Every action not set to `allow` is denied +- **One-to-one mapping** — Each user can only have one role per tenant - **Fully customizable** — Set permissions per feature/resource - **Granular control** — UI-level hiding/locking, folder-level catalogue access @@ -68,50 +75,78 @@ When inviting users, select which role they should have. If a user needs differe ## Field-level permissions -Field-level permissions are configured per **shape resource** in the Roles & Permissions settings. +Field-level permissions are UI preferences, configured per shape on the **Shapes** resource of a role. ### How to configure (UI) -1. Go to **Settings → Roles & Permissions** +1. Go to **Settings → Roles** 2. Select or create a role -3. Under **Shapes** resource, click on a specific shape -4. You'll see all components on that shape +3. Open the **Shapes** resource, switch to the **UI preferences** tab, and click on a specific shape +4. You'll see all components on that shape (product shapes have a **Product** and a **Variant** tab) 5. For each component, toggle: - - **Read-Only** — Field appears greyed out, cannot be edited - - **Hide Component Completely** — Field is invisible to this role + - **Lock icon (read-only)** — Field appears greyed out, cannot be edited + - **Eye icon (hidden)** — Field is invisible to this role ### Supported fields -- **Root-level components** on shapes -- **Structural components** (contentChunk, componentChoice, etc.) -- **Native Crystallize fields** — SKUs, images, variants +- **Root-level components** on shapes, and variant components on product shapes +- **Structural components** (contentChunk, componentChoice, etc.) — as a whole, not the fields nested in them +- **Native Crystallize fields** — Name, variant name, SKU and media can be locked. External reference and attributes can be locked or hidden +- **Whole shapes** — A hidden shape cannot be used to create items in the App. Existing items of that shape stay readable, editable and deletable - **Price variants** — Configure in the Price Variants resource (same pattern as shapes) +- **Orders, fulfilment pipelines and flows** — Have their own UI preferences tab: which pipelines and flow stages appear, stage transitions, hiding the settings from navigation ### Behavior | Permission | Crystallize UI | API behavior | | ---------------------------- | ----------------------- | -------------------------- | -| **Read-Only** | Greyed out, cannot edit | Can only read field values | -| **Hidden** | Completely invisible | Field is not accessible | -| **Default (no restriction)** | Editable | Full read/write access | +| **Read-Only** | Greyed out, cannot edit | Not affected, CRUD decides | +| **Hidden** | Completely invisible | Not affected, CRUD decides | +| **Default (no restriction)** | Editable | Not affected, CRUD decides | **Important:** UI permissions (hide/lock) only apply to the Crystallize UI, **not** the API level. Use API-level CRUD restrictions for API access control. +**Publishing gotcha:** Locking or hiding a component with min/max validation (files, selection, colors, item relations, or a chunk or choice containing one) leaves the role unable to meet that validation, so it can no longer publish. The App warns about it. The API does not. + ## Resource permissions Almost every feature in Crystallize has **CRUD operation resources** (Create, Read, Update, Delete). -### Common resources +### Resources + +API names, as used in `resources`. Each action takes `{ effect: allow | deny }`, and an action left out is denied. + +| Actions | Resources | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `create`, `read`, `update`, `delete` | `apps`, `assets`, `bookingPolicies`, `customers`, `fulfillment`, `languages`, `markets`, `metaPresets`, `operationLog`, `orders`, `pluginInstallations`, `priceLists`, `priceVariants`, `productBookable`, `roles`, `shapes`, `stockLocations`, `subscriptionContracts`, `subscriptionPlans`, `topics`, `users`, `vatGroups`, `vocabularies`, `webhooks` | +| `create`, `read`, `update`, `delete`, `transition` | `flows` | +| `create`, `read`, `update`, `delete`, `publish`, `unpublish` | `catalogue`, `grids` | +| `read` | `reports` | + +What a name covers is not always obvious: + +- `catalogue` is all items (products, documents, folders). The App labels it **Tree** +- `shapes` includes pieces, `assets` includes images, videos and files, `customers` includes customer groups +- `users` includes invite tokens +- `operationLog` also governs mass operation bulk tasks, so an integration running mass operations needs it + +**Dependencies.** The App treats these as required and adds them when you tick a permission. `createRole` and `updateRole` accept a role without them, so list them yourself: + +- Any action on a resource needs `read` on that resource +- `catalogue.read` needs `read` on `shapes`, `languages`, `priceVariants`, `subscriptionPlans` and `assets` +- `catalogue.create` needs `vatGroups.read` +- `catalogue.update` needs `update` on `priceVariants` and `subscriptionPlans`, plus `create` and `update` on `assets` +- `topics.read` needs `languages.read` +- `grids.read` needs `languages.read` and `catalogue.read` +- `orders.create`, `priceLists.read` and `subscriptionContracts.create` need `priceVariants.read` + +**API-only resources.** The App's role editor does not know `vocabularies` and `metaPresets`, and saving a role there drops them. Set them on the API, and set them again after a save in the App. + +**Treat `roles` and `users` writes as admin rights.** Nothing compares them to the holder's own level: `roles.update` can widen the holder's own role, and `users.update` or `users.create` can hand out `tenantAdmin`. -- **Shapes** — Control shape management and field-level permissions -- **Items** — Control item creation, editing, publishing -- **Price Variants** — Control which price variants a role can see/edit -- **Catalogue** — Restrict access to specific folders (e.g., User X can only edit folder Y) -- **Topics** — Control topic map management -- **Orders** — Control order visibility and management -- **Users** — Control user/team management +### Conditions -### Folder-level restrictions +Conditions narrow an `allow`, and are how folder-level restrictions work. In the App: open the resource and use **Add condition**. On the API: `conditions` on the action. A condition on `deny` is refused. Restrict catalogue access to specific folders per role: @@ -119,6 +154,22 @@ Restrict catalogue access to specific folders per role: - User can only **edit** certain folders - Combine with other permissions for granular control +| Resource | Condition fields | Actions | +| ---------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------- | +| `catalogue` | `itemIds`, `languages` | `create`, `read`, `update`, `publish`, `unpublish` | +| `catalogue` | `itemIds` | `delete` | +| `orders` | `stockLocations: { stockLocationIdentifiers, includeOrdersWithoutStockLocations }` | `create`, `read`, `update`, `delete` | +| `stockLocations` | `identifiers` | `read`, `update`, `delete` (`create` takes none) | + +How they behave: + +- **Per action** — An action allowed without conditions stays tenant-wide. To lock a team to a folder, put the condition on every action it holds +- **Item ids cover the subtree** — An item id matches that item and everything under it. Use the folder's item id, not its path +- **Conditions are alternatives** — Entries in `conditions` are ORed. Inside one entry, `itemIds` and `languages` must both match +- **Empty means everything** — `itemIds: []` is no item restriction, and `conditions: []` is no condition at all +- **Limits** — At most 42 `itemIds` per condition. Item ids, languages and stock locations must exist on the tenant, or the role is refused +- **No parent, no access** — A conditioned `create` is checked against `input.tree.parentId`, and a conditioned `read` on an item list against `filter.parentItemId`. Without one, the call is refused + ## UI vs API permissions | Permission type | Scope | Use case | @@ -130,104 +181,65 @@ Restrict catalogue access to specific folders per role: ## Access tokens -Crystallize has two types of tokens: - -### User tokens - -- **Follow user role permissions** — Same API access as the role assigned to the user -- **API-level only** — UI preferences are not included -- **Use case** — Programmatic access with the same permissions as a specific user role - -### API tokens - -- **Tenant-level** — Created for the entire tenant, not bound to a specific role -- **Use case** — Service-to-service integrations, webhooks, background jobs, ERP syncs - -Generate tokens in **Settings → Access Tokens**. - -**Choosing between token types:** Use user tokens when you want API access scoped to a role's permissions. Use API tokens for service integrations that need tenant-wide access regardless of any specific role. - -## API configuration +Every access token belongs to a user. There is no tenant-level or service token, and a token carries no permissions of its own: it acts as its owner, with the role the owner holds on the tenant at the time of the request. Change the role and the token follows. Remove the user from the tenant and the token stops working there. UI preferences do not apply to it. -Permissions can be queried and configured via the **Core API** under the `user` resource in the GraphQL schema. Permissions are a **separate resource** from shapes — you won't see permission settings when querying shapes. - -### Query role permissions +- **Personal** — Created in the user's own account settings, under **Access tokens**, not in the tenant's settings. On the API, `createAccessToken` on the Me API (`https://api.crystallize.com/@me`) always creates for the caller, and `accessTokens` lists the caller's. Nobody can create a token for another user, tenant admins included +- **Secret shown once** — `secret` is only returned at creation +- **Limit** — 50 tokens per user +- **Tenant scope** — `tenantId` (the id, not the identifier) limits a token to one tenant the user belongs to. Without it, the token reaches every tenant the user is on +- **A tenant-scoped token is refused on `/@me`** with a 403. It cannot call the Me API, and it cannot fetch a Shop API token, which goes through `/@me` too +- **SSO** — On a tenant that enforces SAML login, a token with no tenant scope only works for a Tenant Admin. Other users need a tenant-scoped token ```graphql -query GetRoles { - user { - roles { +mutation CreateToken($tenantId: ID) { + createAccessToken(name: "ERP sync", tenantId: $tenantId) { + ... on AccessToken { id - name - tenantPermissions + secret + } + ... on BasicError { + errorName + message } } } ``` -### Query a specific role +**Integrations run as a user.** Give each one a dedicated user, invited with a custom role that holds only what the integration needs, and sign in as that user to create the token. A person's token ties the integration to that person's role and account. Scope the token to the tenant, unless the integration also needs Shop API tokens: then leave it unscoped, and keep that user on a single tenant. -```graphql -query GetRole($roleId: ID!) { - user { - role(id: $roleId) { - id - name - tenantPermissions - } - } -} -``` +### Other credentials -### Create a custom role +Only the Core and PIM APIs check role permissions. -```graphql -mutation CreateRole { - user { - role { - create(input: { name: "Content Editor" }) { - id - name - } - } - } -} -``` +| API | Gated by | Role permissions apply | +| --------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| Core, PIM | Access token pair or session | Yes | +| Catalogue | **Settings → API access**: open, static token or access token pair | No: with the pair, any role on the tenant passes | +| Discovery | Static token, when the tenant protects it | No | +| Shop | Bearer token from `/auth/token`, requested with a session or an unscoped pair and a list of scopes | No | -### Update role permissions +See [js-api-client](../js-api-client/SKILL.md) for wiring these credentials. -```graphql -mutation UpdateRole($roleId: ID!) { - user { - role { - update( - id: $roleId - input: { - name: "Content Editor" - tenantPermissions: { - # Set CRUD permissions per resource - } - } - ) { - id - name - } - } - } -} -``` +## API configuration + +Roles are configured on the **Core API** (`https://api.crystallize.com/@{tenant}`, `nextPimApi` in the JS client), through the root fields `role`, `roles`, `createRole`, `updateRole`, `deleteRole`, `upsertLimitedShapeToRole`, `removeLimitedShapeFromRole` and `setUserRole`. Permissions are a **separate resource** from shapes — you won't see permission settings when querying shapes. -For the exact permission structure and available fields, check the Core API schema — the `tenantPermissions` input type defines all configurable resources and their CRUD flags. +Read [references/role-api.md](references/role-api.md) before writing any of these calls. It has every query, mutation and input shape. The traps: + +- **`updateRole` replaces `resources` and `interfaceSettings` whole.** Send one resource and every other resource is denied +- **`createRole` requires `resources`**, even if empty, and refuses reserved identifiers: `name: "Admin"` fails unless you pass another `identifier` +- **UI preference lists are allow-lists.** The first `upsertLimitedShapeToRole` on a role hides every other shape, and an unlisted component is hidden +- **`setUserRole` never demotes a Tenant Admin.** It succeeds and changes nothing, so promoting to `tenantAdmin` is one-way ## Best practices -1. **Start with User role** — Create custom roles based on the base User role, then add permissions as needed -2. **Always set both UI + CRUD** — UI restrictions alone don't protect data at the API level +1. **Start from an empty custom role** — Add only the permissions the role needs, with their dependencies. Do not assign the legacy User role +2. **Do not rely on UI restrictions to protect data** — They don't apply at the API level, and CRUD permissions stop at the resource 3. **One role per user type** — Can't assign multiple roles, so create distinct roles (e.g., "Content Editor", "Product Manager", "External Contributor") 4. **Test with non-admin account** — Always verify permissions work as expected from a non-admin user perspective 5. **Name roles descriptively** — e.g., "Content Editor - Blog Only", "Product Manager - Catalogue Admin" 6. **Use folder restrictions for multi-team setups** — Isolate catalogue access by team or department -7. **Separate human and machine access** — Use role-scoped user tokens for human workflows, tenant-level API tokens for integrations +7. **Separate human and machine access** — Personal tokens for human workflows, a dedicated user and role for each integration ## Common use cases @@ -238,9 +250,9 @@ Scenario: Prices are synced from an ERP — editors shouldn't manually change th Solution: 1. Create "Editor" role -2. In Shapes → Product shape → price component → set **Read-Only** (UI) +2. In the Price Variants resource → **UI preferences** → lock each price variant 3. Editors can see prices but cannot edit them in the dashboard -4. ERP integration uses an API token with full write access +4. ERP integration uses the access token of a dedicated user whose role has write access ### Hide internal metadata @@ -249,8 +261,8 @@ Scenario: Migration tracking fields should be invisible to content editors. Solution: 1. Create "Content Editor" role -2. In Shapes → [Your Shape] → migration-metadata component → **Hide Component Completely** -3. Only admins can see these fields +2. In Shapes → **UI preferences** → [Your Shape] → migration-metadata component → hide it with the eye icon +3. Only roles without that preference see these fields in the App. They are still returned to any token whose role can read the item ### Folder-restricted access @@ -258,21 +270,24 @@ Scenario: Team A manages products in `/shop/electronics`, Team B manages `/shop/ Solution: -1. Create "Team A Editor" role → Catalogue folder restriction → `/shop/electronics` -2. Create "Team B Editor" role → Catalogue folder restriction → `/shop/fashion` +1. Create "Team A Editor" role → condition on each of its `catalogue` actions → the item id of `/shop/electronics` +2. Create "Team B Editor" role → condition on each of its `catalogue` actions → the item id of `/shop/fashion` 3. Each team can only see and edit their assigned folder ### API-only integration with limited scope -Scenario: A storefront needs read-only access to products but should never modify data. +Scenario: A reporting job needs read-only access to products on the Core API but should never modify data. Solution: -1. Create "Storefront Reader" role with read-only CRUD permissions on Items, Shapes, Topics -2. Generate a user token scoped to this role -3. Use this token in the storefront's API calls — even if compromised, it can't write data +1. Create "Report Reader" role with `read` on `catalogue` and its dependencies (`shapes`, `languages`, `priceVariants`, `subscriptionPlans`, `assets`), plus `topics` +2. Invite a dedicated user with this role, and sign in as them to generate a token scoped to the tenant +3. Use this token in the job's API calls — even if compromised, it can't write data + +A storefront needs none of this: the Catalogue and Discovery APIs are read-only and do not use roles. Protect them with **Settings → API access** if they should not be public. ## References +- [Role API reference](references/role-api.md) — Queries, mutations and input shapes for roles, UI preferences and role assignment - [Official docs — User roles](https://crystallize.com/docs/guides/user-roles) - [Official docs — Access tokens](https://crystallize.com/docs/guides/access-tokens) diff --git a/use-crystallize/skills/permissions/references/role-api.md b/use-crystallize/skills/permissions/references/role-api.md new file mode 100644 index 0000000..9fe75fa --- /dev/null +++ b/use-crystallize/skills/permissions/references/role-api.md @@ -0,0 +1,239 @@ +# Role API Reference + +Queries and mutations for roles, on the **Core API** (`https://api.crystallize.com/@{tenant}`, `nextPimApi` in the JS client). Results are unions, so select `... on Role` and `... on BasicError`. Role calls need `read` or the matching write on the `roles` resource. + +## Contents + +- [Query role permissions](#query-role-permissions) +- [Query a specific role](#query-a-specific-role) +- [Create a custom role](#create-a-custom-role) +- [Update role permissions](#update-role-permissions) +- [Delete a role](#delete-a-role) +- [Set UI preferences](#set-ui-preferences) +- [Assign a role](#assign-a-role) + +## Query role permissions + +`roles` lists the tenant's custom roles. + +```graphql +query GetRoles($first: Int, $after: String) { + roles(first: $first, after: $after) { + ... on RoleConnection { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + identifier + name + userCount + } + } + } + ... on BasicError { + errorName + message + } + } +} +``` + +## Query a specific role + +```graphql +query GetRole($identifier: String!) { + role(identifier: $identifier) { + ... on Role { + identifier + name + userCount + resources { + catalogue { + read { + effect + conditions { + itemIds + languages { + code + } + } + } + } + shapes { + read { + effect + } + } + } + interfaceSettings { + shapes { + limitedShapes { + identifier + components { + id + editable + } + } + } + } + } + ... on BasicError { + errorName + message + } + } +} +``` + +To test a role against one item, `resources.catalogue` also has `canRead(itemId, language)`, `canUpdate`, `canDelete`, `canPublish`, `canUnpublish`, and `canCreate(parentItemId, language)`. To read the caller's own role, query `user { ... on User { role(tenantIdentifier: "your-tenant") { identifier } } }` on the Me API. + +## Create a custom role + +```graphql +mutation CreateRole($input: CreateRoleInput!) { + createRole(input: $input) { + ... on Role { + identifier + name + } + ... on BasicError { + errorName + message + } + } +} +``` + +```json +{ + "input": { + "name": "Blog Editor", + "resources": { + "catalogue": { + "read": { "effect": "allow", "conditions": [{ "itemIds": [""] }] }, + "update": { "effect": "allow", "conditions": [{ "itemIds": [""] }] }, + "publish": { + "effect": "allow", + "conditions": [{ "itemIds": [""], "languages": ["en"] }] + } + }, + "shapes": { "read": { "effect": "allow" } }, + "languages": { "read": { "effect": "allow" } }, + "priceVariants": { "read": { "effect": "allow" }, "update": { "effect": "allow" } }, + "subscriptionPlans": { "read": { "effect": "allow" }, "update": { "effect": "allow" } }, + "assets": { + "read": { "effect": "allow" }, + "create": { "effect": "allow" }, + "update": { "effect": "allow" } + } + } + } +} +``` + +`resources` is required, even if empty. `identifier` is optional, is generated from `name` and can never change. Reserved identifiers such as `admin`, `tenantAdmin` and `user` are refused, in any case. So `name: "Admin"` fails unless you pass another `identifier`. + +## Update role permissions + +```graphql +mutation UpdateRole($identifier: String!, $input: UpdateRoleInput!) { + updateRole(identifier: $identifier, input: $input) { + ... on Role { + identifier + name + } + ... on BasicError { + errorName + message + } + } +} +``` + +`input` takes `name`, `resources` and `interfaceSettings`, all optional. + +**`resources` and `interfaceSettings` are replaced whole, never merged.** Send one resource and every other resource is denied. Read the role first and send all of it. + +## Delete a role + +`deleteRole(identifier: $identifier)` returns `... on DeleteCount { removed }`. It is refused while a user holds the role, or while the role is the tenant's SAML default. + +## Set UI preferences + +Prefer these to `updateRole` for field locks: they change one shape and leave the rest of `interfaceSettings` alone. + +```graphql +mutation LockFields { + upsertLimitedShapeToRole( + identifier: "blog-editor" + input: { + limitedShape: { + identifier: "product" + components: [{ id: "title", editable: true }, { id: "erp-data", editable: false }] + productSku: { editable: false } + externalReferences: { visible: false, editable: false } + } + } + ) { + ... on Role { + identifier + } + ... on BasicError { + errorName + message + } + } +} +``` + +`removeLimitedShapeFromRole(roleIdentifier: "blog-editor", shapeIdentifier: "product")` takes a shape out of the list. + +**These lists are allow-lists.** The App's toggles fill them in for you. On the API you must: + +- `shapes.limitedShapes` — Absent means all shapes. Once present, only the listed shapes can be used to create items in the App. So the first `upsertLimitedShapeToRole` on a role hides every other shape, unless you upsert them all +- `components`, `variantComponents` — Absent means all editable. Once present, only the listed components show, in that order, and an unlisted one is hidden. To lock one component, list all of them +- `upsertLimitedShapeToRole` replaces the whole entry for that shape +- The same rule holds for `priceVariants.preferredPriceVariants`, `fulfillment.pipelines` and `flows.limitedFlows` with its `limitedFlowStages`. `pipelines[].identifier` takes the pipeline's id + +```text +interfaceSettings + shapes { hideShapesAnchor, limitedShapes[] } + priceVariants { hidePriceVariantsAnchor, preferredPriceVariants[]: { identifier, editable } } + orders { hideOrdersAnchor, limitOrdersToPipelines } + fulfillment { hideFulfillmentAnchor, pipelines[]: { identifier, editable } } + flows { hideFlowsAnchor, limitedFlows[]: { identifier, limitedFlowStages[]: { identifier, canTransitionIn, canTransitionOut } } } + +limitedShapes[] + identifier, components[]: { id, editable }, variantComponents[]: { id, editable } + itemName, variantName, variantMedia, productSku: { editable } + productAttributes, externalReferences: { editable, visible } +``` + +## Assign a role + +```graphql +mutation SetRole($userId: ID!, $role: String!) { + setUserRole(userId: $userId, role: $role) { + ... on UserTenantRole { + userId + role + } + ... on BasicError { + errorName + message + } + } +} +``` + +`role` is a custom role's identifier or `tenantAdmin`. The call needs `users.update`. The user must already be on the tenant, and you cannot change your own role. + +**Promoting to `tenantAdmin` is one-way here.** `setUserRole` on a Tenant Admin succeeds and returns `tenantAdmin` unchanged, so check `role` in the result. To demote, remove the user from the tenant (`tenant { removeUsers(tenantId, userIds) }` on the PIM API, which needs `users.create`) and invite them again. Before promoting, weigh what it brings: + +- Every token of theirs that reaches the tenant gains full access to it, and mass operations they created run with it +- An unscoped token gets into the tenant even if it enforces SAML login +- On an agent-made tenant, a human `tenantAdmin` claims the tenant for good, however they got the role: see [agent-tenants](../../agent-tenants/SKILL.md) + +To bring someone new onto the tenant, call `createInviteToken(role: "blog-editor", expiresAt: "...")`, which needs `users.create` and returns `... on InviteToken { token }`. They join through the App's `/get-access?token=` link. From 7daeeff47f4a1e4af793b4e9325ad4ce9b982968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Fri, 30 Oct 2026 16:08:57 -0700 Subject: [PATCH 19/29] fix(skills): correct content-json text rules and image nodes The skill rejected text on formatting and block nodes, which is what the App and html input write, and put src in image metadata, which nothing renders. It now lists what the App editor keeps, what html input loses, and how json reads back. --- use-crystallize/skills/content-json/SKILL.md | 413 +++++++++++-------- 1 file changed, 233 insertions(+), 180 deletions(-) diff --git a/use-crystallize/skills/content-json/SKILL.md b/use-crystallize/skills/content-json/SKILL.md index 6db9adf..d5f1d7f 100644 --- a/use-crystallize/skills/content-json/SKILL.md +++ b/use-crystallize/skills/content-json/SKILL.md @@ -1,6 +1,6 @@ --- name: content-json -description: Generate Crystallize Rich Text Content JSON — the tree-based AST format used by Crystallize for structured rich text. Use this skill whenever the user needs to create, convert, or manipulate rich text content for Crystallize, including converting HTML/Markdown to Crystallize JSON, building content programmatically, or understanding the Content Transformer format. Also use when the user mentions "rich text JSON", "content nodes", "Crystallize content format", or needs to populate rich text components. +description: Generate Crystallize rich text JSON, the node tree stored in `richText` components, paragraph bodies and image captions. Use when creating, converting or editing rich text for Crystallize, converting HTML or Markdown to Crystallize JSON, or debugging rich text that opens empty in the App. Also use when the user mentions "rich text JSON", "content nodes", "Crystallize content format" or the Content Transformer format. --- # Crystallize Rich Text Content JSON @@ -13,76 +13,79 @@ The output must be a **JSON array of block nodes** (or a single node object when Every node has these properties: -| Property | Type | Required | Description | -|---------------|-----------------------|----------|-------------| -| `kind` | `"block"` or `"inline"` | yes | Block-level or inline | -| `type` | `string` or `null` | yes | Semantic content type (see tables below) | -| `textContent` | `string` | no | Raw text (leaf nodes only — mutually exclusive with `children`) | -| `children` | `node[]` | no | Child nodes (mutually exclusive with `textContent`) | -| `metadata` | `object` | no | Extra attributes (e.g. `{ "href": "..." }` for links, `{ "id": "..." }` for any element) | +| Property | Type | Required | Description | +| ------------- | ----------------------- | -------- | ---------------------------------------------------------------------------------------- | +| `kind` | `"block"` or `"inline"` | yes | Block-level or inline | +| `type` | `string` or `null` | yes | Semantic content type (see tables below) | +| `textContent` | `string` | no | Raw text (leaf nodes only — mutually exclusive with `children`) | +| `children` | `node[]` | no | Child nodes (mutually exclusive with `textContent`) | +| `metadata` | `object` | no | Extra attributes (e.g. `{ "href": "..." }` for links, `{ "id": "..." }` for any element) | A node is either a **leaf** (has `textContent`) or a **branch** (has `children`). Never set both. ## Block Node Types (`kind: "block"`) -| `type` | HTML equivalent | Notes | -|-------------------|-----------------|-------| -| `"paragraph"` | `

` | Most common block type | -| `"heading1"` – `"heading6"` | `

` – `

` | | -| `"unordered-list"`| `