Skip to content

medusa-plugin-bluestone

@evolve-framework/medusa-plugin-bluestone · v1.0.0

Imports a Bluestone PIM catalog into Medusa, so a Medusa shop gets its categories, products, variants, translations and localized slugs from the PIM.

Prices and stock can be imported too, where you ask for them. An import runs from a widget in the admin, an admin route, a workflow or the server's start, and it records every run so you can follow it while it happens.

When to use

Install it in a Medusa backend whose product data comes from Bluestone PIM. It reads the same environment variables as @evolve-framework/bluestone-commercetools, the sync for commercetools, so a deployment that runs both configures Bluestone once.

Install @evolve-framework/medusa-plugin-slug as well to get a slug per locale; without it, the import writes no slugs. Translations are written with Medusa's Translation Module, which needs featureFlags: { translation: true }.

Get started

  1. Install the package with the peer dependencies it needs. It comes from the Evolve registry, which needs a one-time sign-in.

    pnpm add @evolve-framework/medusa-plugin-bluestone @medusajs/cli @medusajs/framework @medusajs/medusa
    Requirements4
    node>=20
    @medusajs/cli2.21.2
    @medusajs/framework2.21.2
    @medusajs/medusa2.21.2

For example:

// medusa-config.ts
// After adding the plugin, run `npx medusa db:migrate` to create its table.
import { defineConfig } from "@medusajs/framework/utils";
module.exports = defineConfig({
// projectConfig, modules and other plugins as before
plugins: [
{ resolve: "@evolve-framework/medusa-plugin-slug", options: {} },
{
resolve: "@evolve-framework/medusa-plugin-bluestone",
options: {
pricing: { strategy: "generated", band: [5, 500] },
stock: { strategy: "default", quantity: 100 },
},
},
],
});

What it imports

Bluestone Medusa
Category subtree under the webshop root product_category, matched on metadata.bluestone_id
GROUP and SINGLE products product, matched on external_id
VARIANT products product_variant on their parent
Attributes named as option axes product options and variant option values
Product relations of a mapped type metadata.relations on the product or variant
Non-base publication contexts translations of products, categories, option titles and option values, only where they differ from base
Every context one slug per locale via medusa-plugin-slug

The root category itself is not imported: it is Bluestone's container for the tree, not a category a shopper should see.

A PIM holds no prices and no stock. What the plugin does about that is a configured decision, never a default, as described under Prices and stock.

Configuration

Every option can be set in medusa-config.ts or in the environment. A plugin option wins over its environment variable.

Option Environment Meaning
apiKey BLUESTONE_API_KEY Bluestone API key. Required
apiUrl BLUESTONE_API_URL Bluestone API URL. Required
rootCategoryId BLUESTONE_CATEGORY_WEBSHOP_ID The category whose subtree is the webshop. Required
contextMapping BLUESTONE_CONTEXT_MAPPING Context to locales, as JSON in the environment. Required
baseContext BLUESTONE_BASE_CONTEXT The context written onto the record itself. Default en
optionAttributes BLUESTONE_OPTION_ATTRIBUTES Attributes that become product options, most significant first; comma-separated or JSON
optionTitles BLUESTONE_OPTION_TITLES Attribute number to the option title shoppers see, as JSON in the environment
relationTypes BLUESTONE_RELATION_TYPES Relation number or name to accessories, spareParts, complementaryProducts or bundleProducts, as JSON in the environment. Default accessory, spare-part, complementary and bundle
chunkSize BLUESTONE_CHUNK_SIZE Products per write, at most 200. Default 50
chunkDelayMs BLUESTONE_CHUNK_DELAY_MS Pause between writes, in milliseconds. Default 0
pricing BLUESTONE_PRICING_STRATEGY attribute, generated or none. Required, no default
pricing.attribute BLUESTONE_PRICE_ATTRIBUTE The Bluestone attribute that holds the price, for attribute
pricing.onMissing BLUESTONE_PRICE_ON_MISSING reject or unpriced, for attribute. Default reject
pricing.band BLUESTONE_PRICE_BAND Price bounds as a JSON pair, for generated. Default [5, 500]
stock BLUESTONE_STOCK_STRATEGY default or none. Required, no default
stock.quantity BLUESTONE_STOCK_QUANTITY The stock level written where a variant has none, for default. Default 100
BLUESTONE_IMPORT_ON_BOOTSTRAP true starts an import when the server starts. Off by default

Nothing is validated at boot. A Medusa that cannot reach Bluestone still starts and serves; the configuration is checked when an import is asked for, which is the first moment it matters and the only moment there is somebody to tell.

Running an import

Four ways in, one workflow behind them:

  • the Bluestone import widget on the products list page in the admin;
  • POST /admin/bluestone/imports, which returns a run id immediately and does not wait — a full catalog takes minutes, far past any HTTP timeout;
  • BLUESTONE_IMPORT_ON_BOOTSTRAP, described below;
  • importBluestoneCatalogWorkflow, for a scheduled job.

All four go through the same guard, so a button and a starting server can never disagree about when an import may begin.

Progress and results:

GET  /admin/bluestone/imports          list runs
GET  /admin/bluestone/imports/latest   the current or most recent run
GET  /admin/bluestone/imports/:id      one run
POST /admin/bluestone/imports/:id/cancel

Counts are written per chunk rather than at the end, so a run is observable while it happens. Canceling is cooperative: the run stops between pages and finishes as cancelled, which is not the same as failed.

Only one import runs at a time. A second request is refused rather than queued.

Set BLUESTONE_IMPORT_ON_BOOTSTRAP=true and the server starts one when it comes up. Off by default, because filling a catalog is not a decision a server should make on its own — a restart during business hours would rewrite every product in the shop. It exists for the case it is named after: an environment coming up from nothing, where the alternative is somebody remembering to press a button before anyone can browse.

