mass-operations
npx skills add https://github.com/crystallizeapi/ai --skill mass-operationsCrystallize Mass Operations
A mass operation file is a strict JSON document listing every mutation you want Crystallize to run. Each entry is an operation. The mass-operations runner consumes them as a BulkTask, applying 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.
Verified against
@crystallize/schemav6.13.0 and the behaviour of the mass-operations runner as of 2026-08-18. 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 — seereferences/intents.md§ “Documentation discrepancies”.
Decide the execution path first
| Situation | Path |
|---|---|
| User has the Crystallize CLI, or is doing a production migration | CLI (default — recommended) |
| Browser/app context, CI without CLI, or building tooling around it | Raw API (see references/lifecycle.md) |
Workflow
1. Establish tenant context before writing anything
Do not invent IDs, shape identifiers, component IDs, or languages. Confirm with the user, or pull them:
crystallize mass-operation dump-content-model <tenant> <file>Component IDs that don’t exist on the target shape are the single most common cause of a failed task.
2. Write the file
{ "version": "1.0.0", "operations": [ { "intent": "piece/upsert", "identifier": "rating-system", "name": "Rating System", "components": [ { "id": "name", "name": "Name", "type": "singleLine", "config": { "singleLine": { "required": true } } } ] } ]}Top-level shape (OperationsSchema):
version— must be exactly"1.0.0". This is a behaviour switch, not a label: the runner picks the highest converter registered at a version<=this string. See “Theversionfield” below.operations— ordered array; executed sequentially, in order. An empty array fails the task.
Every operation:
- has an
intent— a literal string in a discriminated union. An unrecognised intent fails validation. - carries the matching GraphQL input’s fields inlined at the top level (not nested under
input). - accepts an optional
_ref(non-empty string) for downstream references.
Required fields per intent are listed in references/intents.md — check them before writing.
For _ref chaining, handlebars, and helpers, read references/templating.md.
3. The version field selects the converter set
The runner resolves each intent to the newest handler whose version is equal to or lower than the
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; seereferences/templating.md."1","1.0","*"— pass schema validation, then crash the whole task at runtime. The version string is fed tosemver.eq/semver.lt, which throwTypeError: Invalid Version: 1on any non-full semver. Verified empirically against semver 7.8.5.
4. Identify items by resourceIdentifier where it is actually implemented
resourceIdentifier is your own stable key for an entity. It makes files re-runnable and portable
across tenants without hardcoding Crystallize IDs. The schema’s checkResourceIdentifierOrId
refinement accepts it in place of itemId/id/topicId:
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”.
5. Order operations as a dependency tree
A thing must exist before anything references it. Execution is strictly sequential, so every dependency is positional — the referencing operation has to come later in the array than the operation that creates its target. This is a universal rule: it applies to content exactly as it applies to the content model. There is no deferred resolution and no second pass.
| Reference | Must already exist |
|---|---|
shape/upsert choice → piece |
the piece/upsert |
item’s shapeIdentifier |
the shape/upsert |
tree.parentId |
the parent folder/create (or {{ defaults.rootItemId }}) |
topicIds on an item |
the topic/* operations |
itemRelations content |
every item it points at |
item/updateComponent/* |
the item, and the component on its shape |
product/variant/*, …/price/modify, …/stock/modify |
the product, price variant, stock location |
item/flow/stage/addItems |
the flow/* defining that stage |
order/subscription-contract customer link |
the customer/upsert |
any {{ myRef.… }} |
the operation carrying that _ref |
Chain with _ref where there is no natural identifier, and with a shared identifier /
resourceIdentifier where there is.
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
content validation disabled (see references/limits.md), so a dangling reference written that way may
not raise at all. Order the
array correctly rather than relying on an error.
6. Validate and run
crystallize mass-operation run <tenant> <file>Validates, requests presigned upload, pushes the file, creates the bulk task with autoStart, and waits
while tailing logs. Add --no-interactive for CI.
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).
Raw API sequence: references/lifecycle.md.
7. Monitor and verify
Lifecycle: pending → started → complete | error. There is no running status — the enum is
exactly {pending, started, complete, error}.
Always check operationLogs, not just task status. A task reaches complete if the loop finishes,
even when individual operations failed — each failure is logged and the runner moves to the next
operation. There is no rollback. Per-operation status is success, partial, or failure, with
statusCode 200, 206, or 500 respectively.
Three ways an operation can leave no log entry at all:
- its converter returned no command (see
references/intents.md§ “Silent skips”), - 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.
Rules that prevent most failures
- An item
upsertwith neitheritemIdnorresourceIdentifieralways CREATES — on every run.externalReferenceis not a lookup key. This is the number-one duplicate-data trap: give everyproduct|document|folder /upsertaresourceIdentifier. Identifier-keyed upserts (piece,shape,customer,customer/group,pricelist,flow,product/variant) are genuinely idempotent;order/upsertandsubscription-contract/upsertare not unless you pass a realid. item/unpublishis 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 APIunpublishItemmutation instead.- Prefer
upsertfor re-runnability — but upserts derive from the create input schema, so they need the full create payload, not a sparse patch.product/upsertrequirestree,vatTypeId, andvariantsexactly asproduct/createdoes. (product/variant/updateis 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 legacy0.0.1behaviour — harmless but unnecessary. Seereferences/templating.md. richText.htmlis 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 oftype) validates and is then silently discarded — the component is created with default settings. componentChoice/componentMultipleChoiceneed ≥2 choices, and each choice needs atype. When a choice carries more than one field, make it apiece/upsertand reference it with{"type": "piece", "config": {"piece": {"identifier": "…"}}}— an inlinecomponentsarray on a choice is not a valid API structure. Structural components (contentChunk,componentChoice,componentMultipleChoice) can never be direct children of each other; put a piece in between.- ID fields accept handlebars.
IdSchemais/^(?:[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. item/paths/set*needs two operations to converge. If anything must be removed, that run only removes; the add is expected as a separate operation. Not atomic, despite the name.- Respect the domain limits — the schema does not. A file that exceeds one validates, uploads, and
fails at execution, or worse succeeds with altered data. One invalid operation rejects the entire
file (all-or-nothing parse), so chunk large jobs. The caps that bite most: 30 topics per
topic/create(counting the wholechildrensubtree), 50 items peritem/flow/stage/addItems, 500 cart items per order, 250topicIdsper item, 75 item relations, and component nesting depth 5. Default string fields cap at 256 chars. - A large class of limits fails silently — no error, no log, wrong data stored. Notably: only the
first component-type key in a content object is used and the rest are discarded;
nullcontent is coerced to empty and wipes the component;propertiesTableis rebuilt from the shape config;numericdecimalPlacesfloors the stored value; choice/selection values not in the config are filtered out; strings are never trimmed. Readreferences/limits.mdbefore 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.mdis exhaustive as of v6.13.0. - Presigned URLs are short-lived. Upload immediately after requesting one.
Reference files
references/intents.md— all 59 intents, required fields, verified_refoutputs, silent skips, limits, whereresourceIdentifierworks, corrected examples, docs bugsreferences/templating.md—_ref, positional refs, handlebars,defaults.*,upload,fetch*references/limits.md— every enforced limit: file-level, batch caps, silent data loss, config traps, string/number bounds, structural rulesreferences/lifecycle.md— raw API upload/run/monitor, CLI commands, troubleshooting
Reference Details
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.
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.
Contents
- Not supported by the runner
- Required fields per intent
- Where
resourceIdentifieractually works - Which upserts are idempotent
- Properties exposed to
_ref - Silent skips
- Limits
- Schema quirks worth knowing
- Corrected examples
- Documentation discrepancies
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.
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.
Required fields per intent
Derived by parsing empty payloads against the real schema. _ref is optional on every intent.
“+ id” means checkResourceIdentifierOrId applies at the schema level: supply itemId/id/topicId
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 |
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.
Where resourceIdentifier actually works
The schema accepts resourceIdentifier far more widely than the runner implements it. Several
converters carry a literal ResourceIdentifier is not implemented yet error.
| Intent | Behaviour when you supply only resourceIdentifier |
|---|---|
product|document|folder /update |
✅ resolved via loadByResourceIdentifier (scoped by language) |
product|document|folder /upsert |
✅ resolved; used to decide create-vs-update |
product|document|folder /create |
✅ stored on the new item, so later ops can find it |
item/delete |
✅ resolved. Supplying both it and itemId throws |
topic/update, topic/upsert |
✅ resolved; mismatch against a supplied topicId throws |
topic/delete |
✅ resolved (default language) |
topic/create |
✅ stored on the new topic |
image/register |
✅ used as a dedupe key — if already registered, the operation is skipped |
item/paths/* |
✅ resolved; throws Item with resourceIdentifier … not found if missing |
item/updateComponent/item |
❌ throws itemId is required … ResourceIdentifier is not implemented yet |
item/publish |
❌ throws Operation is missing itemId. ResourceIdentifier is not implemented yet |
order/update |
❌ throws Operation is missing id. ResourceIdentifier is not implemented yet |
order/upsert |
❌ silently registers a brand-new order |
subscription-contract/update |
❌ throws the same “not implemented” error |
subscription-contract/upsert |
❌ silently creates a new contract |
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).
Which upserts are idempotent
An upsert is only idempotent if the converter has something to look the entity up by.
| Intent | Lookup key | Re-run safe? |
|---|---|---|
piece/upsert |
identifier |
✅ |
shape/upsert |
identifier |
✅ |
customer/upsert |
identifier |
✅ |
customer/group/upsert |
identifier |
✅ |
pricelist/upsert |
identifier |
✅ |
flow/upsert |
identifier |
✅ |
product/variant/upsert |
productId + sku |
✅ |
topic/upsert |
topicId or resourceIdentifier (one is required) |
✅ |
product|document|folder /upsert |
itemId or resourceIdentifier — only if you supply one |
⚠️ |
order/upsert |
id only |
⚠️ |
subscription-contract/upsert |
id only |
⚠️ |
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.
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.
Verified (at version: "1.0.0"):
| Intent | _ref output |
|---|---|
product/upsert, folder/upsert, document/upsert |
{ id: string } — flat, in all branches |
shape/upsert |
{ identifier: string } |
piece/create, piece/upsert |
{ identifier: string } |
customer/create, customer/update, customer/upsert |
{ identifier: string } |
product/variant/create, /update, /upsert |
the full variant DTO minus tenantId/language (id, sku, name, isDefault, priceVariants, stockLocations, components, …); falls back to { sku } if the variant can’t be resolved |
product/variant/delete |
{ sku: string } |
item/publish |
{ language, success: string[], failure: [{ itemId, error }] } |
item/updateComponent/item, item/updateComponent/sku |
a bare ID string, not { id } — reference it as {{ myRef }} |
product/variant/stock/modify |
a bare ID string, same as above |
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
is unreliable and is not reproduced here. Read the output field of a real OperationLog before
depending on one, or key off resourceIdentifier + a fetch* helper instead
(references/templating.md).
Note that item/publish is special-cased by the runner and stores its raw result.
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).
Known cases:
image/registerwhoseresourceIdentifieris already registered — intentional dedupe.item/deletewhere neitheritemIdnor a resolvableresourceIdentifieryields an id.topic/deletewhere theresourceIdentifierdoesn’t resolve.item/paths/setAliases | setShortcuts | setHistorytargeting the tree root node.
If an operation you expected has no log entry, this is why — not a monitoring gap.
Limits
Moved to its own file: references/limits.md — every bound that can break a mass operation, or
silently change what it writes, from an exhaustive source audit.
The headline ones: one invalid operation rejects the entire file; 30 topics per topic/create
counting the whole subtree; 50 items per item/flow/stage/addItems; 500 cart items per order;
250 topicIds per item; 75 item relations; component nesting depth 5; and a large class of
silent truncations and drops that raise no error at all.
Schema quirks worth knowing
- Upsert = create schema.
UpsertProductOperationSchemaextendsCreateProductInputSchema, not the update one. So an upsert needs the full create payload — it is not a sparse patch. Same for document, folder, piece, shape, customer, customer group, pricelist, topic, flow, variant, and subscription contract. piece/updatealso usesCreatePieceInputSchema, so it too requires the full payload — and the converter sends the whole input, so omitted components are dropped, not preserved.order/upsertomitspipelines— present onorder/register, rejected here.product/variant/updateomitsskuandisDefaultfrom the variant input, then.partial()s the rest, re-addingskuas required. It’s the only genuinely patch-style intent.flow/*requiresidentifieron top of the flow input.product/variant/createand/upsertrequireproductId;/updateand/deletekey offsku.versionregex is/^(\d+\.)?(\d+\.)?(\*|\d+)$/— looser than the runner. Only1.0.0is safe;1,1.0and*validate and then crash the task. SeeSKILL.md§ “Theversionfield”.priceVariants[].tierTypeand.tiersare stripped by the schema but re-attached by the runner.enrichPriceVariantTierswalks the parsed result against the raw JSON and puts them back forproduct/create|update|upsertandproduct/variant/create|update|upsert. So tiered pricing works even though the fields don’t appear in the schema. Matching is byidentifier, falling back to array position only when no raw entry has one.variant.topicIdsis silently dropped. Also stripped by the schema, and not re-attached — there is an explicitblocked on @crystallize/schema bumpcomment in both variant converters. Assign variant topics another way.item/updateComponent/itemruns withdisableContentValidation: true. Component content is written without validation, so malformed content lands silently.item/updateComponent/skudoes not disable it.
Corrected examples
Customer → order → component (docs example, valid as published)
{ "version": "1.0.0", "operations": [ { "intent": "customer/upsert", "identifier": "customer-for-order-123", "firstName": "John", "lastName": "Doe", "type": "individual" }, { "intent": "order/register", "customer": { "identifier": "customer-for-order-123", "type": "individual" }, "additionalInformation": "Please deliver between 9am-5pm", "cart": [ { "sku": "SP-RED-001", "name": "Sample Product", "productId": "67e5d2d12d31ee752710a74b", "quantity": 2, "price": { "currency": "USD", "gross": 1000, "net": 800, "tax": { "name": "VAT", "percent": 20 } } } ] }, { "intent": "item/updateComponent/item", "itemId": "632958a35dfc2c90cbbad20d", "language": "en", "component": { "componentId": "title", "singleLine": { "text": "Mass operation updated title" } } } ]}The customer link uses a shared identifier, not _ref. Prefer that where a natural identifier
exists. Note order/register is not idempotent — re-running this file registers a second order.
Product upsert → component update
The published docs version omits tree, vatTypeId and variants, passes richText.html as a
string, and — the part that actually costs you data — keys the upsert on externalReference alone, so
it duplicates the product on every run. Corrected:
{ "version": "1.0.0", "operations": [ { "_ref": "rootProducts", "intent": "product/upsert", "resourceIdentifier": "product-sku-12345", "externalReference": "SKU-12345", "name": "My Product", "language": "en", "shapeIdentifier": "product", "tree": { "parentId": "{{ defaults.rootItemId }}" }, "vatTypeId": "{{ defaults.vatTypeIds.[0] }}", "variants": [{ "sku": "SKU-12345", "name": "My Product", "isDefault": true }] }, { "intent": "item/updateComponent/item", "itemId": "{{ rootProducts.id }}", "language": "en", "component": { "componentId": "description", "richText": { "html": ["<p>Updated description</p>"] } } } ]}resourceIdentifier is what makes the upsert an upsert. {{ rootProducts.id }} is a plain string at
version: "1.0.0" — the {{#if rootProducts.id.id}} dance in the docs is legacy 0.0.1 behaviour.
Piece upsert (docs version is invalid)
The published version omits name on the component. Every component definition needs both id and
name:
{ "version": "1.0.0", "operations": [ { "intent": "piece/upsert", "identifier": "rating-system", "name": "Rating System", "components": [{ "id": "name", "name": "Name", "type": "singleLine", "singleLine": { "required": true } }] } ]}Documentation discrepancies
Points where https://crystallize.com/docs/developer/mass-operations differs from what the runner does. The skill follows the runner.
Examples that fail schema validation
piece/upsertexample omitsnameon the component definition.product/upsertexample omitstree,vatTypeIdandvariants(upserts extend the create schema).richText.htmlis passed as a string; the schema requires an array of strings.
Statements contradicted by the runner
- Task lifecycle is documented as
pending → started → running → complete | error. There is norunningstate; the enum is{pending, started, complete, error}. - The
_refproperty table is largely wrong for v1.0.0 — upserts are normalised to flat{id}/{identifier},item/updateComponent/*andproduct/variant/stock/modifyreturn a bare string, andproduct/variant/*returns a full DTO rather than{sku}. - The “upsert returns
{id}on create and{id:{id}}on update” guidance describesversion: "0.0.1"only, but the docs pair it withversion: "1.0.0"examples. resourceIdentifieris presented as generally available. It is unimplemented onitem/updateComponent/item,item/publish,order/update|upsertandsubscription-contract/update|upsert.item/unpublishis documented as a usable intent; it has no converter and aborts the whole task.item/paths/*Shortcutsare documented as takingpaths; they takeshortcuts/parentIds.item/paths/set*is presented as a set operation; it removes or adds in a single run, never both.versionis presented as a version label. It selects the converter set, and three of the four formats the regex accepts crash the runner.externalReferenceis used as the de-duplication key in the flagshipproduct/upsertexample, which duplicates data on re-run.
Lifecycle: upload, run, monitor
Verified against runner and Core API behaviour as of 2026-08-18.
Contents
CLI path
Recommended for anything production-facing. The CLI validates locally against the same schema the server enforces, so schema problems surface with per-field detail before upload — the server throws that detail away (see Troubleshooting).
crystallize mass-operation dump-content-model <tenant> <file>Generates a starter file containing the tenant’s current shapes and pieces. Use this to ground shape identifiers and component IDs instead of guessing them.
crystallize mass-operation run <tenant> <file>Validates → requests presigned upload → pushes the file → creates the bulk task with autoStart →
waits for completion while tailing logs.
crystallize mass-operation execute-mutations <tenant> <file> [image-mapping-file]Runs client-side GraphQL mutations alongside mass operations, for steps the runner doesn’t cover.
All commands support --no-interactive for CI and reuse stored credentials. --legacy-spec converts
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.
Raw API path
Endpoints and auth
| Endpoint | URL | Auth |
|---|---|---|
| Core API | https://api.crystallize.com/@{tenant} |
Headers below |
| File upload | Returned in generatePresignedUploadRequest.url |
Presigned — no auth needed |
Content-Type: application/jsonX-Crystallize-Access-Token-Id: <your-token-id>X-Crystallize-Access-Token-Secret: <your-token-secret>1. Request a presigned upload
mutation GeneratePresignedUpload($filename: String!, $contentType: String!) { generatePresignedUploadRequest(input: { type: MASS_OPERATIONS, filename: $filename, contentType: $contentType }) { ... on PresignedUploadRequest { url fields { name value } } ... on BasicError { error errorName } }}MASS_OPERATIONS routes to a dedicated bucket, separate from MEDIA and STATIC. Presigned URLs are
short-lived — upload immediately.
2. Upload the file
Multipart form POST. Order matters: all presigned fields first, the file last.
async function uploadToPresignedUrl( presignedUrl: string, fields: Array<{ name: string; value: string }>, fileContent: string,): Promise<void> { const formData = new FormData();
// Add all presigned fields first (order matters!) for (const field of fields) { formData.append(field.name, field.value); }
// Add the file last const blob = new Blob([fileContent], { type: "application/json" }); formData.append("file", blob);
const response = await fetch(presignedUrl, { method: "POST", body: formData }); if (!response.ok) throw new Error(`Upload failed: ${response.status}`);}
// The storage key is inside `fields` — there is no top-level `key`const storageKey = fields.find((f) => f.name === "key")?.value;Equivalent with curl:
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"3. Register the bulk task
mutation CreateMassOperationBulkTask($key: String!, $autoStart: Boolean) { createMassOperationBulkTask(input: { key: $key, autoStart: $autoStart }) { ... on BulkTaskMassOperation { id status } ... on BasicError { error errorName } }}autoStart: true dispatches the runner immediately. Omit it, or set false, to start later.
4. Start it (only if autoStart wasn’t true)
mutation StartMassOperationBulkTask($id: ID!) { startMassOperationBulkTask(id: $id) { ... on BulkTaskMassOperation { id status } ... on BasicError { error errorName } }}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.
Both mutations are flagged EXPERIMENTAL: the full feature set is not yet complete. in the schema.
What the runner does with your file
Worth knowing when a task behaves oddly:
- A task is only picked up while its status is
pending. Anything else is ignored outright. - The file is fetched from S3 and parsed with
OperationsSchema.safeParse. On failure the worker logs each issue to its own logs and throwsInvalid Operation File. enrichPriceVariantTiersre-attachestierType/tiersthat the schema stripped frompriceVariants(seeintents.md).- An empty
operationsarray stops the task aserrorwith causeNo operations retrieved from spec file. - 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.
- 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.
Monitoring
Task status
query GetBulkTaskStatus($id: ID!) { bulkTask(id: $id) { ... on BulkTaskMassOperation { id status info { error errorName stack } } ... on BasicError { error errorName } }}Also available: bulkTasks(filter: { type: massOperation }).
Lifecycle: pending → started → complete | error. The status enum is exactly
{pending, started, complete, error} — there is no running state, despite what the public docs
say.
| Status | Meaning |
|---|---|
pending |
Created but not started. Only a pending task will be picked up |
started |
The operation loop is running |
complete |
The loop finished. Individual operations may still have failed |
error |
The task aborted — check info |
complete means “the runner reached the end of the array”, not “everything worked”.
Per-operation logs
query OperationLogs($id: ID!, $first: Int) { operationLogs(filter: { operationId: $id }, first: $first) { edges { node { status statusCode message input output } } pageInfo { hasNextPage endCursor } }}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.
Each entry stores the original input payload, the command executed, the result, a status
(success / partial / failure), and a statusCode.
| status | statusCode | When |
|---|---|---|
success |
200 |
Command executed without throwing |
partial |
206 |
item/publish only — some items failed, or zero succeeded |
failure |
500 |
The command threw, or the converter failed to build it |
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.
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.
An operation with no log entry was skipped, not lost. Some converters return no command (an already
registered image, an unresolvable delete, a root-node path set) and the runner writes nothing at all
for those. See intents.md § “Silent skips”.
Image uploads appear as their own entries with input.intent = "image/upload", separate from the
operation whose {{ upload ... }} queued them.
Error example
{ "bulkTask": { "id": "abc123", "status": "error", "info": { "error": "Invalid Operation File", "errorName": "Error", "stack": "Error: Invalid Operation File\n at OperationsFetcherService.fetch..." } }}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.
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 |
Browser CORS
The S3 bucket may block cross-origin requests from a browser. Options, in order of preference:
- Use the CLI — recommended for production migrations
- Server-side proxy — route uploads through your backend
- Dev proxy — Vite/webpack dev server
export default defineConfig({ server: { proxy: { "/api/s3-upload": { target: "https://crystallize-mass-operations-production.s3.eu-central-1.amazonaws.com", changeOrigin: true, rewrite: (path) => path.replace(/^\/api\/s3-upload/, ""), }, }, },});Limits
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 type, primitive and value object. These are observed limits, not documented API guarantees — re-check anything load-bearing before depending on it.
Contents
- How limits fail
- File-level
- Batch caps per operation
- Silent data loss
- Config traps
- Component capacity
- String and number bounds
- Structural rules
How limits fail
Three distinct modes. Knowing which one applies tells you whether to expect an error at all.
| Mode | What you see | Blast radius |
|---|---|---|
| schema-validation | task error, Invalid Operation File, nothing runs |
the whole file |
| runtime-throw | that operation logs failure / 500, run continues |
one operation |
| silent | nothing — no error, no log, wrong data stored | however many rows carry it |
The silent class is the dangerous one and it is large. Local validation cannot catch any of it, because the value is legal — it is the behaviour that differs from what you wrote.
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 only1.0.0works, seeSKILL.md` |
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 you one chunk, and validate locally before every upload.
Batch caps per operation
| Intent | Cap | Counting subtlety |
|---|---|---|
topic/create, topic/upsert |
30 topics | counts the whole subtree recursively — 1 root + 29 descendants |
item/flow/stage/addItems |
50 items | raw array length; throws during conversion, so the op is skipped with a 500 |
order/* |
500 cart items | |
order/* |
100 applied promotions, 50 related orders | |
| Any item or variant | 250 topicIds |
product variants share one deduplicated union across all variants |
flow/* |
30 stages, 8 actions per stage | nested onFailure actions are not counted |
customer/*, customer/group/* |
5 parents, 20 addresses, hierarchy depth 5 |
item/publish has a 50-id batch cap that you cannot reach — the converter always sends exactly
one itemId.
Silent data loss
Nothing below raises. Ordered by how many intents carry the risk.
The component-content mapper
All component content passes through a single input mapper, and it is the largest source of silent loss in the whole format.
- Only the first present type key is used.
{componentId: "media", images: [...], videos: [...]}writes one and silently discards the other. Same rule in the config mapper. nullis coerced to empty, wiping the component.{"componentId":"gallery","images":null}stores an empty list rather than erroring. Same fornumeric: null(writes a contentless component),chunksmissing/null (empty chunk list),paragraphsomitted (wipes the whole collection), per-paragraphimagesomitted (wipes that paragraph’s images).- Unrecognised component types are dropped by
filterComponents()via the mapper’sunknownTypebranch — no error, the component simply does not appear. - Stored content is dropped when its type no longer matches the definition, e.g. after a shape change.
propertiesTable is rebuilt from the shape config
If the shape config declares sections, the content constructor ignores your structure entirely
and regenerates it:
- sections are matched by array index only; sections beyond
config.sections.lengthare dropped - property keys not declared in the config are dropped
- your section title is discarded and replaced by the config’s
- property order is forced to the config’s key order
- every configured key is materialised as a valueless row, so omitting a key does not omit the row
- mentioning the component with an empty content object writes a full empty skeleton
- a property
value: nullis silently dropped, storing a bare key
Treat propertiesTable as config-driven: the shape decides the shape of the data, not your payload.
numeric decimal places
decimalPlacesFLOORS the stored number. It is not a display setting — the value is truncated on write, and it does not round.decimalPlaces: 0is 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 aparseInt(String(...))branch that destroys small-magnitude numbers.- An empty-string
unitis silently dropped in content, but throws in shape config.
Choice and selection filter to configured options
- A
componentChoiceselection that is not one of the configured choices is silently discarded. componentMultipleChoiceentries not matching a configured choice are filtered out.selectionkeys not present in the shape config’s options are filtered out.itemRelations: falsy entries initemIds/skusare filtered; an all-falsy array becomes empty.
In every case the write “succeeds” with fewer values than you sent. If you are seeding a shape and its
content in one file, an ordering mistake (step 5 in SKILL.md) shows up here as quiet data loss
rather than an error.
Strings, dates, identifiers
ValidatedStringnever trims. Leading/trailing whitespace is stored and counts toward every length cap.ValidatedStringcoerces viatoString()— a number or boolean becomes its string form rather than being rejected. An explicitnullraises a rawTypeError.- Datetime is re-parsed by
new Date()and re-emitted as UTC ISO. Offsets are normalised away. - Topic
pathIdentifieris sliced to 64 chars before slugification, so a long topic name silently produces a different stored path. KeyValuePaircoerces an empty-string value tonull.- 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. - 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/uploadlog entries with status 500 — the operation that queued the image still reports success.
Config traps
Cases where two components behave oppositely from the same JSON.
max: 0on afilescomponent silently means 512. Amaxof0is treated as “unset” for files and falls back to the ceiling. The samemax: 0onimagesorvideosrejects every entry. Identical JSON, opposite outcome.required: falsedisables the configuredminentirely.{min: 3, required: false}accepts zero items. Omittingrequiredis stricter than setting it tofalse.richTextmin/maxof 0 is a silent no-op — the opposite ofsingleLine.item/updateComponent/itemskips ALL component content validation: min/max counts, file size, MIME type andrequiredare simply not executed. The identical payload viaitem/updateComponent/sku,product/upsert,document/upsertis validated and throws. Only reference existence and type are still checked. Enforcement depends on which intent you chose.- Order line items and payment objects are
.strict()— they reject any unknown key, while nearly every other schema silently strips them. The one place a typo errors instead of vanishing.
Component capacity
Ceilings on the max you may configure, and therefore on content.
| Component | Cap |
|---|---|
itemRelations |
75 (quick-select folders: 100) |
images, files, videos, gridRelations |
512 |
colors |
100 |
numeric decimalPlaces |
1–64 (0 is rejected) |
Configurable min/max bounds |
max 1048576, min 256 |
| Component nesting depth | 5, following piece expansion |
String and number bounds
| Field | Bound |
|---|---|
| Default for every unqualified string | min 1, max 256 |
| Shape / piece / flow identifier | 2–64, charset [A-Za-z0-9] plus ; : + @ ( and space |
resourceIdentifier |
1–256, charset [A-Za-z0-9] plus . - _ / @ |
externalReference |
1–256 |
| Item / catalogue item name | 512 |
| Product variant name | 1024 |
| SKU | 512 |
| Variant attribute key / value | 128 / 2048 |
| Component name / description | 256 / 1024 |
singleLine text, meta value, tree path |
1048576 |
| Meta key | 256 |
| Image alt text / URL | 1024 / 10240 |
Order additionalInformation |
10240 |
| Language code | 2–20 |
| Currency code | 10 |
| Topic display colour | 4–9, `/^#([A-Fa-f0-9]{3} |
Uploaded filename (basename of the {{upload}} URL) |
3–512 |
Every id field |
exactly 24 lowercase hex, or {{ ... }} |
| Price | −1e9 … 1e9 |
| Percent (order tax & discount) | −1000 … 1000 |
| Order cart item quantity | 0 … 1e6 |
| Item tree position | 1 … 100000 |
| Latitude / longitude | ±90 / ±180 |
| Focal point | x, y each 0 … 1 |
product/variant/price/modify price |
minimum 1 — you cannot set 0 this way |
product/variant/stock/modify quantity |
positive integer |
| Stock (elsewhere) | ValidatedInteger, fractional input silently truncated |
Structural rules
Minimums and shape rules, not maximums.
componentChoicerequires ≥ 2 choices;componentMultipleChoice≥ 1;contentChunk≥ 1 component.contentChunkwithrepeatable: falseaccepts exactly 1 chunk.selectionneeds ≥ 1 option, keys unique.product/*requires a non-emptyvariantsarray;pricelist/*a non-emptypriceVariants;item/flow/stage/addItemsa non-emptyitems.- Component identifiers must be unique within a component list.
minmust not exceedmaxin any component config.- Structural components cannot nest —
contentChunk,componentChoice,componentMultipleChoicecan never be direct children of each other. Put a piece in between. variantComponentsare allowed only on product shapes.- Handlebars enrichment substitutes into string values only — a
{{ ... }}inside a number or boolean is never resolved. - At least one of
resourceIdentifier/id/itemId/topicIdis required wherecheckResourceIdentifierOrIdapplies.
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.
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.
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
"<parent-id>" 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.
Contents
- How enrichment works
_refchaining- Positional references
- The upsert return-shape trap (legacy only)
- Default context variables
- Uploading images
- Fetch helpers
valueOf
How enrichment works
MassOperationEnricherService.enrich 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
_refkey itself is skipped, so a_refname 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.
_ref chaining
Tag an operation with _ref (a non-empty string), then reference its output by that name.
[ { "_ref": "rooms", "intent": "folder/create", "name": "My Rooms", "language": "en", "shapeIdentifier": "folder", "tree": { "parentId": "6902a7a78f7d45cf23343fab" } }, { "intent": "folder/create", "name": "My Kitchen", "language": "en", "shapeIdentifier": "folder", "tree": { "parentId": "{{ rooms.id }}" } }]“My Kitchen” is created inside “My Rooms”.
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”).
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.
{{ 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.
Prefer named _ref. Positional references are brittle against exactly the failure mode that is
hardest to notice.
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,
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 }.
So at 1.0.0, this is correct and sufficient:
{{ rootProducts.id }}The defensive form still works — id.id is undefined on a flat {id}, so the else branch fires —
but it is noise:
{{#if rootProducts.id.id}}{{ rootProducts.id.id }}{{else}}{{ rootProducts.id }}{{/if}}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:
| Variable | Source |
|---|---|
{{ defaults.taskId }} |
the bulk task id |
{{ defaults.tenantId }} |
tenant ObjectId |
{{ 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) |
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.
defaults.vatTypeIds has no guaranteed ordering, so [0] means “some vat type”, not “the default
one” — name the vat type explicitly if it matters.
Uploading images
{{ upload "https://my-image.com/image.jpg" }}{{ 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.
{ "intent": "image/register", "key": "{{ upload \"https://my-image.com/image.jpg\" \"hero-image-1\" }}", "resourceIdentifier": "hero-image-1"}Mechanics worth knowing:
uploaddoes 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 ap-queuewith concurrency 10, and the runner awaitsqueue.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
uploadinline in several operations is safe. - The filename is
path.basename(sourceUrl), so query strings and redirect URLs can produce odd names. - Image uploads are logged as their own entries with
"intent": "image/upload"and statusCode 200/500, separate from the operation that calledupload. A failed image does not fail its operation. - You can call
uploadinline anywhere a key is expected in another operation.
The file is strict JSON, so inner double quotes must be escaped as \" — or use single quotes, as the
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:
| Helper | Arguments |
|---|---|
fetchItemById |
itemId, language, version |
fetchItemByResourceIdentifier |
resourceIdentifier, language |
fetchOrderById |
orderId |
fetchSubscriptionContractById |
id |
fetchCustomerByIdentifier |
identifier |
fetchCustomerGroupByIdentifier |
identifier |
fetchPriceListByIdentifier |
identifier |
fetchProductVariantBySku |
sku, language |
fetchTopicByResourceIdentifier |
resourceIdentifier, language |
fetchImageByResourceIdentifier |
resourceIdentifier, language |
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.
{{ valueOf (fetchItemById '626af86d6f9c909e39ecb8a6' 'en' 'published') 'name' }}{{ valueOf (fetchOrderById '626af86d6f9c909e39ecb8a6') 'reference' }}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.
Caching and staleness
Six helpers read through a per-task cache (MassOperationResourceCacheService): 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:
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.
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.
fetchOrderById and fetchSubscriptionContractById load by raw id and then verify tenant ownership,
returning null for a foreign-tenant record — indistinguishable from “not found”.
The *ByResourceIdentifier / *ByIdentifier variants pair naturally with writing resourceIdentifier
on your create/upsert operations: you set a stable key on the way in, then look entities up by that key
later, without ever hardcoding a Crystallize ID. This is the main workaround for intents whose _ref
output is unhelpful, and for the intents where the runner ignores resourceIdentifier (see
intents.md).
valueOf
Reads a property off a helper result. It splits on ., so dotted paths work:
{{ valueOf (fetchItemById '626af86d…' 'en' 'draft') 'name' }}{{ valueOf (fetchItemById '626af86d…' 'en' 'draft') 'components.description.content.plainText' }}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.
Crystallize AI