Generated banner for Rp Quick Shopify
Generated logo for Rp Quick Shopify

Rp Quick Shopify

WixOfficialPublished by Wix

  • Skill
  • Workflow
  • Automation and integrations
  • MIT
  • Low risk
  • Has scripts

From the author

Runs the bounded deterministic public Shopify catalog quick-import adapter after a qualifying source probe.
— the description by Wix

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

The rp-quick-shopify skill provides a structured, deterministic workflow for importing public Shopify catalog data into a target system. It utilizes the store's tokenless Storefront GraphQL API to extract products, collections, variants, and media without requiring credentials or browser-based scraping. The process is managed through a series of CLI commands that handle preflight checks, planning, extraction, and import, ensuring data integrity through crosswalks and inventory reconciliation. It includes robust error handling, retry logic for network issues, and a verification phase to address partial imports.

What it does

  • Extracts public Shopify catalog data including products, collections, and variants.
  • Uses the tokenless Storefront GraphQL API for efficient, paginated data retrieval.
  • Validates import plans against source coverage and category capacity constraints.
  • Performs inventory reconciliation for imported products.
  • Supports dry-run operations to preview reports without modifying live data.
  • Provides a reverification workflow to resolve partial import discrepancies.

When to use it

  • When the system router selects managementImportMode=quick for a Shopify source.
  • When a high-confidence source probe identifies a Shopify store.
  • When performing a migration that requires a deterministic, tokenless catalog import.
  • When you need to verify and reconcile inventory data alongside product imports.

Advantages

  • Deterministic and repeatable import process.
  • No requirement for API tokens, credentials, or browser-based scraping.
  • Efficient data retrieval using paginated GraphQL queries.
  • Built-in validation for category limits and source coverage.
  • Supports partial import recovery and reverification.

Usage examples

“Run the preflight check for the Shopify import in the current project directory.”
The adapter initiates the preflight process, reads storefront localization, and prepares the environment for the import plan.
“Execute the import process for the Shopify catalog.”
The adapter performs the import, validates the payload, reconciles inventory, and generates a completion report.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/wix/rp-quick-shopify-sege/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip rp-quick-shopify.zip -d .agents/skills

Documentation by Wix

rp-quick-shopify

Use only when the router selected this resource for managementImportMode=quick and the source probe identified shopify with high confidence. This adapter imports only the catalog entity graph declared in quick-mode.json: public products, collections, product tags, product media, variants, and collection membership. It reads them through the store's tokenless Storefront GraphQL API (POST /api/<storefrontApiVersion>/graphql.json, no access token) at the API version pinned in quick-mode.json. One paginated product query carries each product's variants, images, and collection memberships, so a store with thousands of collections still needs only a handful of requests. It never uses credentials, a Storefront or Admin access token, browser extraction, page scraping, app endpoints, or an LLM mapping step.

Run the adapter from the repository root:

node skills/wix-replatform/resources/rp-quick-shopify/scripts/quick-mode.js preflight <projectDir>
node skills/wix-replatform/resources/rp-quick-shopify/scripts/quick-mode.js plan <projectDir>
node skills/wix-replatform/resources/rp-quick-shopify/scripts/quick-mode.js extract <projectDir>
node skills/wix-replatform/resources/rp-quick-shopify/scripts/quick-mode.js import <projectDir> [--dry-run]
node skills/wix-replatform/resources/rp-quick-shopify/scripts/quick-mode.js request-reverify <projectDir>
node skills/wix-replatform/resources/rp-quick-shopify/scripts/quick-mode.js reverify <projectDir>

plan also writes discovery/source-coverage.json: one adapter-excluded row per excluded[] entry, each dispositioned as the adapter's declared exclusion and carrying the committed excludedCapabilities translation from quick-mode.json (a target capability, or source-scope.<name> when Wix has none). The plan refuses if that translation is missing, extra, duplicated or outside both namespaces, and stamps sourceCoverageContract: 1 in quick-mode/plan.json. import refuses to write its completion summary when the plan is missing or unstamped, or its coverage file is missing or does not reconcile (a declared exclusion must name an excluded[] entry of the committed contract, with its committed capability); re-run plan to fix any of these. The summary carries sourceCoverage[] and sourceCoverageStatus.

preflight also reads the storefront's localization (country, its currency, and language) once, best effort, and plan writes it to discovery/source-locale.json (adapter quick-shopify). The storefront API has no time zone, so that is an explicit unknown; a failed read leaves every regional value unknown and never blocks the plan.

Setup does block until all four locale values are known. For any value the storefront does not expose, ask the merchant and edit that field in discovery/source-locale.json to {"state":"supplied","value":"<validated value>","evidence":"merchant input","suppliedBy":"<operator>"}. Then re-run plan and setup. The shared writer preserves a valid supplied value when the new public capture is still unknown; never substitute a Wix default or guess.

preflight pages through the product and collection connections (completing any product whose variants, images, or collections exceed the first nested page) and stores the capture that extract reuses, before plan writes the authoritative quick plan and execution manifest. HTTP 429, 5xx, THROTTLED, dropped connections, and hung calls are retried: one first call plus at most six retries per request, each call time-limited, and every wait (including a server-requested Retry-After, as seconds or an HTTP date) capped at 30 seconds. Any other error is a real answer and blocks at once. When retries run out, quick-mode/preflight.json records a blocked check with the connection, page, status, error codes and attempts. Normal setup discovery/execution and execution approval remain mandatory before import. An inaccessible (e.g. password-protected), malformed, or changed endpoint is a durable blocker: do not substitute a browser, credentials, or a different endpoint; create a durable choice between stopping and standard mode instead. The standard-mode choice must carry this effect so the CLI applies the user's selection before it resumes:

{"kind":"decision","key":"managementImportMode","choices":{"switch_to_standard":"standard"}}

Records are normalized to the storefront-JSON row shape the import consumes (numeric legacy ids, body_html, variants[].option1..3, variants[].available, images[].src), so crosswalks keyed by Shopify id stay valid across adapter versions. A dry run writes only execution/dry-run-completion-report.json, execution/dry-run-final-report.md, execution/dry-run-inventory-report.json and execution/dry-run-category-capacity.json; it never creates or changes a live artifact (the live completion, final and inventory reports, the crosswalk, the limit-recovery log, the capacity file, the import-started marker or a re-check file), so the router still sends the project to the live import afterwards.

Extraction persists the raw records and membership edges. Only collections that contain at least one extracted product become categories (manifest.entities.collection.excludedEmptyCount counts the rest): Wix Stores allows at most 1,000 categories per site, including the built-in All Products category. Import checks that limit against the destination's current categories before its first write and refuses, naming both counts, rather than creating categories up to the cap and stranding the remaining memberships. The check counts only categories still to be created (ones already crosswalked or already on the destination by slug are reused) plus the built-in All Products category, and records quick-mode/category-capacity.json. When it is blocked, nothing was written and the router returns quick_mode_category_capacity_blocked: create a durable choice between free_destination_categories_and_retry (the operator frees categories on the destination, then the import is re-run and rewrites the file as passed) and stop. A dry run cannot see the destination, so it applies the same rule with the built-in category as the floor and reports the real headroom as unknown. Collections left out for having no imported product are listed by name in the completion report (excludedCollections) and the final report, so the operator can recreate any the merchant still needs. Import uses the shared Stores V3 runtime, durable crosswalks, and checkpoints. The shared writer validates the product option and variant payload; a record that cannot be represented safely is skipped and reported rather than collapsed to a different product shape.

Quick mode maps each public Shopify variant's explicit available boolean to untracked inStock; a missing or non-boolean value is a reported gap, never implicit availability. Products created with the standalone endpoint are followed by shared inventory reconciliation. Match returned variants by their option/choice identities, not response order or SKU. On resume, keep the product crosswalk and reconcile stock for existing products too.

A product that exists on the destination with uniquely correlated variants but fails a field read-back (for example a description fragment Wix dropped) is still reported as skipped and listed in the summary's writtenUnverified.product (sourceId, targetId, name, failedFields), but its stock is reconciled and its category memberships are written: both depend only on product and variant identity, and a for-sale item must not be left with the wrong stock because of an unrelated field. The run stays partial and the final report lists each such product under "Products to check". A product whose variants do not correlate one-to-one gets no stock and no category link, and is reported as a gap.

