Setting the file. One moment.
Quick Mode · Rp Quick Shopify · wix/skills · Skills Docs
ContentsBack to the top of the page function storefrontEndpoint
— line 76
This file
Number 32.1
Position 1 of 2
Type JavaScript
Size 56 KB
Lines 603 scripts/ quick-mode.js
JavaScript · 603 lines · 56 KB
fs
=
require
(
'node:fs/promises'
);
10 const path = require ( 'node:path' );
11 const crypto = require ( 'node:crypto' );
12 const ADAPTER_DIR = path. resolve (__dirname, '..' );
13 const CONTRACT_PATH = path. join ( ADAPTER_DIR , 'quick-mode.json' );
14 const CONTRACT = require ( CONTRACT_PATH );
15 const sourceCoverage = require (path. join ( ADAPTER_DIR , '..' , '..' , 'lib' , 'source-coverage.js' ));
16 const sourceRegional = require (path. join ( ADAPTER_DIR , '..' , '..' , 'lib' , 'source-regional.js' ));
17 const { buildProduct , toWixSlug } = require (path. join ( ADAPTER_DIR , '..' , 'rp-target-wix' , 'lib' , 'wix-build.js' ));
18 const inventory = require (path. join ( ADAPTER_DIR , '..' , 'rp-target-wix' , 'lib' , 'stores-inventory.js' ));
19 const recovery = require (path. join ( ADAPTER_DIR , '..' , 'rp-quick-runtime' , 'lib' , 'limit-recovery.js' ));
20 const importProgress = require (path. join ( ADAPTER_DIR , '..' , 'rp-quick-runtime' , 'lib' , 'import-progress.js' ));
21
22 const verification = require ( '../../rp-target-wix/lib/write-verification' );
23 const verificationState = require ( '../../../lib/write-verification-state' );
24 const catalogBaseline = require ( '../../../lib/catalog-baseline' );
25 const PRODUCT_LIMIT = 250 ;
26 const MAX_DESCRIPTION_LENGTH = 16000 ;
27 // Requests stay serial and paced; a full catalog is a handful of pages, so a short delay is
28 // enough. Retries back off exponentially (or by retry-after) up to the cap below.
29 const SOURCE_REQUEST_DELAY_MS = 300 ;
30 // One first call plus at most six retries per request; every wait and every call is capped.
31 const SOURCE_MAX_RETRIES = 6 ;
32 const SOURCE_MAX_BACKOFF_MS = 30000 ;
33 const SOURCE_CALL_TIMEOUT_MS = 30000 ;
34 const hash = ( value ) => crypto. createHash ( 'sha256' ). update ( JSON . stringify (value)). digest ( 'hex' );
35 const readJson = async ( file ) => JSON . parse ( await fs. readFile (file, 'utf8' ));
36 async function writeJson ( file , value ) { await fs. mkdir (path. dirname (file), { recursive: true }); await fs. writeFile (file, `${ JSON . stringify ( value , null , 2 ) } \n ` ); }
37 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 ' : '' )); }
38 async function readNdjson ( file ) { const raw = await fs. readFile (file, 'utf8' ); return raw. split ( / \r ? \n / ). filter (Boolean). map ( JSON .parse); }
39 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; }
40 function text ( value ) { return String (value == null ? '' : value). trim (); }
41 function validPrice ( value ) { const amount = Number (value); return Number. isFinite (amount) && amount >= 0 ; }
42 function file ( projectDir , name ) { return path. join (projectDir, 'data' , 'source-extract' , `${ name }.ndjson` ); }
43 const sleep = ( milliseconds ) => new Promise (( resolve ) => setTimeout (resolve, milliseconds));
44
45 async function context ( projectDir ) {
46 const decisions = await readJson (path. join (projectDir, 'orchestration' , 'decisions.json' ));
47 const sourceUrl = decisions.sourceUrl && decisions.sourceUrl.value;
48 const platform = decisions.sourcePlatform && decisions.sourcePlatform.value;
49 if ( ! sourceUrl) throw new Error ( 'quick mode requires orchestration.decisions.sourceUrl' );
50 if (platform !== 'shopify' ) throw new Error ( `quick Shopify adapter requires sourcePlatform=shopify (got ${ platform || 'missing'})` );
51 return { sourceUrl: sourceOrigin (sourceUrl) };
52 }
53
54 // Tokenless Storefront GraphQL. A single paginated product query carries each product's
55 // variants, images, and collection memberships, so membership costs no per-collection
56 // requests. Records are normalized to the storefront-JSON row shape the import consumes.
57 const STOREFRONT_API_VERSION = CONTRACT .storefrontApiVersion;
58 const PRODUCT_CONNECTION = 'variants(first: 100) { pageInfo { hasNextPage endCursor } nodes { id title sku availableForSale price { amount } compareAtPrice { amount } selectedOptions { name value } } }' ;
59 const IMAGE_CONNECTION = 'images(first: 20) { pageInfo { hasNextPage endCursor } nodes { url altText } }' ;
60 const MEMBERSHIP_CONNECTION = 'collections(first: 50) { pageInfo { hasNextPage endCursor } nodes { id } }' ;
61 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 } } } }` ;
62 const COLLECTIONS_QUERY = 'query QuickCollections($first: Int!, $after: String) { collections(first: $first, after: $after) { pageInfo { hasNextPage endCursor } nodes { id handle title descriptionHtml updatedAt } } }' ;
63 // The storefront's own currency, language and country (the store's default market). It carries no time zone.
64 const LOCALIZATION_QUERY = 'query QuickLocalization { localization { country { isoCode currency { isoCode } } language { isoCode } } }' ;
65 // A product whose nested list exceeds the first page is completed through its own cursor.
66 const NESTED_QUERIES = {
67 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 } } } } }' ,
68 images: 'query QuickImages($id: ID!, $after: String) { product(id: $id) { images(first: 250, after: $after) { pageInfo { hasNextPage endCursor } nodes { url altText } } } }' ,
69 collections: 'query QuickMemberships($id: ID!, $after: String) { product(id: $id) { collections(first: 250, after: $after) { pageInfo { hasNextPage endCursor } nodes { id } } } }' ,
70 };
71 const RETRYABLE_GRAPHQL_CODES = new Set ([ 'THROTTLED' , 'INTERNAL_SERVER_ERROR' ]);
72 // Everything time- or network-dependent goes through `io` so tests can drive the retry contract
73 // with a fake clock and a scripted fetch instead of real waits.
74 const defaultIo = { fetch : ( ... args ) => fetch ( ... args), sleep, now : () => Date. now (), timeoutMs: SOURCE_CALL_TIMEOUT_MS };
75
76 function storefrontEndpoint ( origin ) { return new URL ( `/api/${ STOREFRONT_API_VERSION }/graphql.json` , `${ origin }/` ). toString (); }
77 function legacyId ( gid ) { const match = / \/ ( \d + ) $ / . exec ( text (gid)); return match ? match[ 1 ] : text (gid); }
78 function amount ( money ) { return money && money.amount != null ? String (money.amount) : null ; }
79 // Wait before retry n (1..6): a server-requested wait (delta-seconds or HTTP-date) is honoured but
80 // clamped to [0, 30 s]; otherwise min(30 s, 1 s * 2^n). An unparseable header falls back to the latter.
81 function retryWaitMs ( retry , retryAfterHeader , nowMs ) {
82 const exponential = Math. min ( SOURCE_MAX_BACKOFF_MS , 1000 * 2 ** retry);
83 const raw = retryAfterHeader == null ? '' : String (retryAfterHeader). trim ();
84 if ( ! raw) return exponential;
85 let requested = null ;
86 if ( / ^ \d + ( \. \d + ) ?$ / . test (raw)) requested = Number (raw) * 1000 ;
87 else { const date = Date. parse (raw); if (Number. isFinite (date)) requested = date - nowMs; }
88 return requested === null ? exponential : Math. min ( SOURCE_MAX_BACKOFF_MS , Math. max ( 0 , requested));
89 }
90 // Transient throttles, 5xx responses, dropped connections and hung calls are retried; anything else is data.
91 async function storefrontQuery ( endpoint , query , variables , io = defaultIo) {
92 let last = null ;
93 for ( let attempt = 0 ; attempt <= SOURCE_MAX_RETRIES ; attempt += 1 ) {
94 if (attempt) await io. sleep ( retryWaitMs (attempt, last.retryAfter, io. now ()));
95 let response;
96 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 ; }
97 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 ; }
98 let body = null ; try { body = bodyText ? JSON . parse (bodyText) : null ; } catch { /* shape checked below */ }
99 const errorCodes = (body && Array. isArray (body.errors) ? body.errors : []). map (( error ) => error && error.extensions && error.extensions.code). filter (Boolean);
100 const clean = response.ok && body && body.data && ! errorCodes. length && ! (body.errors || []). length ;
101 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 };
102 if (last.ok) return last;
103 const retryable = response.status === 429 || response.status >= 500 || errorCodes. some (( code ) => RETRYABLE_GRAPHQL_CODES . has (code));
104 if ( ! retryable) break ;
105 }
106 return { ... last, ok: false };
107 }
108 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 }; }
109 async function fetchConnection ( endpoint , query , key , first , io , variables = {}) {
110 const records = []; const pages = []; let after = null ;
111 for ( let page = 1 ; ; page += 1 ) {
112 const result = await storefrontQuery (endpoint, query, { ... variables, first, after }, io);
113 const connection = result.ok ? result.body.data[key] : null ;
114 if ( ! connection || ! Array. isArray (connection.nodes)) return blockedFetch (endpoint, key, page, result, records, pages);
115 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 });
116 if ( ! connection.pageInfo || ! connection.pageInfo.hasNextPage) break ;
117 after = connection.pageInfo.endCursor;
118 if (page >= 10000 ) return { ok: false , status: 508 , url: endpoint, responseShape: 'pagination_limit' , errorCodes: [], attempts: 0 , connection: key, page, records, pages };
119 await io. sleep ( SOURCE_REQUEST_DELAY_MS );
120 }
121 return { ok: true , url: endpoint, records, pages };
122 }
123 async function completeNested ( endpoint , node , field , io ) {
124 const first = node[field];
125 if ( ! first || ! first.pageInfo || ! first.pageInfo.hasNextPage) return { ok: true , nodes: first ? first.nodes : [] };
126 const nodes = [ ... first.nodes]; let after = first.pageInfo.endCursor;
127 while (after) {
128 await io. sleep ( SOURCE_REQUEST_DELAY_MS );
129 const result = await storefrontQuery (endpoint, NESTED_QUERIES [field], { id: node.id, after }, io);
130 const connection = result.ok && result.body.data.product ? result.body.data.product[field] : null ;
131 if ( ! connection) return { ok: false , status: result.status, responseShape: result.ok ? 'missing_connection' : result.responseShape, errorCodes: result.errorCodes || [], attempts: result.attempts };
132 nodes. push ( ... connection.nodes); after = connection.pageInfo.hasNextPage ? connection.pageInfo.endCursor : null ;
133 }
134 return { ok: true , nodes };
135 }
136 function toProductRow ( node , variants , images ) {
137 const options = (node.options || []). map (( option , index ) => ({ name: option.name, position: index + 1 , values: (option.optionValues || []). map (( value ) => value.name) }));
138 return {
139 id: legacyId (node.id), handle: node.handle, title: node.title, body_html: node.descriptionHtml || '' , vendor: node.vendor, product_type: node.productType,
140 tags: node.tags || [], published_at: node.publishedAt || null ,
141 images: images. map (( image ) => ({ src: image.url, alt: image.altText || null })),
142 options,
143 variants: variants. map (( variant ) => {
144 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 };
145 options. forEach (( option , index ) => { const selected = (variant.selectedOptions || []). find (( entry ) => entry.name === option.name); row[ `option${ index + 1 }` ] = selected ? selected.value : null ; });
146 return row;
147 }),
148 };
149 }
150 // Best effort: a failed or empty read leaves the regional values unknown, it never blocks the capture.
151 async function readLocalization ( endpoint , io ) {
152 const result = await storefrontQuery (endpoint, LOCALIZATION_QUERY , {}, io);
153 const localization = result.ok ? result.body.data.localization : null ;
154 if ( ! localization) return { ok: false , errorCodes: result.errorCodes || [] };
155 const country = localization.country || {};
156 return { ok: true , countryCode: text (country.isoCode) || null , currencyCode: text (country.currency && country.currency.isoCode) || null , languageCode: text (localization.language && localization.language.isoCode) || null };
157 }
158 async function captureSource ( origin , io = defaultIo) {
159 const endpoint = storefrontEndpoint (origin);
160 const productNodes = await fetchConnection (endpoint, PRODUCTS_QUERY , 'products' , PRODUCT_LIMIT , io);
161 const memberships = []; const membershipChecks = [];
162 const products = { ... productNodes, records: [] };
163 // A blocked product read ends the capture: nothing downstream can be trusted, and the retry budget is spent.
164 if ( ! productNodes.ok) return { products, collections: { ok: false , status: 0 , url: endpoint, responseShape: 'not_attempted' , errorCodes: [], attempts: 0 , records: [], pages: [] }, memberships, membershipChecks };
165 const collectionNodes = await fetchConnection (endpoint, COLLECTIONS_QUERY , 'collections' , PRODUCT_LIMIT , io);
166 const localization = await readLocalization (endpoint, io);
167 for ( const node of productNodes.records) {
168 const [ variants , images , collections ] = [ await completeNested (endpoint, node, 'variants' , io), await completeNested (endpoint, node, 'images' , io), await completeNested (endpoint, node, 'collections' , io)];
169 const incomplete = [[ 'variants' , variants], [ 'images' , images], [ 'collections' , collections]]. filter (([, result ]) => ! result.ok);
170 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 ; }
171 products.records. push ( toProductRow (node, variants.nodes, images.nodes));
172 for ( const collection of collections.nodes) memberships. push ({ collectionId: legacyId (collection.id), productId: legacyId (node.id) });
173 }
174 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 })) };
175 return { products, collections, memberships, membershipChecks, localization };
176 }
177 // A blocked read is recorded with everything an operator needs: the connection, the page, the
178 // last status, the GraphQL error codes and how many calls were made.
179 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 }; }
180 async function preflight ( projectDir , io = defaultIo) {
181 const { sourceUrl } = await context (projectDir); const data = await captureSource (sourceUrl, io);
182 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 });
183 const checks = [
184 readCheck ( 'product' , data.products, 'data.products.nodes[]' ),
185 readCheck ( 'collection' , data.collections, 'data.collections.nodes[]' ),
186 { 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 },
187 ... data.membershipChecks. filter (( check ) => ! check.ok). map (( check ) => ({ entity: 'product' , ... check })),
188 ];
189 const adapter = { id: CONTRACT .id, version: CONTRACT .version, fingerprint: hash ( CONTRACT ) };
190 const out = { schemaVersion: 1 , generatedAt: new Date (). toISOString (), adapter, sourceUrl, status: checks. every (( check ) => check.ok) ? 'passed' : 'blocked' , checks };
191 await writeJson (path. join (projectDir, 'quick-mode' , 'preflight.json' ), out);
192 if (out.status === 'passed' ) await writeJson (path. join (projectDir, 'quick-mode' , 'preflight-capture.json' ), { schemaVersion: 1 , generatedAt: out.generatedAt, adapter, sourceUrl, data });
193 return out;
194 }
195 // The storefront's language code is an enum (`HE`, `PT_BR`); the regional library decides whether it is valid.
196 function storefrontLanguage ( code ) {
197 if ( ! code) return undefined ;
198 const [ language , region ] = code. toLowerCase (). split ( '_' );
199 return { value: language, locale: region ? `${ language }-${ region . toUpperCase () }` : undefined , evidence: 'storefront localization.language.isoCode' };
200 }
201 // Writes discovery/source-locale.json from the capture `preflight` stored. Time zone is not on the storefront.
202 async function writeQuickRegional ( projectDir , generatedAt ) {
203 const capture = await readJsonIfPresent (path. join (projectDir, 'quick-mode' , 'preflight-capture.json' ));
204 const read = capture && capture.data ? capture.data.localization : null ;
205 const gap = ( evidence ) => ({ reason: read && read.ok === false ? 'unreadable' : 'not-exposed' , evidence });
206 const file = sourceRegional. buildSourceRegional ({
207 adapter: 'quick-shopify' ,
208 generatedAt,
209 currency: read && read.currencyCode ? { value: read.currencyCode, evidence: 'storefront localization.country.currency.isoCode' } : gap ( 'storefront localization' ),
210 timeZone: { reason: 'not-exposed' , evidence: 'the storefront API has no time zone' },
211 primaryLanguage: (read && storefrontLanguage (read.languageCode)) || gap ( 'storefront localization' ),
212 country: read && read.countryCode ? { value: read.countryCode, evidence: 'storefront localization.country.isoCode' } : gap ( 'storefront localization' ),
213 });
214 await sourceRegional. writeSourceRegional (projectDir, file);
215 }
216 async function plan ( projectDir ) {
217 const { sourceUrl } = await context (projectDir); const preflightResult = await readJson (path. join (projectDir, 'quick-mode' , 'preflight.json' ));
218 if (preflightResult.status !== 'passed' ) throw new Error ( 'quick mode preflight must pass before planning' );
219 const adapter = { id: CONTRACT .id, version: CONTRACT .version, fingerprint: hash ( CONTRACT ) };
220 const generatedAt = new Date (). toISOString ();
221 // Every excluded[] capability becomes a declared-exclusion coverage row, checked by the same
222 // reconcile rule as a full run before the plan is written.
223 const coverage = await sourceCoverage. writeQuickPlanCoverage (projectDir, CONTRACT , { rawText: await fs. readFile ( CONTRACT_PATH , 'utf8' ), generatedAt });
224 await writeQuickRegional (projectDir, generatedAt);
225 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 };
226 await writeJson (path. join (projectDir, 'quick-mode' , 'plan.json' ), out);
227 await fs. writeFile (path. join (projectDir, 'quick-mode' , 'plan.md' ), `# Shopify quick-mode plan \n\n Adapter: ${ adapter . id } ${ adapter . version } \n\n Imported: collections, products, variants, tags, media, and collection membership. \n\n Excluded: ${ CONTRACT . excluded . join ( ', ' ) }. \n ` );
228 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) });
229 return out;
230 }
231 async function extract ( projectDir , io = defaultIo) {
232 const { sourceUrl } = await context (projectDir); const planResult = await readJson (path. join (projectDir, 'quick-mode' , 'plan.json' ));
233 if (planResult.adapter.fingerprint !== hash ( CONTRACT )) throw new Error ( 'quick adapter contract changed; re-run preflight and plan' );
234 let data;
235 try {
236 const capture = await readJson (path. join (projectDir, 'quick-mode' , 'preflight-capture.json' ));
237 if (capture.adapter?.fingerprint === planResult.adapter.fingerprint && capture.sourceUrl === sourceUrl) data = capture.data;
238 } catch (error) { if (error.code !== 'ENOENT' ) throw error; }
239 if ( ! data) data = await captureSource (sourceUrl, io);
240 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' );
241 await writeNdjson ( file (projectDir, 'product' ), data.products.records);
242 const knownProductIds = new Set (data.products.records. map (( row ) => String (row.id)));
243 const edges = data.memberships. filter (( edge ) => knownProductIds. has (edge.productId));
244 // Only collections that contain an extracted product become categories: Wix Stores caps a
245 // site at 1,000 categories, and an empty source collection would spend a slot on nothing.
246 const populated = new Set (edges. map (( edge ) => edge.collectionId));
247 const collections = data.collections.records. filter (( row ) => populated. has ( String (row.id)));
248 await writeNdjson ( file (projectDir, 'collection' ), collections);
249 await writeNdjson ( file (projectDir, 'collection_product' ), edges);
250 const entities = {
251 product: { file: 'data/source-extract/product.ndjson' , recordCount: data.products.records. length , pages: data.products.pages },
252 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 },
253 collection_product: { file: 'data/source-extract/collection_product.ndjson' , recordCount: edges. length , sourceOnlyCount: data.memberships. length - edges. length },
254 };
255 const manifest = { schemaVersion: 1 , generatedAt: new Date (). toISOString (), managementImportMode: 'quick' , adapter: planResult.adapter, sourceUrl, entities };
256 await writeJson (path. join (projectDir, 'data' , 'source-extract' , 'manifest.json' ), manifest); return manifest;
257 }
258 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 }; }
259 // A token from config/wix.env renews itself for this site; a process-env override is used as given.
260 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 }) }; }
261 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; } }
262 function canonicalProduct ( row ) {
263 if ( ! text (row.id) || ! text (row.title) || ! text (row.handle)) throw new Error ( 'missing product id, title, or handle' );
264 if ( text (row.body_html). length > MAX_DESCRIPTION_LENGTH ) throw new Error ( 'description exceeds Wix limit' );
265 const names = (row.options || []). map (( option ) => text (option.name)). filter (( name ) => name && name. toLowerCase () !== 'title' );
266 if (names. length > 3 ) throw new Error ( 'more than three options' );
267 const variants = (row.variants || []). map (( variant ) => {
268 if ( ! text (variant.id) || ! validPrice (variant.price)) throw new Error ( 'invalid variant id or price' );
269 if ( typeof variant.available !== 'boolean' ) throw new Error ( 'variant availability must be explicit true or false' );
270 const choices = names. map (( optionName , index ) => ({ optionName, choiceName: text (variant[ `option${ index + 1 }` ]) })). filter (( choice ) => choice.choiceName);
271 if (choices. length !== names. length ) throw new Error ( 'variant option values do not match product options' );
272 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 };
273 });
274 if ( ! variants. length ) throw new Error ( 'product has no variants' );
275 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)) };
276 }
277 // Correlate by option/choice names resolved through target IDs, never response position or SKU.
278 const { resolveInventoryRows } = require ( '../../rp-target-wix/lib/variant-correlation' );
279
280 // LIVE-VERIFIED (2026-10-15): a product read immediately after its create can 404 although the
281 // product exists and is listed under the same id moments later. Retry only that 404, briefly;
282 // every other error, and a 404 that persists, still fails the row.
283 const READ_AFTER_CREATE_DELAYS_MS = [ 500 , 1500 , 3000 ];
284 async function readCreatedProduct ( writers , wix , id , delays = READ_AFTER_CREATE_DELAYS_MS ) {
285 for ( let attempt = 0 ; ; attempt += 1 ) {
286 try { return await writers. getStoresProductForVerification (wix, id); } catch (error) {
287 if ( ! / \b 404 \b / . test (error.message) || attempt >= delays. length ) throw error;
288 await sleep (delays[attempt]);
289 }
290 }
291 }
292
293 // Category capacity. Pure: callers decide where the answer is written.
294 // `existingCount` already includes the undeletable All Products category.
295 function categoryCapacity ({ existingCount , rows , seen , existingSlugs }) {
296 const toCreate = recovery. countCategoriesToCreate ({ rows, seen, existingSlugs, slugOf: toWixSlug });
297 return { toCreate, existingCount, max: recovery. STORES_CATEGORIES_MAX , exceeds: existingCount + toCreate > recovery. STORES_CATEGORIES_MAX };
298 }
299 // Live: the queried count, plus the built-in All Products category when the query did not return it
300 // under its canonical slug. If it comes back under another slug this refuses one slot early.
301 function liveExistingCount ( existingCategories ) { return existingCategories.size + (existingCategories. has ( 'all-products' ) ? 0 : 1 ); }
302 function assertCategoryCapacity ({ existingCount , rows , seen , existingSlugs }) {
303 const capacity = categoryCapacity ({ existingCount, rows, seen, existingSlugs });
304 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)` );
305 return capacity.toCreate;
306 }
307
308 // The payload and read-back comparison the import and the re-check share, so a re-check applies
309 // exactly the same rules the import did.
310 function buildProductPayload ( row , tagIds ) {
311 const canonical = canonicalProduct (row);
312 const payload = buildProduct (canonical);
313 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) });
314 payload.variantsInfo.variants. forEach (( variant ) => { if (variant.sku) variant.sku = recovery. truncate (variant.sku, recovery. STORES_SKU_MAX , `${ row . id }:${ variant . sku }` ); });
315 payload.tags = { publicTags: { tagIds: normalized.tagIds } };
316 // This endpoint creates only the product. Stock is written by the reconciler.
317 for ( const variant of payload.variantsInfo.variants) delete variant.inventoryItem;
318 return { canonical, payload, normalized };
319 }
320 function verifyWrittenProduct ( writers , { row , canonical , payload , fullTarget , targetId , siteId }) {
321 const correspondence = resolveInventoryRows (canonical, fullTarget);
322 const expected = { ... writers. buildCreateStoresProductRequest (payload).body.product };
323 delete expected.options; delete expected.variantsInfo;
324 const variants = correspondence. map (( entry , index ) => {
325 const fields = { ... payload.variantsInfo.variants[index] };
326 delete fields.choices;
327 return { variantId: entry.variantId, expected: fields };
328 });
329 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' });
330 return { receipt, correspondence };
331 }
332 const failedFieldsOf = ( receipt ) => [ ...new Set ((receipt.checks || []). filter (( check ) => ! check.matches). map (( check ) => check.path))];
333 function deriveStatus ( summary ) {
334 if (summary.dryRun) return 'simulated' ;
335 if (summary.inventory && summary.inventory.status === 'no-data' ) return 'no-data' ;
336 return summary.inventory && summary.inventory.complete && Object. values (summary.skipped || {}). every (( rows ) => ! rows. length ) ? 'complete' : 'partial' ;
337 }
338
339 // ---- Re-check of written-unverified products ----
340 const QM_DIR = 'quick-mode' ;
341 const REVERIFY_REQUEST = `${ QM_DIR }/reverify-request.json` ;
342 const REVERIFY_RECEIPT = `${ QM_DIR }/reverification.json` ;
343 const REVERIFY_STAGED = `${ QM_DIR }/reverification-staged.json` ;
344 const COMPLETION_REPORT = 'execution/completion-report.json' ;
345 async function readJsonIfPresent ( file ) { try { return await readJson (file); } catch (error) { if (error.code === 'ENOENT' ) return null ; throw error; } }
346 async function writeJsonAtomic ( file , value ) { await recovery. writeFileAtomic (file, `${ JSON . stringify ( value , null , 2 ) } \n ` ); }
347 async function removeIfPresent ( file ) { try { await fs. rm (file); } catch (error) { if (error.code !== 'ENOENT' ) throw error; } }
348 // A staged result whose request has no matching receipt is unfinished publication.
349 async function pendingReverification ( projectDir ) {
350 const request = await readJsonIfPresent (path. join (projectDir, REVERIFY_REQUEST ));
351 if ( ! request) return { request: null , receipt: null , staged: null , done: false };
352 const receipt = await readJsonIfPresent (path. join (projectDir, REVERIFY_RECEIPT ));
353 const staged = await readJsonIfPresent (path. join (projectDir, REVERIFY_STAGED ));
354 return { request, receipt, staged, done: Boolean (receipt && receipt.requestId === request.requestId) };
355 }
356 async function requestReverify ( projectDir ) {
357 const completion = await readJsonIfPresent (path. join (projectDir, COMPLETION_REPORT ));
358 if ( ! completion) throw new Error ( 'no live completion report: nothing to re-check' );
359 const pending = await pendingReverification (projectDir);
360 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' );
361 if ( ! ((completion.writtenUnverified && completion.writtenUnverified.product) || []). length ) throw new Error ( 'the completion report lists no written-unverified products: nothing to re-check' );
362 await removeIfPresent (path. join (projectDir, REVERIFY_STAGED ));
363 const request = { schemaVersion: 1 , requestId: crypto. randomUUID (), requestedAt: new Date (). toISOString () };
364 await writeJsonAtomic (path. join (projectDir, REVERIFY_REQUEST ), request);
365 return { status: 'requested' , requestId: request.requestId };
366 }
367 // Pure replay of the staged result. Every step SETS values (never removes items), so running it
368 // again after a crash at any boundary converges on the same bytes. The receipt is last.
369 async function finalizeReverification ( projectDir , staged , hooks = {}) {
370 if ( ! staged.verificationProduct) throw new Error ( 'staged re-check result omitted product verification evidence' );
371 const report = path. join (projectDir, COMPLETION_REPORT );
372 const completion = await readJson (report);
373 completion.writtenUnverified = { ... (completion.writtenUnverified || {}), product: staged.writtenUnverified };
374 completion.skipped = { ... (completion.skipped || {}), product: staged.skippedProduct };
375 completion.imported = { ... (completion.imported || {}), product: staged.importedProduct };
376 completion.verification = { ... (completion.verification || {}), product: staged.verificationProduct };
377 completion.status = staged.status;
378 catalogBaseline. quickReport (projectDir, completion);
379 await writeJsonAtomic (report, completion);
380 if (hooks.afterCompletionReport) await hooks. afterCompletionReport ();
381 await recovery. writeFinalReport (projectDir, completion, { dryRun: false });
382 if (hooks.afterFinalReport) await hooks. afterFinalReport ();
383 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 });
384 if (hooks.afterReceipt) await hooks. afterReceipt ();
385 await removeIfPresent (path. join (projectDir, REVERIFY_STAGED ));
386 return completion;
387 }
388 const httpStatusOf = ( error ) => { const match = / \b ( [1-5]\d\d ) \b / . exec (error && error.message || '' ); return match ? Number (match[ 1 ]) : 'error' ; };
389 async function reverify ( projectDir , { wix : injectedWix , writers : injectedWriters , hooks = {} } = {}) {
390 const writers = injectedWriters || require (path. join ( ADAPTER_DIR , '..' , 'rp-target-wix' , 'lib' , 'wix-writers.js' ));
391 const env = await loadWixEnv (projectDir);
392 if (writers. createDryRunConfig (env, process.argv. slice ( 2 )).dryRun) throw new Error ( 'reverify refuses a dry run: it re-checks the live import' );
393 const pending = await pendingReverification (projectDir);
394 if ( ! pending.request) throw new Error ( 'no re-check requested: run request-reverify first' );
395 if (pending.done) { await removeIfPresent (path. join (projectDir, REVERIFY_STAGED )); return { status: 'already_done' , requestId: pending.request.requestId }; }
396 // A staged result for this request is the whole answer: publish it, read nothing from Wix.
397 if (pending.staged && pending.staged.requestId === pending.request.requestId) { await finalizeReverification (projectDir, pending.staged, hooks); return { status: 'finalized' , requestId: pending.request.requestId }; }
398 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' );
399 const completion = await readJson (path. join (projectDir, COMPLETION_REPORT ));
400 const listed = (completion.writtenUnverified && completion.writtenUnverified.product) || [];
401 if ( ! listed. length ) throw new Error ( 'the completion report lists no written-unverified products: nothing to re-check' );
402 const wix = injectedWix || writers. createWixClient ({ dryRun: false , siteId: env. WIX_SITE_ID , projectDir, ... wixCredentials (projectDir, env, false ) });
403 const crosswalk = await loadCrosswalk (projectDir);
404 const tagIds = new Map (Object. entries (crosswalk.product_tag || {}). filter (([, id ]) => id));
405 const rows = new Map (( await readNdjson ( file (projectDir, 'product' ))). map (( row ) => [ String (row.id), row]));
406 const refreshedVerification = new Map (((completion.verification && completion.verification.product && completion.verification.product.rows) || []). map (( row ) => [ String (row.sourceKey), row]));
407 const cleared = [], remaining = [], errors = [], kept = [];
408 for ( const entry of listed) {
409 const sourceId = String (entry.sourceId);
410 try {
411 const row = rows. get (sourceId);
412 if ( ! row) throw new Error ( 'source product is no longer in the extract' );
413 const { canonical , payload } = buildProductPayload (row, tagIds);
414 // The post-create 404 retry is deliberately not shared here: an operator-requested
415 // re-check performs exactly one read per listed product.
416 const fullTarget = await writers. getStoresProductForVerification (wix, entry.targetId);
417 const { receipt } = verifyWrittenProduct (writers, { row, canonical, payload, fullTarget, targetId: entry.targetId, siteId: env. WIX_SITE_ID });
418 refreshedVerification. set (sourceId, { sourceKey: sourceId, targetId: entry.targetId, receipt });
419 if (receipt.verified) cleared. push (sourceId);
420 else { remaining. push (sourceId); kept. push ({ ... entry, failedFields: failedFieldsOf (receipt) }); }
421 } catch (error) { if (wixAuth. isRunInterruption (error)) throw error;
422 remaining. push (sourceId); kept. push (entry); errors. push ({ sourceId, status: httpStatusOf (error) });
423 if ( ! refreshedVerification. has (sourceId)) refreshedVerification. set (sourceId, { sourceKey: sourceId, targetId: entry.targetId, outcome: 'needs_verification' });
424 }
425 }
426 const skippedProduct = ((completion.skipped && completion.skipped.product) || []). filter (( row ) => ! cleared. includes ( String (row.sourceId)));
427 const verificationProduct = verificationState. report ([ ... refreshedVerification. values ()], env. WIX_SITE_ID );
428 const next = { ... completion, writtenUnverified: { ... (completion.writtenUnverified || {}), product: kept }, skipped: { ... (completion.skipped || {}), product: skippedProduct }, verification: { ... (completion.verification || {}), product: verificationProduct } };
429 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) };
430 if (hooks.beforeStaged) await hooks. beforeStaged ();
431 await writeJsonAtomic (path. join (projectDir, REVERIFY_STAGED ), staged);
432 if (hooks.afterStaged) await hooks. afterStaged ();
433 await finalizeReverification (projectDir, staged, hooks);
434 return { status: 'rechecked' , requestId: staged.requestId, cleared, remaining, errors };
435 }
436
437 async function importData ( projectDir ) {
438 const planResult = await readJson (path. join (projectDir, 'quick-mode' , 'plan.json' )); const manifest = await readJson (path. join (projectDir, 'data' , 'source-extract' , 'manifest.json' ));
439 importProgress. assertQuickImportAdapter ({ plan: planResult, manifest, current: { id: CONTRACT .id, version: CONTRACT .version, fingerprint: hash ( CONTRACT ) } });
440 // Fail closed before any write: a stamped plan with a missing or unreconciled coverage file
441 // must not produce a completion summary.
442 await sourceCoverage. quickSummaryCoverage (projectDir);
443 const productRows = await readNdjson ( file (projectDir, 'product' ));
444 if (productRows. length !== manifest.entities?.product?.recordCount) throw new Error ( 'Product extraction count differs from its manifest' );
445 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' );
446 const writers = require (path. join ( ADAPTER_DIR , '..' , 'rp-target-wix' , 'lib' , 'wix-writers.js' )); const dryRun = writers. createDryRunConfig (env, process.argv. slice ( 2 )).dryRun;
447 const quickRun = capabilities. startQuickRun ({ projectDir, adapterId: 'quick-shopify' , siteId: env. WIX_SITE_ID , dryRun });
448 // The site's currency is checked before an entity that writes money-bearing records gets its write
449 // capability. A refused entity gets no client and writes nothing; whatever depends on it is skipped
450 // too. The gate records its own row.
451 const regionalRefused = {};
452 const clientFor = async ( id ) => {
453 const entity = CONTRACT .entities. find (( row ) => row.id === id);
454 const upstream = entity.dependsOn. find (( dependency ) => regionalRefused[dependency]);
455 if (upstream) { regionalRefused[id] = 'DEPENDENCY_REFUSED' ; return null ; }
456 // Every other entity builds on the regional setup step: a held entity writes nothing (files only; no site read).
457 const dependency = siteRegional. regionalDependency ({ projectDir, siteId: env. WIX_SITE_ID , entityRefs: [entity.target, ... (entity.auxiliaryTargets || [])] });
458 if ( ! dependency.ok) { regionalRefused[id] = dependency.code; return null ; }
459 return writers. createWixClient ({ capability: capabilities. bindQuickEntity (quickRun, id), ... wixCredentials (projectDir, env, dryRun) });
460 };
461 const crosswalk = await loadCrosswalk (projectDir);
462 const remember = ( entity , sourceId , targetId ) => { if ( ! dryRun) { crosswalk[entity] ||= {}; crosswalk[entity][ String (sourceId)] = targetId; } }; const seen = ( entity , id ) => crosswalk[entity]?.[ String (id)];
463 const checkpoint = async () => { if ( ! dryRun) await writeJson (path. join (projectDir, 'state' , 'crosswalk' , 'quick-shopify.json' ), crosswalk); };
464 const excludedCollections = manifest.entities?.collection?.excluded || [];
465 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 };
466 const categoryIds = new Map ();
467 let wix = await clientFor ( 'collection' );
468 // Durable pre-write evidence, before the first destination create (idempotent: a refused first entity
469 // must not claim a start, and the marker must exist before whichever entity writes first).
470 let startedMarker = false ;
471 const markStarted = async () => { if ( ! dryRun && ! startedMarker) { startedMarker = true ; await importProgress. writeQuickImportStartedMarker (projectDir, planResult.adapter); } };
472 if (wix) {
473 const collectionRows = await readNdjson ( file (projectDir, 'collection' ));
474 // Category capacity: decided before the first write. A dry run cannot see the
475 // destination, so it applies the same rule with the built-in category as the floor.
476 // Categories that were on the store before this import still take a slot, but are
477 // never adopted for a source collection: a slug match on one is held as a collision.
478 const destinationCategories = dryRun ? [] : await writers. queryAllStoresCategories (wix);
479 const baselineCategoryIds = catalogBaseline. protectedIds (projectDir).categories;
480 const allCategorySlugs = new Map (destinationCategories. map (( category ) => [category.slug, category]));
481 const existingCategories = new Map (destinationCategories. filter (( category ) => ! baselineCategoryIds. has (category.id)). map (( category ) => [category.slug, category]));
482 const existingCount = dryRun ? 1 : liveExistingCount (allCategorySlugs);
483 const capacity = categoryCapacity ({ existingCount, rows: collectionRows, seen: dryRun ? () => false : ( id ) => seen ( 'collection' , id), existingSlugs: allCategorySlugs });
484 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' ) }) };
485 await writeJson (path. join (projectDir, dryRun ? 'execution/dry-run-category-capacity.json' : 'quick-mode/category-capacity.json' ), capacityRecord);
486 if (capacity.exceeds) {
487 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` );
488 Object. assign (summary, { status: 'blocked' , reason: 'category_capacity' , categoryCapacity: capacityRecord }); Object. assign (summary, await sourceCoverage. quickSummaryCoverage (projectDir));
489 await recovery. writeFinalReport (projectDir, summary, { dryRun: true }); await writeJson (path. join (projectDir, 'execution' , 'dry-run-completion-report.json' ), summary); return summary;
490 }
491 await markStarted ();
492 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 }); } }
493 summary.imported.collection = categoryIds.size;
494 capabilities. finishQuickEntity (quickRun, 'collection' );
495 }
496 const productIds = new Map (); const unverifiedTargets = new Map ();
497 wix = await clientFor ( 'product' );
498 if (wix) {
499 await markStarted ();
500 const existingTags = new Map ((( await wix. send (writers. buildListStoresProductTagsRequest ())).tags || []). map (( tag ) => [ text (tag.name). toLowerCase (), tag]));
501 const tagIds = new Map (); let tagLimitReached = false ;
502 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 (wixAuth. isRunInterruption (error)) throw 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 (); }
503 const catalog = await writers. queryAllStoresProducts (wix);
504 // Products that were on the store before this import are never adopted by slug.
505 const baselineProductIds = catalogBaseline. protectedIds (projectDir).products;
506 const heldProducts = new Map (catalog. filter ( product => baselineProductIds. has (product.id)). map ( product => [product.slug, product]));
507 const existingProducts = new Map (catalog. filter ( product => ! baselineProductIds. has (product.id)). map ( product => [product.slug, product]));
508 // Resolve collisions across the whole source set before any candidate product write.
509 // This pre-pass must call `toWixSlug` inside try/catch, like every other call site in this
510 // file -- otherwise one product whose handle sanitizes to a degenerate slug (e.g. an
511 // emoji/symbol-only handle, which `toWixSlug` rejects) crashes this `.map()` uncaught and
512 // aborts the ENTIRE quick-import run instead of being skipped for just that one row, same as
513 // every other per-row failure here.
514 const slugErrors = new Map ();
515 const identities = productRows. map ( row => {
516 let slug = null ;
517 try {
518 slug = toWixSlug (row.handle);
519 } catch (error) { if (wixAuth. isRunInterruption (error)) throw error;
520 slugErrors. set ( String (row.id), error.message);
521 }
522 return { sourceId: String (row.id), slug,
523 targetId: seen ( 'product' , row.id) || seen ( 'product_candidate' , row.id) || (slug !== null ? existingProducts. get (slug)?.id : undefined ) };
524 });
525 const ambiguousSources = new Set (identities. filter ( identity =>
526 identities. some ( other => other !== identity && (other.sourceId === identity.sourceId || other.slug === identity.slug || (identity.targetId && other.targetId === identity.targetId))) ||
527 catalog. filter ( product => product.slug === identity.slug). length > 1
528 ). map ( identity => identity.sourceId));
529 const inventoryRows = [], inventoryGaps = [], productVerification = [];
530 // Products that exist on the target with uniquely correlated variants but failed a field
531 // read-back (e.g. a description fragment Wix dropped). Stock and category membership depend
532 // only on product and variant identity, so they still apply; the product stays reported.
533 const expectedVariants = productRows. reduce (( count , row ) => count + Math. max ( 1 , (row.variants || []). length ), 0 );
534 for ( const row of productRows) {
535 let stockQueued = false ;
536 if ( ! dryRun && seen ( 'product' , row.id)) {
537 remember ( 'product_candidate' , row.id, seen ( 'product' , row.id));
538 delete crosswalk.product[ String (row.id)];
539 await checkpoint ();
540 }
541 try {
542 if (slugErrors. has ( String (row.id))) throw new Error (slugErrors. get ( String (row.id)));
543 if (ambiguousSources. has ( String (row.id))) throw new Error ( 'Ambiguous source product marker or target identity' );
544 // A saved target id is checked too: an earlier run may have adopted a baseline product.
545 const savedProduct = seen ( 'product' , row.id) || seen ( 'product_candidate' , row.id);
546 const heldProduct = savedProduct ? (baselineProductIds. has (savedProduct) && (catalog. find ( product => product.id === savedProduct) || { id: savedProduct })) : heldProducts. get ( toWixSlug (row.handle));
547 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); }
548 const { canonical , payload , normalized } = buildProductPayload (row, tagIds);
549 const knownId = seen ( 'product' , row.id) || seen ( 'product_candidate' , row.id);
550 const existing = knownId ? { id: knownId } : existingProducts. get (payload.slug);
551 const target = existing || await writers. createStoresProduct (wix, payload);
552 remember ( 'product_candidate' , row.id, target.id);
553 if ( ! dryRun && crosswalk.product) delete crosswalk.product[ String (row.id)];
554 await checkpoint ();
555 if (normalized.recoveries. length ) { if ( ! dryRun) await recovery. appendRecoveries (projectDir, planResult.adapter, row.id, normalized.recoveries); recovery. recordRecoveredRecord (summary, normalized.recoveries); }
556 if ( ! dryRun) {
557 // A post-write verification gate must not use the bare getStoresProduct read -- the same
558 // rule as at its sibling call site in product-import.js's verifyProductCandidate. The
559 // bare read omits plainDescription entirely and collapses media to `media.main` only (see
560 // getStoresProductForVerification's own comment in wix-writers.js), so `receipt.verified`
561 // below could never actually confirm either field, and a real loss there would pass
562 // silently instead of throwing.
563 const fullTarget = await readCreatedProduct (writers, wix, target.id);
564 const { receipt , correspondence } = verifyWrittenProduct (writers, { row, canonical, payload, fullTarget, targetId: target.id, siteId: env. WIX_SITE_ID });
565 writers. registerQuickInventoryScope (wix, canonical, fullTarget);
566 productVerification. push ({ sourceKey: String (row.id), targetId: target.id, receipt });
567 if ( ! receipt.verified) {
568 inventoryRows. push ( ... correspondence); stockQueued = true ; unverifiedTargets. set ( String (row.id), { targetId: target.id, name: canonical.name, failedFields: failedFieldsOf (receipt) });
569 throw new Error ( 'Product identity or values failed read-back' );
570 }
571 inventoryRows. push ( ... correspondence);
572 }
573 productIds. set ( String (row.id), target.id); remember ( 'product' , row.id, target.id); if ( ! dryRun) catalogBaseline. clearCollision (projectDir, 'stores/product' , row.id); await checkpoint ();
574 } catch (error) { if (wixAuth. isRunInterruption (error)) throw error;
575 if ( ! productVerification. some ( r => r.sourceKey === String (row.id))) productVerification. push ({ sourceKey: String (row.id), outcome: 'needs_verification' });
576 summary.skipped.product. push ({ sourceId: String (row.id), reason: error.message });
577 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 });
578 }
579 }
580 summary.verification = { product: dryRun ? { status: 'simulated' } : verificationState. report (productVerification, env. WIX_SITE_ID ) };
581 summary.inventory = dryRun ? { status: 'simulated' , complete: false , intended: expectedVariants }
582 : await inventory. reconcileInventory (wix, inventoryRows, { expectedCount: expectedVariants, gaps: inventoryGaps });
583 await inventory. persistInventoryReport (path. join (projectDir, dryRun ? 'execution/dry-run-inventory-report.json' : 'state/inventory-report.json' ), summary.inventory);
584 summary.status = deriveStatus (summary);
585 summary.imported.product = productIds.size;
586 summary.writtenUnverified = { product: [ ... unverifiedTargets]. map (([ sourceId , entry ]) => ({ sourceId, targetId: entry.targetId, name: entry.name, failedFields: entry.failedFields })) };
587 capabilities. finishQuickEntity (quickRun, 'product' );
588 }
589 wix = await clientFor ( 'collection_product' );
590 let memberships = 0 ;
591 if (wix) {
592 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 ; }
593 capabilities. finishQuickEntity (quickRun, 'collection_product' );
594 summary.imported.collection_product = memberships;
595 }
596 // A refusal means the run did not import everything: it is never reported complete.
597 if ( ! summary.status) summary.status = deriveStatus (summary); else if ( ! dryRun && Object. keys (regionalRefused). length ) summary.status = 'partial' ;
598 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;
599 }
600 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 ` ); }
601 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 ]))); });
602
603 module . exports = { canonicalProduct, resolveInventoryRows, assertCategoryCapacity, categoryCapacity, readCreatedProduct, importData, storefrontQuery, retryWaitMs, captureSource, preflight, extract, plan, requestReverify, reverify, finalizeReverification, deriveStatus };