The trigger is a scheduled job that runs once, in the first minute after the server starts, so it only fires where jobs run: a worker or shared instance, not a server. It never awaits the import, so a server that cannot reach the PIM still serves, and a run already in progress is left alone with a line in the log rather than being queued behind itself.

After an import

The plugin writes to Medusa and nothing else. Everything downstream follows the catalog events Medusa emits, and those are forwarded fire-and-forget — a consumer that is slow, restarting, or not yet listening simply misses them. So the tail of a full import is three steps, and they are not optional bookkeeping:

  1. back-fill catalog pages in the CMS;
  2. rebuild any navigation built from the category tree;
  3. reindex search.

In the Evolve reference these are seed:catalog-pages, seed:navigation and reindex in the discover service. Two things worth knowing before trusting a run: a category tree is written in the import's first seconds, so a consumer still booting misses nearly all of it — one measured run back-filled 143 of 147 pages that events should have created. And a storefront that caches its listings keeps serving the old catalog afterwards, with every server-side check reporting success.

Variant options

Bluestone has no notion of an option axis. It has variants, attributes on them, and a definingAttribute flag meant for exactly this — a flag that is unset in every tenant seen so far. So the axes are configured by attribute number (such as paint-finish, not its display name), the flag is the fallback, and optionAttributes is the setting that matters most.

Two things follow from that, both of which cost real imports to learn:

Naming too few axes is expensive. Every variant that cannot be told apart collapses onto the same option tuple, and all but the first are rejected as duplicates — Medusa refuses duplicate option combinations outright. Naming an axis no variant carries costs nothing: it is dropped rather than added empty.

The run tells you what it needed. Where two variants collide, the run records axis_hints: the attributes that differ between exactly those two and are not configured. That turns "412 rejected" into a list of attribute numbers. It reports rather than acts, deliberately — one tenant's hints included sizeSequence, a numeric sort key that moves with the size and would have put ordering numbers in a storefront dropdown.

An axis is titled with the attribute's display name in the base context, and falls back to its number when Bluestone gives it no name. optionTitles names an axis instead, for the case where the display name is still written for the people who maintain the PIM: one tenant has an axis called option whose values are "High Handlebar" and "Low Handlebar". Its default titles that one Handlebar and both size and sizeText Size.

Option titles and values are translated like product content: per locale, from the matching context, and only where they differ from the base. A value is read from the same variant in the other context. A title set in optionTitles is kept in every locale. The specification attributes in metadata.attributes are not translated, because Medusa cannot translate metadata, so their labels stay in the base context.

Each option records the attribute it stands for in metadata.bluestone_attribute. A rerun matches options on it, so an axis whose display name changed is renamed in place rather than reported as changed. Options from an import before this existed, titled with the attribute number or a default title, are recognized, renamed and tagged on the next run.

A product whose variants nothing distinguishes still imports. One variant gets Medusa's own default option; several get an axis keyed on the variant number, which is ugly in a storefront and honest — the source offers nothing better, and dropping real variants to keep the catalog tidy is the worse trade.

Product relations

Bluestone relates products as accessories, spare parts, complementary products and bundle extras. The commercetools sites keep these in product reference attributes. Medusa has no field for them, so the import writes them to metadata.relations, as Medusa ids a storefront can load with the Store API:

{
	"relations": [
		{ "kind": "accessories", "product_id": "prod_01…" },
		{ "kind": "spareParts", "product_id": "prod_01…", "variant_id": "variant_01…" },
		{ "kind": "complementaryProducts", "product_id": "prod_01…" },
		{ "kind": "bundleProducts", "product_id": "prod_01…" }
	]
}
  • kind is the related-products field of the Evolve product contract the relation fills. relationTypes says which Bluestone relation type fills which. A relation of another type is not imported.
  • product_id is the related product. variant_id is there when Bluestone relates a specific variant rather than a product.
  • Entries are in Bluestone's order, without repeats.
  • Relations of a GROUP or SINGLE product go on the Medusa product. Relations a VARIANT has of its own go on that variant. A reader takes a variant's own relations of a kind over its product's.
  • A relation to a product this webshop does not sell, outside the category tree or not imported, is left out and counted in the log. So is a relation to the product itself.
  • Bluestone also lists a relation on the related product, marked reverse. That copy is not imported, for a one-way and a two-way relation alike: a roller is not an accessory of the paint that lists it as one.

The relations are written in a step after every product, because a relation can point at a product a later chunk creates. The step finds related products among everything any import wrote, and writes only relations that changed: each write is a product event, and a rerun should not reindex the catalog. A relation that left Bluestone leaves Medusa on the next run.

Relations are one array rather than an object per kind because Medusa merges objects in metadata into what is stored, and replaces arrays. Only an array lets a removed relation go.

The related products are read like any product, from the Store API with the storefront's publishable key. No route or admin credentials are involved.

Prices and stock

Neither has a default, and that is the point. A PIM holds no prices, so every strategy here is a decision about commercial data, and the one that invents it must be asked for by name. A default — any default — is how generated prices end up in a production catalog with nobody having chosen them. An import refuses to start until both are set.

Pricing Behavior
attribute read from a named Bluestone attribute; onMissing decides reject or unpriced — never a generated fallback
generated derived from the SKU, stable across runs, within band
none no prices are written
Stock Behavior
default quantity at the stock location where none exists
none no inventory levels are written

Generated values are marked on the record — metadata.price_source, metadata.stock_source — and counted on the run, so a catalog can always answer "which of these numbers did we make up". The marker is written positively in both directions (generated or attribute), so a later run supplying a real price overwrites it rather than relying on a key being removed.