After the operator fixes or accepts those differences, request-reverify records a request (quick-mode/reverify-request.json; refused when the live completion report lists none) and the router returns quick_mode_reverification_required for a finished partial import. reverify (refuses --dry-run) re-reads each listed product once with the import's read-back comparison and only ever reads from Wix. It first stages its complete result in quick-mode/reverification-staged.json, then publishes it by replay — completion report, final report, then the receipt quick-mode/reverification.json — so a crash at any step is finished by the next routing step without re-reading Wix. Once the receipt matches the request the same request never routes again; a later fix needs a new request.

The post-create read retries a transient 404 briefly (the product can be unreadable for a moment after its create); a failed create is never retried inline, because resume reuses it by slug.

state/inventory-report.json accounts for all extracted variants (including gaps) and requires default-location Inventory and Catalog read-back before completion. An inventory failure leaves the quick completion report partial even when all product IDs exist. An empty inventory census is no-data with complete:false; an extraction count that differs from its manifest blocks before writes. Dry runs write a separate simulated inventory report and cannot complete or overwrite a live inventory checkpoint.

Setup scope: the quick setup router verifies the normal setup receipt (including required notification protection and Stores provisioning). Its deterministic setup plan does not run an inventory write canary. Do not report inventory create/update grants as preverified from that receipt; a denied inventory write remains partial in the persisted stock report.

Files

scripts/quick-mode.js

#!/usr/bin/env node
'use strict';

// Public-only Shopify quick-mode capture. It intentionally has no credential, browser, or
// endpoint-fallback path. All product-specific writes go through rp-target-wix.
const capabilities = require('../../../lib/execution-capability');
const siteRegional = require('../../../lib/site-regional');
const wixAuth = require('../../../lib/wix-auth');
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const ADAPTER_DIR = path.resolve(__dirname, '..');
const CONTRACT_PATH = path.join(ADAPTER_DIR, 'quick-mode.json');
const CONTRACT = require(CONTRACT_PATH);
const sourceCoverage = require(path.join(ADAPTER_DIR, '..', '..', 'lib', 'source-coverage.js'));
const sourceRegional = require(path.join(ADAPTER_DIR, '..', '..', 'lib', 'source-regional.js'));
const { buildProduct, toWixSlug } = require(path.join(ADAPTER_DIR, '..', 'rp-target-wix', 'lib', 'wix-build.js'));
const inventory = require(path.join(ADAPTER_DIR, '..', 'rp-target-wix', 'lib', 'stores-inventory.js'));
const recovery = require(path.join(ADAPTER_DIR, '..', 'rp-quick-runtime', 'lib', 'limit-recovery.js'));
const importProgress = require(path.join(ADAPTER_DIR, '..', 'rp-quick-runtime', 'lib', 'import-progress.js'));

const verification = require('../../rp-target-wix/lib/write-verification');
const verificationState = require('../../../lib/write-verification-state');
const catalogBaseline = require('../../../lib/catalog-baseline');
const PRODUCT_LIMIT = 250;
const MAX_DESCRIPTION_LENGTH = 16000;
// Requests stay serial and paced; a full catalog is a handful of pages, so a short delay is
// enough. Retries back off exponentially (or by retry-after) up to the cap below.
const SOURCE_REQUEST_DELAY_MS = 300;
// One first call plus at most six retries per request; every wait and every call is capped.
const SOURCE_MAX_RETRIES = 6;
const SOURCE_MAX_BACKOFF_MS = 30000;
const SOURCE_CALL_TIMEOUT_MS = 30000;
const hash = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
const readJson = async (file) => JSON.parse(await fs.readFile(file, 'utf8'));
async function writeJson(file, value) { await fs.mkdir(path.dirname(file), { recursive: true }); await fs.writeFile(file, `${JSON.stringify(value, null, 2)}\n`); }
async function writeNdjson(file, rows) { await fs.mkdir(path.dirname(file), { recursive: true }); await fs.writeFile(file, rows.map((row) => JSON.stringify(row)).join('\n') + (rows.length ? '\n' : '')); }
async function readNdjson(file) { const raw = await fs.readFile(file, 'utf8'); return raw.split(/\r?\n/).filter(Boolean).map(JSON.parse); }
function sourceOrigin(value) { const url = new URL(value); if (!['https:', 'http:'].includes(url.protocol)) throw new Error('sourceUrl must use http or https'); return url.origin; }
function text(value) { return String(value == null ? '' : value).trim(); }
function validPrice(value) { const amount = Number(value); return Number.isFinite(amount) && amount >= 0; }
function file(projectDir, name) { return path.join(projectDir, 'data', 'source-extract', `${name}.ndjson`); }
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));

async function context(projectDir) {
  const decisions = await readJson(path.join(projectDir, 'orchestration', 'decisions.json'));
  const sourceUrl = decisions.sourceUrl && decisions.sourceUrl.value;
  const platform = decisions.sourcePlatform && decisions.sourcePlatform.value;
  if (!sourceUrl) throw new Error('quick mode requires orchestration.decisions.sourceUrl');
  if (platform !== 'shopify') throw new Error(`quick Shopify adapter requires sourcePlatform=shopify (got ${platform || 'missing'})`);
  return { sourceUrl: sourceOrigin(sourceUrl) };
}

// Tokenless Storefront GraphQL. A single paginated product query carries each product's
// variants, images, and collection memberships, so membership costs no per-collection
// requests. Records are normalized to the storefront-JSON row shape the import consumes.
const STOREFRONT_API_VERSION = CONTRACT.storefrontApiVersion;
const PRODUCT_CONNECTION = 'variants(first: 100) { pageInfo { hasNextPage endCursor } nodes { id title sku availableForSale price { amount } compareAtPrice { amount } selectedOptions { name value } } }';
const IMAGE_CONNECTION = 'images(first: 20) { pageInfo { hasNextPage endCursor } nodes { url altText } }';
const MEMBERSHIP_CONNECTION = 'collections(first: 50) { pageInfo { hasNextPage endCursor } nodes { id } }';
const PRODUCTS_QUERY = `query QuickProducts($first: Int!, $after: String) { products(first: $first, after: $after) { pageInfo { hasNextPage endCursor } nodes { id handle title vendor productType tags descriptionHtml publishedAt options { name optionValues { name } } ${IMAGE_CONNECTION} ${MEMBERSHIP_CONNECTION} ${PRODUCT_CONNECTION} } } }`;
const COLLECTIONS_QUERY = 'query QuickCollections($first: Int!, $after: String) { collections(first: $first, after: $after) { pageInfo { hasNextPage endCursor } nodes { id handle title descriptionHtml updatedAt } } }';
// The storefront's own currency, language and country (the store's default market). It carries no time zone.
const LOCALIZATION_QUERY = 'query QuickLocalization { localization { country { isoCode currency { isoCode } } language { isoCode } } }';
// A product whose nested list exceeds the first page is completed through its own cursor.
const NESTED_QUERIES = {
  variants: 'query QuickVariants($id: ID!, $after: String) { product(id: $id) { variants(first: 250, after: $after) { pageInfo { hasNextPage endCursor } nodes { id title sku availableForSale price { amount } compareAtPrice { amount } selectedOptions { name value } } } } }',
  images: 'query QuickImages($id: ID!, $after: String) { product(id: $id) { images(first: 250, after: $after) { pageInfo { hasNextPage endCursor } nodes { url altText } } } }',
  collections: 'query QuickMemberships($id: ID!, $after: String) { product(id: $id) { collections(first: 250, after: $after) { pageInfo { hasNextPage endCursor } nodes { id } } } }',
};
const RETRYABLE_GRAPHQL_CODES = new Set(['THROTTLED', 'INTERNAL_SERVER_ERROR']);
// Everything time- or network-dependent goes through `io` so tests can drive the retry contract
// with a fake clock and a scripted fetch instead of real waits.
const defaultIo = { fetch: (...args) => fetch(...args), sleep, now: () => Date.now(), timeoutMs: SOURCE_CALL_TIMEOUT_MS };

function storefrontEndpoint(origin) { return new URL(`/api/${STOREFRONT_API_VERSION}/graphql.json`, `${origin}/`).toString(); }
function legacyId(gid) { const match = /\/(\d+)$/.exec(text(gid)); return match ? match[1] : text(gid); }
function amount(money) { return money && money.amount != null ? String(money.amount) : null; }
// Wait before retry n (1..6): a server-requested wait (delta-seconds or HTTP-date) is honoured but
// clamped to [0, 30 s]; otherwise min(30 s, 1 s * 2^n). An unparseable header falls back to the latter.
function retryWaitMs(retry, retryAfterHeader, nowMs) {
  const exponential = Math.min(SOURCE_MAX_BACKOFF_MS, 1000 * 2 ** retry);
  const raw = retryAfterHeader == null ? '' : String(retryAfterHeader).trim();
  if (!raw) return exponential;
  let requested = null;
  if (/^\d+(\.\d+)?$/.test(raw)) requested = Number(raw) * 1000;
  else { const date = Date.parse(raw); if (Number.isFinite(date)) requested = date - nowMs; }
  return requested === null ? exponential : Math.min(SOURCE_MAX_BACKOFF_MS, Math.max(0, requested));
}
// Transient throttles, 5xx responses, dropped connections and hung calls are retried; anything else is data.
async function storefrontQuery(endpoint, query, variables, io = defaultIo) {
  let last = null;
  for (let attempt = 0; attempt <= SOURCE_MAX_RETRIES; attempt += 1) {
    if (attempt) await io.sleep(retryWaitMs(attempt, last.retryAfter, io.now()));
    let response;
    try { response = await io.fetch(endpoint, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(io.timeoutMs) }); } catch (error) { last = { ok: false, status: 0, responseShape: error && error.name === 'TimeoutError' ? 'timeout' : 'network_error', errorCodes: [], error: error && error.message, retryAfter: null, attempts: attempt + 1 }; continue; }
    let bodyText = ''; try { bodyText = await response.text(); } catch (error) { last = { ok: false, status: 0, responseShape: error && error.name === 'TimeoutError' ? 'timeout' : 'network_error', errorCodes: [], error: error && error.message, retryAfter: null, attempts: attempt + 1 }; continue; }
    let body = null; try { body = bodyText ? JSON.parse(bodyText) : null; } catch { /* shape checked below */ }
    const errorCodes = (body && Array.isArray(body.errors) ? body.errors : []).map((error) => error && error.extensions && error.extensions.code).filter(Boolean);
    const clean = response.ok && body && body.data && !errorCodes.length && !(body.errors || []).length;
    last = { ok: Boolean(clean), status: response.status, body, errorCodes, responseShape: body && body.data ? 'object.data' : typeof body, retryAfter: response.headers.get('retry-after'), attempts: attempt + 1 };
    if (last.ok) return last;
    const retryable = response.status === 429 || response.status >= 500 || errorCodes.some((code) => RETRYABLE_GRAPHQL_CODES.has(code));
    if (!retryable) break;
  }
  return { ...last, ok: false };
}
function blockedFetch(endpoint, key, page, result, records, pages) { return { ok: false, status: result.status, url: endpoint, responseShape: result.ok ? 'missing_connection' : result.responseShape, errorCodes: result.errorCodes || [], attempts: result.attempts, connection: key, page, records, pages }; }
async function fetchConnection(endpoint, query, key, first, io, variables = {}) {
  const records = []; const pages = []; let after = null;
  for (let page = 1; ; page += 1) {
    const result = await storefrontQuery(endpoint, query, { ...variables, first, after }, io);
    const connection = result.ok ? result.body.data[key] : null;
    if (!connection || !Array.isArray(connection.nodes)) return blockedFetch(endpoint, key, page, result, records, pages);
    records.push(...connection.nodes); pages.push({ page, count: connection.nodes.length, cost: result.body.extensions && result.body.extensions.cost ? result.body.extensions.cost.requestedQueryCost : null });
    if (!connection.pageInfo || !connection.pageInfo.hasNextPage) break;
    after = connection.pageInfo.endCursor;
    if (page >= 10000) return { ok: false, status: 508, url: endpoint, responseShape: 'pagination_limit', errorCodes: [], attempts: 0, connection: key, page, records, pages };
    await io.sleep(SOURCE_REQUEST_DELAY_MS);
  }
  return { ok: true, url: endpoint, records, pages };
}
async function completeNested(endpoint, node, field, io) {
  const first = node[field];
  if (!first || !first.pageInfo || !first.pageInfo.hasNextPage) return { ok: true, nodes: first ? first.nodes : [] };
  const nodes = [...first.nodes]; let after = first.pageInfo.endCursor;
  while (after) {
    await io.sleep(SOURCE_REQUEST_DELAY_MS);
    const result = await storefrontQuery(endpoint, NESTED_QUERIES[field], { id: node.id, after }, io);
    const connection = result.ok && result.body.data.product ? result.body.data.product[field] : null;
    if (!connection) return { ok: false, status: result.status, responseShape: result.ok ? 'missing_connection' : result.responseShape, errorCodes: result.errorCodes || [], attempts: result.attempts };
    nodes.push(...connection.nodes); after = connection.pageInfo.hasNextPage ? connection.pageInfo.endCursor : null;
  }
  return { ok: true, nodes };
}
function toProductRow(node, variants, images) {
  const options = (node.options || []).map((option, index) => ({ name: option.name, position: index + 1, values: (option.optionValues || []).map((value) => value.name) }));
  return {
    id: legacyId(node.id), handle: node.handle, title: node.title, body_html: node.descriptionHtml || '', vendor: node.vendor, product_type: node.productType,
    tags: node.tags || [], published_at: node.publishedAt || null,
    images: images.map((image) => ({ src: image.url, alt: image.altText || null })),
    options,
    variants: variants.map((variant) => {
      const row = { id: legacyId(variant.id), title: variant.title, sku: variant.sku, price: amount(variant.price), compare_at_price: amount(variant.compareAtPrice), available: variant.availableForSale };
      options.forEach((option, index) => { const selected = (variant.selectedOptions || []).find((entry) => entry.name === option.name); row[`option${index + 1}`] = selected ? selected.value : null; });
      return row;
    }),
  };
}
// Best effort: a failed or empty read leaves the regional values unknown, it never blocks the capture.
async function readLocalization(endpoint, io) {
  const result = await storefrontQuery(endpoint, LOCALIZATION_QUERY, {}, io);
  const localization = result.ok ? result.body.data.localization : null;
  if (!localization) return { ok: false, errorCodes: result.errorCodes || [] };
  const country = localization.country || {};
  return { ok: true, countryCode: text(country.isoCode) || null, currencyCode: text(country.currency && country.currency.isoCode) || null, languageCode: text(localization.language && localization.language.isoCode) || null };
}
async function captureSource(origin, io = defaultIo) {
  const endpoint = storefrontEndpoint(origin);
  const productNodes = await fetchConnection(endpoint, PRODUCTS_QUERY, 'products', PRODUCT_LIMIT, io);
  const memberships = []; const membershipChecks = [];
  const products = { ...productNodes, records: [] };
  // A blocked product read ends the capture: nothing downstream can be trusted, and the retry budget is spent.
  if (!productNodes.ok) return { products, collections: { ok: false, status: 0, url: endpoint, responseShape: 'not_attempted', errorCodes: [], attempts: 0, records: [], pages: [] }, memberships, membershipChecks };
  const collectionNodes = await fetchConnection(endpoint, COLLECTIONS_QUERY, 'collections', PRODUCT_LIMIT, io);
  const localization = await readLocalization(endpoint, io);
  for (const node of productNodes.records) {
    const [variants, images, collections] = [await completeNested(endpoint, node, 'variants', io), await completeNested(endpoint, node, 'images', io), await completeNested(endpoint, node, 'collections', io)];
    const incomplete = [['variants', variants], ['images', images], ['collections', collections]].filter(([, result]) => !result.ok);
    if (incomplete.length) { const [field, result] = incomplete[0]; membershipChecks.push({ productId: legacyId(node.id), ok: false, status: result.status, responseShape: `incomplete_${incomplete.map(([name]) => name).join('_')}`, errorCodes: result.errorCodes || [], attempts: result.attempts, connection: field }); continue; }
    products.records.push(toProductRow(node, variants.nodes, images.nodes));
    for (const collection of collections.nodes) memberships.push({ collectionId: legacyId(collection.id), productId: legacyId(node.id) });
  }
  const collections = { ...collectionNodes, records: collectionNodes.records.map((node) => ({ id: legacyId(node.id), handle: node.handle, title: node.title, body_html: node.descriptionHtml || '', updated_at: node.updatedAt || null })) };
  return { products, collections, memberships, membershipChecks, localization };
}
// A blocked read is recorded with everything an operator needs: the connection, the page, the
// last status, the GraphQL error codes and how many calls were made.
function blockedDetail(source) { return { errorCodes: source.errorCodes || [], attempts: source.attempts == null ? 0 : source.attempts, connection: source.connection || null, page: source.page == null ? null : source.page }; }
async function preflight(projectDir, io = defaultIo) {
  const { sourceUrl } = await context(projectDir); const data = await captureSource(sourceUrl, io);
  const readCheck = (entity, source, shape) => ({ entity, ok: source.ok, status: source.status == null ? 200 : source.status, url: source.url || null, responseShape: source.ok ? shape : source.responseShape, ...(source.ok ? {} : blockedDetail(source)), pages: source.pages.length, records: source.records.length });
  const checks = [
    readCheck('product', data.products, 'data.products.nodes[]'),
    readCheck('collection', data.collections, 'data.collections.nodes[]'),
    { entity: 'collection_product', ok: data.products.ok && data.membershipChecks.every((check) => check.ok), status: data.products.status == null ? 200 : data.products.status, url: data.products.url || null, responseShape: 'data.products.nodes[].collections', records: data.memberships.length },
    ...data.membershipChecks.filter((check) => !check.ok).map((check) => ({ entity: 'product', ...check })),
  ];
  const adapter = { id: CONTRACT.id, version: CONTRACT.version, fingerprint: hash(CONTRACT) };
  const out = { schemaVersion: 1, generatedAt: new Date().toISOString(), adapter, sourceUrl, status: checks.every((check) => check.ok) ? 'passed' : 'blocked', checks };
  await writeJson(path.join(projectDir, 'quick-mode', 'preflight.json'), out);
  if (out.status === 'passed') await writeJson(path.join(projectDir, 'quick-mode', 'preflight-capture.json'), { schemaVersion: 1, generatedAt: out.generatedAt, adapter, sourceUrl, data });
  return out;
}
// The storefront's language code is an enum (`HE`, `PT_BR`); the regional library decides whether it is valid.
function storefrontLanguage(code) {
  if (!code) return undefined;
  const [language, region] = code.toLowerCase().split('_');
  return { value: language, locale: region ? `${language}-${region.toUpperCase()}` : undefined, evidence: 'storefront localization.language.isoCode' };
}
// Writes discovery/source-locale.json from the capture `preflight` stored. Time zone is not on the storefront.
async function writeQuickRegional(projectDir, generatedAt) {
  const capture = await readJsonIfPresent(path.join(projectDir, 'quick-mode', 'preflight-capture.json'));
  const read = capture && capture.data ? capture.data.localization : null;
  const gap = (evidence) => ({ reason: read && read.ok === false ? 'unreadable' : 'not-exposed', evidence });
  const file = sourceRegional.buildSourceRegional({
    adapter: 'quick-shopify',
    generatedAt,
    currency: read && read.currencyCode ? { value: read.currencyCode, evidence: 'storefront localization.country.currency.isoCode' } : gap('storefront localization'),
    timeZone: { reason: 'not-exposed', evidence: 'the storefront API has no time zone' },
    primaryLanguage: (read && storefrontLanguage(read.languageCode)) || gap('storefront localization'),
    country: read && read.countryCode ? { value: read.countryCode, evidence: 'storefront localization.country.isoCode' } : gap('storefront localization'),
  });
  await sourceRegional.writeSourceRegional(projectDir, file);
}
async function plan(projectDir) {
  const { sourceUrl } = await context(projectDir); const preflightResult = await readJson(path.join(projectDir, 'quick-mode', 'preflight.json'));
  if (preflightResult.status !== 'passed') throw new Error('quick mode preflight must pass before planning');
  const adapter = { id: CONTRACT.id, version: CONTRACT.version, fingerprint: hash(CONTRACT) };
  const generatedAt = new Date().toISOString();
  // Every excluded[] capability becomes a declared-exclusion coverage row, checked by the same
  // reconcile rule as a full run before the plan is written.
  const coverage = await sourceCoverage.writeQuickPlanCoverage(projectDir, CONTRACT, { rawText: await fs.readFile(CONTRACT_PATH, 'utf8'), generatedAt });
  await writeQuickRegional(projectDir, generatedAt);
  const out = { schemaVersion: 1, generatedAt, managementImportMode: 'quick', adapter, sourceUrl, entities: CONTRACT.entities, requiredWixCapabilities: CONTRACT.requiredWixCapabilities, excluded: CONTRACT.excluded, excludedCapabilities: CONTRACT.excludedCapabilities, sourceAuth: 'none', sourceCoverageContract: coverage.sourceCoverageContract };
  await writeJson(path.join(projectDir, 'quick-mode', 'plan.json'), out);
  await fs.writeFile(path.join(projectDir, 'quick-mode', 'plan.md'), `# Shopify quick-mode plan\n\nAdapter: ${adapter.id} ${adapter.version}\n\nImported: collections, products, variants, tags, media, and collection membership.\n\nExcluded: ${CONTRACT.excluded.join(', ')}.\n`);
  await writeJson(path.join(projectDir, 'execution', 'execution-manifest.json'), { schemaVersion: 1, generatedAt: out.generatedAt, managementImportMode: 'quick', adapter, extractCommand: 'rp-quick-shopify extract', importCommand: 'rp-quick-shopify import', entities: CONTRACT.entities.map((entity) => entity.id) });
  return out;
}
async function extract(projectDir, io = defaultIo) {
  const { sourceUrl } = await context(projectDir); const planResult = await readJson(path.join(projectDir, 'quick-mode', 'plan.json'));
  if (planResult.adapter.fingerprint !== hash(CONTRACT)) throw new Error('quick adapter contract changed; re-run preflight and plan');
  let data;
  try {
    const capture = await readJson(path.join(projectDir, 'quick-mode', 'preflight-capture.json'));
    if (capture.adapter?.fingerprint === planResult.adapter.fingerprint && capture.sourceUrl === sourceUrl) data = capture.data;
  } catch (error) { if (error.code !== 'ENOENT') throw error; }
  if (!data) data = await captureSource(sourceUrl, io);
  if (!data.products.ok || !data.collections.ok || !data.membershipChecks.every((check) => check.ok)) throw new Error('source changed after preflight; re-run preflight before extracting');
  await writeNdjson(file(projectDir, 'product'), data.products.records);
  const knownProductIds = new Set(data.products.records.map((row) => String(row.id)));
  const edges = data.memberships.filter((edge) => knownProductIds.has(edge.productId));
  // Only collections that contain an extracted product become categories: Wix Stores caps a
  // site at 1,000 categories, and an empty source collection would spend a slot on nothing.
  const populated = new Set(edges.map((edge) => edge.collectionId));
  const collections = data.collections.records.filter((row) => populated.has(String(row.id)));
  await writeNdjson(file(projectDir, 'collection'), collections);
  await writeNdjson(file(projectDir, 'collection_product'), edges);
  const entities = {
    product: { file: 'data/source-extract/product.ndjson', recordCount: data.products.records.length, pages: data.products.pages },
    collection: { file: 'data/source-extract/collection.ndjson', recordCount: collections.length, sourceCount: data.collections.records.length, excludedEmptyCount: data.collections.records.length - collections.length, excluded: data.collections.records.filter((row) => !populated.has(String(row.id))).map((row) => ({ sourceId: String(row.id), handle: row.handle, title: row.title })), pages: data.collections.pages },
    collection_product: { file: 'data/source-extract/collection_product.ndjson', recordCount: edges.length, sourceOnlyCount: data.memberships.length - edges.length },
  };
  const manifest = { schemaVersion: 1, generatedAt: new Date().toISOString(), managementImportMode: 'quick', adapter: planResult.adapter, sourceUrl, entities };
  await writeJson(path.join(projectDir, 'data', 'source-extract', 'manifest.json'), manifest); return manifest;
}
async function loadWixEnv(projectDir) { const values = {}; try { for (const line of (await fs.readFile(path.join(projectDir, 'config', 'wix.env'), 'utf8')).split(/\r?\n/)) { const match = line.trim().match(/^([A-Z0-9_]+)=(.*)$/); if (match) values[match[1]] = match[2].replace(/^['"]|['"]$/g, '').trim(); } } catch (error) { if (error.code !== 'ENOENT') throw error; } return { ...values, ...process.env }; }
// A token from config/wix.env renews itself for this site; a process-env override is used as given.
function wixCredentials(projectDir, env, dryRun) { return dryRun || process.env.WIX_AUTH_TOKEN ? { authToken: env.WIX_AUTH_TOKEN } : { tokenSource: wixAuth.createWixTokenSource({ projectDir, siteId: env.WIX_SITE_ID }) }; }
async function loadCrosswalk(projectDir) { try { return await readJson(path.join(projectDir, 'state', 'crosswalk', 'quick-shopify.json')); } catch (error) { if (error.code === 'ENOENT') return {}; throw error; } }
function canonicalProduct(row) {
  if (!text(row.id) || !text(row.title) || !text(row.handle)) throw new Error('missing product id, title, or handle');
  if (text(row.body_html).length > MAX_DESCRIPTION_LENGTH) throw new Error('description exceeds Wix limit');
  const names = (row.options || []).map((option) => text(option.name)).filter((name) => name && name.toLowerCase() !== 'title');
  if (names.length > 3) throw new Error('more than three options');
  const variants = (row.variants || []).map((variant) => {
    if (!text(variant.id) || !validPrice(variant.price)) throw new Error('invalid variant id or price');
    if (typeof variant.available !== 'boolean') throw new Error('variant availability must be explicit true or false');
    const choices = names.map((optionName, index) => ({ optionName, choiceName: text(variant[`option${index + 1}`]) })).filter((choice) => choice.choiceName);
    if (choices.length !== names.length) throw new Error('variant option values do not match product options');
    return { sku: text(variant.sku) || undefined, price: variant.price, compareAtPrice: validPrice(variant.compare_at_price) ? variant.compare_at_price : undefined, visible: variant.available !== false, inStock: variant.available, inventoryTracked: false, choices };
  });
  if (!variants.length) throw new Error('product has no variants');
  return { name: text(row.title), slug: toWixSlug(row.handle), description: text(row.body_html) || undefined, visible: Boolean(row.published_at), optionNames: names, variants, images: (row.images || []).map((image) => ({ url: image.src, altText: image.alt || undefined })).filter((image) => text(image.url)) };
}
// Correlate by option/choice names resolved through target IDs, never response position or SKU.
const { resolveInventoryRows } = require('../../rp-target-wix/lib/variant-correlation');

// LIVE-VERIFIED (2026-10-15): a product read immediately after its create can 404 although the
// product exists and is listed under the same id moments later. Retry only that 404, briefly;
// every other error, and a 404 that persists, still fails the row.
const READ_AFTER_CREATE_DELAYS_MS = [500, 1500, 3000];
async function readCreatedProduct(writers, wix, id, delays = READ_AFTER_CREATE_DELAYS_MS) {
  for (let attempt = 0; ; attempt += 1) {
    try { return await writers.getStoresProductForVerification(wix, id); } catch (error) {
      if (!/\b404\b/.test(error.message) || attempt >= delays.length) throw error;
      await sleep(delays[attempt]);
    }
  }
}

// Category capacity. Pure: callers decide where the answer is written.
// `existingCount` already includes the undeletable All Products category.
function categoryCapacity({ existingCount, rows, seen, existingSlugs }) {
  const toCreate = recovery.countCategoriesToCreate({ rows, seen, existingSlugs, slugOf: toWixSlug });
  return { toCreate, existingCount, max: recovery.STORES_CATEGORIES_MAX, exceeds: existingCount + toCreate > recovery.STORES_CATEGORIES_MAX };
}
// Live: the queried count, plus the built-in All Products category when the query did not return it
// under its canonical slug. If it comes back under another slug this refuses one slot early.
function liveExistingCount(existingCategories) { return existingCategories.size + (existingCategories.has('all-products') ? 0 : 1); }
function assertCategoryCapacity({ existingCount, rows, seen, existingSlugs }) {
  const capacity = categoryCapacity({ existingCount, rows, seen, existingSlugs });
  if (capacity.exceeds) throw new Error(`Wix Stores category limit: site has ${existingCount} categories and this import must create ${capacity.toCreate} more (max ${capacity.max} per site)`);
  return capacity.toCreate;
}

// The payload and read-back comparison the import and the re-check share, so a re-check applies
// exactly the same rules the import did.
function buildProductPayload(row, tagIds) {
  const canonical = canonicalProduct(row);
  const payload = buildProduct(canonical);
  const normalized = recovery.normalizeProduct({ sourceId: row.id, sku: payload.variantsInfo.variants[0]?.sku || '', tagIds: (row.tags || []).map((name) => tagIds.get(text(name).toLowerCase())).filter(Boolean) });
  payload.variantsInfo.variants.forEach((variant) => { if (variant.sku) variant.sku = recovery.truncate(variant.sku, recovery.STORES_SKU_MAX, `${row.id}:${variant.sku}`); });
  payload.tags = { publicTags: { tagIds: normalized.tagIds } };
  // This endpoint creates only the product. Stock is written by the reconciler.
  for (const variant of payload.variantsInfo.variants) delete variant.inventoryItem;
  return { canonical, payload, normalized };
}
function verifyWrittenProduct(writers, { row, canonical, payload, fullTarget, targetId, siteId }) {
  const correspondence = resolveInventoryRows(canonical, fullTarget);
  const expected = { ...writers.buildCreateStoresProductRequest(payload).body.product };
  delete expected.options; delete expected.variantsInfo;
  const variants = correspondence.map((entry, index) => {
    const fields = { ...payload.variantsInfo.variants[index] };
    delete fields.choices;
    return { variantId: entry.variantId, expected: fields };
  });
  const receipt = verification.receipt({ siteId, sourceKey: String(row.id), kind: 'product', expected, actual: fullTarget, targetId, marker: { path: 'slug', value: payload.slug }, variants, operation: 'quick-shopify-product' });
  return { receipt, correspondence };
}
const failedFieldsOf = (receipt) => [...new Set((receipt.checks || []).filter((check) => !check.matches).map((check) => check.path))];
function deriveStatus(summary) {
  if (summary.dryRun) return 'simulated';
  if (summary.inventory && summary.inventory.status === 'no-data') return 'no-data';
  return summary.inventory && summary.inventory.complete && Object.values(summary.skipped || {}).every((rows) => !rows.length) ? 'complete' : 'partial';
}

// ---- Re-check of written-unverified products ----
const QM_DIR = 'quick-mode';
const REVERIFY_REQUEST = `${QM_DIR}/reverify-request.json`;
const REVERIFY_RECEIPT = `${QM_DIR}/reverification.json`;
const REVERIFY_STAGED = `${QM_DIR}/reverification-staged.json`;
const COMPLETION_REPORT = 'execution/completion-report.json';
async function readJsonIfPresent(file) { try { return await readJson(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } }
async function writeJsonAtomic(file, value) { await recovery.writeFileAtomic(file, `${JSON.stringify(value, null, 2)}\n`); }
async function removeIfPresent(file) { try { await fs.rm(file); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
// A staged result whose request has no matching receipt is unfinished publication.
async function pendingReverification(projectDir) {
  const request = await readJsonIfPresent(path.join(projectDir, REVERIFY_REQUEST));
  if (!request) return { request: null, receipt: null, staged: null, done: false };
  const receipt = await readJsonIfPresent(path.join(projectDir, REVERIFY_RECEIPT));
  const staged = await readJsonIfPresent(path.join(projectDir, REVERIFY_STAGED));
  return { request, receipt, staged, done: Boolean(receipt && receipt.requestId === request.requestId) };
}
async function requestReverify(projectDir) {
  const completion = await readJsonIfPresent(path.join(projectDir, COMPLETION_REPORT));
  if (!completion) throw new Error('no live completion report: nothing to re-check');
  const pending = await pendingReverification(projectDir);
  if (pending.request && !pending.done && pending.staged && pending.staged.requestId === pending.request.requestId) throw new Error('a re-check result is staged but not yet published: route the project first so it can finish');
  if (!((completion.writtenUnverified && completion.writtenUnverified.product) || []).length) throw new Error('the completion report lists no written-unverified products: nothing to re-check');
  await removeIfPresent(path.join(projectDir, REVERIFY_STAGED));
  const request = { schemaVersion: 1, requestId: crypto.randomUUID(), requestedAt: new Date().toISOString() };
  await writeJsonAtomic(path.join(projectDir, REVERIFY_REQUEST), request);
  return { status: 'requested', requestId: request.requestId };
}
// Pure replay of the staged result. Every step SETS values (never removes items), so running it
// again after a crash at any boundary converges on the same bytes. The receipt is last.
async function finalizeReverification(projectDir, staged, hooks = {}) {
  if (!staged.verificationProduct) throw new Error('staged re-check result omitted product verification evidence');
  const report = path.join(projectDir, COMPLETION_REPORT);
  const completion = await readJson(report);
  completion.writtenUnverified = { ...(completion.writtenUnverified || {}), product: staged.writtenUnverified };
  completion.skipped = { ...(completion.skipped || {}), product: staged.skippedProduct };
  completion.imported = { ...(completion.imported || {}), product: staged.importedProduct };
  completion.verification = { ...(completion.verification || {}), product: staged.verificationProduct };
  completion.status = staged.status;
  catalogBaseline.quickReport(projectDir, completion);
  await writeJsonAtomic(report, completion);
  if (hooks.afterCompletionReport) await hooks.afterCompletionReport();
  await recovery.writeFinalReport(projectDir, completion, { dryRun: false });
  if (hooks.afterFinalReport) await hooks.afterFinalReport();
  await writeJsonAtomic(path.join(projectDir, REVERIFY_RECEIPT), { schemaVersion: 1, requestId: staged.requestId, completedAt: staged.completedAt, checked: staged.checked, cleared: staged.cleared, remaining: staged.remaining, errors: staged.errors });
  if (hooks.afterReceipt) await hooks.afterReceipt();
  await removeIfPresent(path.join(projectDir, REVERIFY_STAGED));
  return completion;
}
const httpStatusOf = (error) => { const match = /\b([1-5]\d\d)\b/.exec(error && error.message || ''); return match ? Number(match[1]) : 'error'; };
async function reverify(projectDir, { wix: injectedWix, writers: injectedWriters, hooks = {} } = {}) {
  const writers = injectedWriters || require(path.join(ADAPTER_DIR, '..', 'rp-target-wix', 'lib', 'wix-writers.js'));
  const env = await loadWixEnv(projectDir);
  if (writers.createDryRunConfig(env, process.argv.slice(2)).dryRun) throw new Error('reverify refuses a dry run: it re-checks the live import');
  const pending = await pendingReverification(projectDir);
  if (!pending.request) throw new Error('no re-check requested: run request-reverify first');
  if (pending.done) { await removeIfPresent(path.join(projectDir, REVERIFY_STAGED)); return { status: 'already_done', requestId: pending.request.requestId }; }
  // A staged result for this request is the whole answer: publish it, read nothing from Wix.
  if (pending.staged && pending.staged.requestId === pending.request.requestId) { await finalizeReverification(projectDir, pending.staged, hooks); return { status: 'finalized', requestId: pending.request.requestId }; }
  if (!env.WIX_AUTH_TOKEN || !env.WIX_SITE_ID) throw new Error('WIX_AUTH_TOKEN and WIX_SITE_ID are required for a re-check');
  const completion = await readJson(path.join(projectDir, COMPLETION_REPORT));
  const listed = (completion.writtenUnverified && completion.writtenUnverified.product) || [];
  if (!listed.length) throw new Error('the completion report lists no written-unverified products: nothing to re-check');
  const wix = injectedWix || writers.createWixClient({ dryRun: false, siteId: env.WIX_SITE_ID, projectDir, ...wixCredentials(projectDir, env, false) });
  const crosswalk = await loadCrosswalk(projectDir);
  const tagIds = new Map(Object.entries(crosswalk.product_tag || {}).filter(([, id]) => id));
  const rows = new Map((await readNdjson(file(projectDir, 'product'))).map((row) => [String(row.id), row]));
  const refreshedVerification = new Map(((completion.verification && completion.verification.product && completion.verification.product.rows) || []).map((row) => [String(row.sourceKey), row]));
  const cleared = [], remaining = [], errors = [], kept = [];
  for (const entry of listed) {
    const sourceId = String(entry.sourceId);
    try {
      const row = rows.get(sourceId);
      if (!row) throw new Error('source product is no longer in the extract');
      const { canonical, payload } = buildProductPayload(row, tagIds);
      // The post-create 404 retry is deliberately not shared here: an operator-requested
      // re-check performs exactly one read per listed product.
      const fullTarget = await writers.getStoresProductForVerification(wix, entry.targetId);
      const { receipt } = verifyWrittenProduct(writers, { row, canonical, payload, fullTarget, targetId: entry.targetId, siteId: env.WIX_SITE_ID });
      refreshedVerification.set(sourceId, { sourceKey: sourceId, targetId: entry.targetId, receipt });
      if (receipt.verified) cleared.push(sourceId);
      else { remaining.push(sourceId); kept.push({ ...entry, failedFields: failedFieldsOf(receipt) }); }
    } catch (error) { if (wixAuth.isRunInterruption(error)) throw error;
      remaining.push(sourceId); kept.push(entry); errors.push({ sourceId, status: httpStatusOf(error) });
      if (!refreshedVerification.has(sourceId)) refreshedVerification.set(sourceId, { sourceKey: sourceId, targetId: entry.targetId, outcome: 'needs_verification' });
    }
  }
  const skippedProduct = ((completion.skipped && completion.skipped.product) || []).filter((row) => !cleared.includes(String(row.sourceId)));
  const verificationProduct = verificationState.report([...refreshedVerification.values()], env.WIX_SITE_ID);
  const next = { ...completion, writtenUnverified: { ...(completion.writtenUnverified || {}), product: kept }, skipped: { ...(completion.skipped || {}), product: skippedProduct }, verification: { ...(completion.verification || {}), product: verificationProduct } };
  const staged = { schemaVersion: 1, requestId: pending.request.requestId, completedAt: new Date().toISOString(), checked: listed.map((entry) => String(entry.sourceId)), cleared, remaining, errors, writtenUnverified: kept, skippedProduct, importedProduct: ((completion.imported && completion.imported.product) || 0) + cleared.length, verificationProduct, status: deriveStatus(next) };
  if (hooks.beforeStaged) await hooks.beforeStaged();
  await writeJsonAtomic(path.join(projectDir, REVERIFY_STAGED), staged);
  if (hooks.afterStaged) await hooks.afterStaged();
  await finalizeReverification(projectDir, staged, hooks);
  return { status: 'rechecked', requestId: staged.requestId, cleared, remaining, errors };
}

async function importData(projectDir) {
  const planResult = await readJson(path.join(projectDir, 'quick-mode', 'plan.json')); const manifest = await readJson(path.join(projectDir, 'data', 'source-extract', 'manifest.json'));
  importProgress.assertQuickImportAdapter({ plan: planResult, manifest, current: { id: CONTRACT.id, version: CONTRACT.version, fingerprint: hash(CONTRACT) } });
  // Fail closed before any write: a stamped plan with a missing or unreconciled coverage file
  // must not produce a completion summary.
  await sourceCoverage.quickSummaryCoverage(projectDir);
  const productRows = await readNdjson(file(projectDir, 'product'));
  if (productRows.length !== manifest.entities?.product?.recordCount) throw new Error('Product extraction count differs from its manifest');
  const env = await loadWixEnv(projectDir); if (!env.WIX_AUTH_TOKEN || !env.WIX_SITE_ID) throw new Error('WIX_AUTH_TOKEN and WIX_SITE_ID are required for quick import');
  const writers = require(path.join(ADAPTER_DIR, '..', 'rp-target-wix', 'lib', 'wix-writers.js')); const dryRun = writers.createDryRunConfig(env, process.argv.slice(2)).dryRun;
  const quickRun = capabilities.startQuickRun({ projectDir, adapterId: 'quick-shopify', siteId: env.WIX_SITE_ID, dryRun });
  // The site's currency is checked before an entity that writes money-bearing records gets its write
  // capability. A refused entity gets no client and writes nothing; whatever depends on it is skipped
  // too. The gate records its own row.
  const regionalRefused = {};
  const clientFor = async (id) => {
    const entity = CONTRACT.entities.find((row) => row.id === id);
    const upstream = entity.dependsOn.find((dependency) => regionalRefused[dependency]);
    if (upstream) { regionalRefused[id] = 'DEPENDENCY_REFUSED'; return null; }
    // Every other entity builds on the regional setup step: a held entity writes nothing (files only; no site read).
    const dependency = siteRegional.regionalDependency({ projectDir, siteId: env.WIX_SITE_ID, entityRefs: [entity.target, ...(entity.auxiliaryTargets || [])] });
    if (!dependency.ok) { regionalRefused[id] = dependency.code; return null; }
    return writers.createWixClient({ capability: capabilities.bindQuickEntity(quickRun, id), ...wixCredentials(projectDir, env, dryRun) });
  };
  const crosswalk = await loadCrosswalk(projectDir);
  const remember = (entity, sourceId, targetId) => { if (!dryRun) { crosswalk[entity] ||= {}; crosswalk[entity][String(sourceId)] = targetId; } }; const seen = (entity, id) => crosswalk[entity]?.[String(id)];
  const checkpoint = async () => { if (!dryRun) await writeJson(path.join(projectDir, 'state', 'crosswalk', 'quick-shopify.json'), crosswalk); };
  const excludedCollections = manifest.entities?.collection?.excluded || [];
  const summary = { schemaVersion: 1, generatedAt: new Date().toISOString(), managementImportMode: 'quick', adapter: planResult.adapter, dryRun, imported: {}, skipped: { product: [] }, recovered: 0, recoveryCounts: {}, excluded: CONTRACT.excluded, excludedCollections, regionalRefused };
  const categoryIds = new Map();
  let wix = await clientFor('collection');
  // Durable pre-write evidence, before the first destination create (idempotent: a refused first entity
  // must not claim a start, and the marker must exist before whichever entity writes first).
  let startedMarker = false;
  const markStarted = async () => { if (!dryRun && !startedMarker) { startedMarker = true; await importProgress.writeQuickImportStartedMarker(projectDir, planResult.adapter); } };
  if (wix) {
    const collectionRows = await readNdjson(file(projectDir, 'collection'));
    // Category capacity: decided before the first write. A dry run cannot see the
    // destination, so it applies the same rule with the built-in category as the floor.
    // Categories that were on the store before this import still take a slot, but are
    // never adopted for a source collection: a slug match on one is held as a collision.
    const destinationCategories = dryRun ? [] : await writers.queryAllStoresCategories(wix);
    const baselineCategoryIds = catalogBaseline.protectedIds(projectDir).categories;
    const allCategorySlugs = new Map(destinationCategories.map((category) => [category.slug, category]));
    const existingCategories = new Map(destinationCategories.filter((category) => !baselineCategoryIds.has(category.id)).map((category) => [category.slug, category]));
    const existingCount = dryRun ? 1 : liveExistingCount(allCategorySlugs);
    const capacity = categoryCapacity({ existingCount, rows: collectionRows, seen: dryRun ? () => false : (id) => seen('collection', id), existingSlugs: allCategorySlugs });
    const capacityRecord = { schemaVersion: 1, status: capacity.exceeds ? 'blocked' : dryRun ? 'passed_at_floor' : 'passed', existingCount, toCreate: capacity.toCreate, max: capacity.max, populatedSourceCount: collectionRows.length, ...(dryRun ? { existingCountBasis: 'built_in_floor', destinationHeadroom: 'unknown' } : { builtInCountedSeparately: !allCategorySlugs.has('all-products') }) };
    await writeJson(path.join(projectDir, dryRun ? 'execution/dry-run-category-capacity.json' : 'quick-mode/category-capacity.json'), capacityRecord);
    if (capacity.exceeds) {
      if (!dryRun) throw new Error(`Wix Stores category limit: site has ${existingCount} categories and this import must create ${capacity.toCreate} more (max ${capacity.max} per site); free categories on the destination or stop`);
      Object.assign(summary, { status: 'blocked', reason: 'category_capacity', categoryCapacity: capacityRecord }); Object.assign(summary, await sourceCoverage.quickSummaryCoverage(projectDir));
      await recovery.writeFinalReport(projectDir, summary, { dryRun: true }); await writeJson(path.join(projectDir, 'execution', 'dry-run-completion-report.json'), summary); return summary;
    }
    await markStarted();
    for (const row of collectionRows) { try { const key = toWixSlug(row.handle); const savedCategory = seen('collection', row.id); const held = savedCategory ? (baselineCategoryIds.has(savedCategory) && { id: savedCategory }) : !existingCategories.has(key) && allCategorySlugs.get(key); if (held) { catalogBaseline.recordCollision(projectDir, { entity: 'stores/category', sourceKey: String(row.id), targetId: held.id, slug: key }); throw catalogBaseline.collisionError('stores/category', held.id); } const target = seen('collection', row.id) ? { id: seen('collection', row.id) } : existingCategories.get(key) || await writers.createStoresCategory(wix, { name: text(row.title), slug: key, description: text(row.body_html).slice(0, 600) || undefined, treeReference: writers.STORES_TREE_REFERENCE }); categoryIds.set(String(row.id), target.id); remember('collection', row.id, target.id); if (!dryRun) catalogBaseline.clearCollision(projectDir, 'stores/category', row.id); await checkpoint(); } catch (error) { if (wixAuth.isRunInterruption(error)) throw error; summary.skipped.collection ||= []; summary.skipped.collection.push({ sourceId: String(row.id), reason: error.message }); } }
    summary.imported.collection = categoryIds.size;
    capabilities.finishQuickEntity(quickRun, 'collection');
  }
  const productIds = new Map(); const unverifiedTargets = new Map();
  wix = await clientFor('product');
  if (wix) {
    await markStarted();
    const existingTags = new Map(((await wix.send(writers.buildListStoresProductTagsRequest())).tags || []).map((tag) => [text(tag.name).toLowerCase(), tag]));
    const tagIds = new Map(); let tagLimitReached = false;
    for (const row of productRows) for (const name of (row.tags || [])) { const key = text(name).toLowerCase(); if (!key || tagIds.has(key)) continue; if (Object.prototype.hasOwnProperty.call(crosswalk.product_tag || {}, key)) { if (crosswalk.product_tag[key]) tagIds.set(key, crosswalk.product_tag[key]); continue; } if (tagLimitReached) { summary.skipped.product_tag ||= []; summary.skipped.product_tag.push({ sourceId: key, reason: 'destination_tag_limit_reached' }); remember('product_tag', key, null); await checkpoint(); continue; } try { const target = existingTags.get(key) || (await wix.send(writers.buildCreateStoresProductTagRequest(text(name)))).tag; tagIds.set(key, target.id); remember('product_tag', key, target.id); } catch (error) { if (!/TAG_NAME_ALREADY_EXISTS|TAGS_REACHED_LIMIT/.test(error.message)) throw error; tagLimitReached = /TAGS_REACHED_LIMIT/.test(error.message); summary.skipped.product_tag ||= []; summary.skipped.product_tag.push({ sourceId: key, reason: tagLimitReached ? 'destination_tag_limit_reached' : 'destination_tag_exists_but_is_not_returned_by_tag_list' }); remember('product_tag', key, null); } await checkpoint(); }
    const catalog = await writers.queryAllStoresProducts(wix);
    // Products that were on the store before this import are never adopted by slug.
    const baselineProductIds = catalogBaseline.protectedIds(projectDir).products;
    const heldProducts = new Map(catalog.filter(product => baselineProductIds.has(product.id)).map(product => [product.slug, product]));
    const existingProducts = new Map(catalog.filter(product => !baselineProductIds.has(product.id)).map(product => [product.slug, product]));
    // Resolve collisions across the whole source set before any candidate product write.
    // This pre-pass must call `toWixSlug` inside try/catch, like every other call site in this
    // file -- otherwise one product whose handle sanitizes to a degenerate slug (e.g. an
    // emoji/symbol-only handle, which `toWixSlug` rejects) crashes this `.map()` uncaught and
    // aborts the ENTIRE quick-import run instead of being skipped for just that one row, same as
    // every other per-row failure here.
    const slugErrors = new Map();
    const identities = productRows.map(row => {
      let slug = null;
      try {
        slug = toWixSlug(row.handle);
      } catch (error) { if (wixAuth.isRunInterruption(error)) throw error;
        slugErrors.set(String(row.id), error.message);
      }
      return { sourceId: String(row.id), slug,
        targetId: seen('product', row.id) || seen('product_candidate', row.id) || (slug !== null ? existingProducts.get(slug)?.id : undefined) };
    });
    const ambiguousSources = new Set(identities.filter(identity =>
      identities.some(other => other !== identity && (other.sourceId === identity.sourceId || other.slug === identity.slug || (identity.targetId && other.targetId === identity.targetId))) ||
      catalog.filter(product => product.slug === identity.slug).length > 1
    ).map(identity => identity.sourceId));
    const inventoryRows = [], inventoryGaps = [], productVerification = [];
    // Products that exist on the target with uniquely correlated variants but failed a field
    // read-back (e.g. a description fragment Wix dropped). Stock and category membership depend
    // only on product and variant identity, so they still apply; the product stays reported.
    const expectedVariants = productRows.reduce((count, row) => count + Math.max(1, (row.variants || []).length), 0);
    for (const row of productRows) {
      let stockQueued = false;
      if (!dryRun && seen('product', row.id)) {
        remember('product_candidate', row.id, seen('product', row.id));
        delete crosswalk.product[String(row.id)];
        await checkpoint();
      }
      try {
        if (slugErrors.has(String(row.id))) throw new Error(slugErrors.get(String(row.id)));
        if (ambiguousSources.has(String(row.id))) throw new Error('Ambiguous source product marker or target identity');
        // A saved target id is checked too: an earlier run may have adopted a baseline product.
        const savedProduct = seen('product', row.id) || seen('product_candidate', row.id);
        const heldProduct = savedProduct ? (baselineProductIds.has(savedProduct) && (catalog.find(product => product.id === savedProduct) || { id: savedProduct })) : heldProducts.get(toWixSlug(row.handle));
        if (heldProduct) { if (!dryRun) catalogBaseline.recordCollision(projectDir, { entity: 'stores/product', sourceKey: String(row.id), targetId: heldProduct.id, slug: heldProduct.slug ?? null }); throw catalogBaseline.collisionError('stores/product', heldProduct.id); }
        const { canonical, payload, normalized } = buildProductPayload(row, tagIds);
        const knownId = seen('product', row.id) || seen('product_candidate', row.id);
        const existing = knownId ? { id: knownId } : existingProducts.get(payload.slug);
        const target = existing || await writers.createStoresProduct(wix, payload);
        remember('product_candidate', row.id, target.id);
        if (!dryRun && crosswalk.product) delete crosswalk.product[String(row.id)];
        await checkpoint();
        if (normalized.recoveries.length) { if (!dryRun) await recovery.appendRecoveries(projectDir, planResult.adapter, row.id, normalized.recoveries); recovery.recordRecoveredRecord(summary, normalized.recoveries); }
        if (!dryRun) {
          // A post-write verification gate must not use the bare getStoresProduct read -- the same
          // rule as at its sibling call site in product-import.js's verifyProductCandidate. The
          // bare read omits plainDescription entirely and collapses media to `media.main` only (see
          // getStoresProductForVerification's own comment in wix-writers.js), so `receipt.verified`
          // below could never actually confirm either field, and a real loss there would pass
          // silently instead of throwing.
          const fullTarget = await readCreatedProduct(writers, wix, target.id);
          const { receipt, correspondence } = verifyWrittenProduct(writers, { row, canonical, payload, fullTarget, targetId: target.id, siteId: env.WIX_SITE_ID });
          writers.registerQuickInventoryScope(wix, canonical, fullTarget);
          productVerification.push({ sourceKey: String(row.id), targetId: target.id, receipt });
          if (!receipt.verified) {
            inventoryRows.push(...correspondence); stockQueued = true; unverifiedTargets.set(String(row.id), { targetId: target.id, name: canonical.name, failedFields: failedFieldsOf(receipt) });
            throw new Error('Product identity or values failed read-back');
          }
          inventoryRows.push(...correspondence);
        }
        productIds.set(String(row.id), target.id); remember('product', row.id, target.id); if (!dryRun) catalogBaseline.clearCollision(projectDir, 'stores/product', row.id); await checkpoint();
      } catch (error) { if (wixAuth.isRunInterruption(error)) throw error;
        if (!productVerification.some(r => r.sourceKey === String(row.id))) productVerification.push({ sourceKey: String(row.id), outcome: 'needs_verification' });
        summary.skipped.product.push({ sourceId: String(row.id), reason: error.message });
        if (!stockQueued) for (const variant of row.variants?.length ? row.variants : [null]) inventoryGaps.push({ sourceProductId: String(row.id), sourceVariantId: variant ? String(variant.id) : null, reason: error.message });
      }
    }
    summary.verification = { product: dryRun ? { status: 'simulated' } : verificationState.report(productVerification, env.WIX_SITE_ID) };
    summary.inventory = dryRun ? { status: 'simulated', complete: false, intended: expectedVariants }
      : await inventory.reconcileInventory(wix, inventoryRows, { expectedCount: expectedVariants, gaps: inventoryGaps });
    await inventory.persistInventoryReport(path.join(projectDir, dryRun ? 'execution/dry-run-inventory-report.json' : 'state/inventory-report.json'), summary.inventory);
    summary.status = deriveStatus(summary);
    summary.imported.product = productIds.size;
    summary.writtenUnverified = { product: [...unverifiedTargets].map(([sourceId, entry]) => ({ sourceId, targetId: entry.targetId, name: entry.name, failedFields: entry.failedFields })) };
    capabilities.finishQuickEntity(quickRun, 'product');
  }
  wix = await clientFor('collection_product');
  let memberships = 0;
  if (wix) {
    for (const edge of await readNdjson(file(projectDir, 'collection_product'))) { const categoryId = categoryIds.get(edge.collectionId); const productId = productIds.get(edge.productId) || unverifiedTargets.get(edge.productId)?.targetId; if (!categoryId || !productId) continue; const edgeId = `${edge.collectionId}:${edge.productId}`; if (!seen('collection_product', edgeId)) { await writers.bulkAddItemToCategories(wix, { productId, categoryIds: [categoryId] }); remember('collection_product', edgeId, productId); await checkpoint(); } memberships += 1; }
    capabilities.finishQuickEntity(quickRun, 'collection_product');
    summary.imported.collection_product = memberships;
  }
  // A refusal means the run did not import everything: it is never reported complete.
  if (!summary.status) summary.status = deriveStatus(summary); else if (!dryRun && Object.keys(regionalRefused).length) summary.status = 'partial';
  if (!dryRun) await checkpoint(); Object.assign(summary, await sourceCoverage.quickSummaryCoverage(projectDir)); catalogBaseline.quickReport(projectDir, summary); await recovery.writeFinalReport(projectDir, summary, { dryRun }); await writeJson(path.join(projectDir, 'execution', dryRun ? 'dry-run-completion-report.json' : 'completion-report.json'), summary); return summary;
}
async function main() { const [command, projectDirArg] = process.argv.slice(2); if (!command || !projectDirArg || !['preflight', 'plan', 'extract', 'import', 'request-reverify', 'reverify'].includes(command)) throw new Error('Usage: quick-mode.js <preflight|plan|extract|import|request-reverify|reverify> <projectDir>'); const fn = { preflight, plan, extract, import: importData, 'request-reverify': requestReverify, reverify }[command]; const result = await fn(path.resolve(projectDirArg)); process.stdout.write(`${JSON.stringify({ ok: true, command, status: result.status || 'completed' })}\n`); }
if (require.main === module) main().catch(async (error) => { process.stderr.write(`${error.message}\n`); process.exit(await wixAuth.stopExitCode(error, process.argv[3] && path.resolve(process.argv[3]))); });

module.exports = { canonicalProduct, resolveInventoryRows, assertCategoryCapacity, categoryCapacity, readCreatedProduct, importData, storefrontQuery, retryWaitMs, captureSource, preflight, extract, plan, requestReverify, reverify, finalizeReverification, deriveStatus };

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs Node.js

Security

What our scanner noticed· Low risk

  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Wix
Publisher
Wix (Official)
Source
wix/skills · skills/wix-replatform/resources/rp-quick-shopify · commit d168bc2
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Wix or Wix.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download rp-quick-shopify.zip