Setting the file. One moment.
Product Import · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
Next
Bundled file Product SEO
lib/ product-import.js
JavaScript · 202 lines · 16 KB
require
(
'../../../lib/local-state'
);
8 const seo = require ( './product-seo' );
9 const catalogBaseline = require ( '../../../lib/catalog-baseline' );
10 const at = ( object , path ) => path. split ( '.' ). reduce (( value , key ) => value?.[key], object);
11 const pause = ms => new Promise ( resolve => setTimeout (resolve, ms));
12
13 // Match source-fixed variant projections, never the order of a returned array. The
14 // mapper chooses unique SKU/choice projections before execution; ambiguity is a gap. Returned
15 // choice ids are resolved to names through the product's option dictionary before comparison.
16 function resolveVariants ( expectations , product ) {
17 const seen = new Set ();
18 return expectations. map ( row => {
19 if ( ! row.sourceKey || ! row.expected || ! Object. keys (row.expected). length ) throw new Error ( 'Source variant correspondence is required' );
20 const matches = (product?.variantsInfo?.variants || []). filter ( variant => v. compareProjection (row.expected, v. variantComparisonView (variant, product)).verified);
21 if (matches. length !== 1 || ! matches[ 0 ].id || seen. has (matches[ 0 ].id)) throw new Error ( 'Ambiguous source-to-target variant correspondence' );
22 seen. add (matches[ 0 ].id);
23 return { ... row, variantId: matches[ 0 ].id };
24 });
25 }
26 async function verifyProductCandidate ( wix , { siteId , sourceKey , expected , targetId , marker , variantExpectations , verifyAttempts = 3 , sleep = pause }) {
27 let receipt, error;
28 for ( let attempt = 1 ; attempt <= verifyAttempts; attempt ++ ) {
29 try {
30 // getStoresProduct's bare GET omits plainDescription entirely and collapses media to
31 // `media.main` only (see getStoresProductForVerification's comment in wix-writers.js) --
32 // a verification read needs both fields or it can never confirm what create actually wrote.
33 const actual = await w. getStoresProductForVerification (wix, targetId);
34 const identityConflict = actual?.id !== targetId || at (actual, marker.path) !== marker.value;
35 if (identityConflict) return { sourceKey, targetId, identityConflict: true , outcome: 'needs_verification' , error: 'Product identity does not agree' };
36 const variants = resolveVariants (variantExpectations, actual);
37 receipt = v. receipt ({ siteId, sourceKey, kind: 'product' , expected, targetId, actual, marker, variants, operation: 'product-import' , attempts: attempt });
38 if (receipt.verified) return { targetId, sourceKey, receipt, outcome: 'verified' , variants };
39 } catch (e) { if (wixAuth. isRunInterruption (e)) throw e; error = e.message; }
40 if (attempt < verifyAttempts) await sleep ( 100 * attempt);
41 }
42 return { sourceKey, targetId, receipt, outcome: 'needs_verification' , error: error || 'Product identity or values do not agree' };
43 }
44 // A row saved by an older toolkit kept `uncertain` on a create that never ran: the send was
45 // refused before any byte left (DEPENDENCY_READINESS_REQUIRED), or Wix answered 429, which it does
46 // before processing. That saved error proves nothing was written, so a resume creates the product instead
47 // of holding it as uncertain forever. The catalog sweep still adopts one that exists by marker.
48 const NOT_WRITTEN_ERROR = / ^ (?:DEPENDENCY_READINESS_REQUIRED $| POST https: \/\/ www \. wixapis \. com \/ stores \/ v3 \/ bulk \/ products-with-inventory \/ create → 429:)/ ;
49 const savedUncertain = row => Boolean (row?.uncertain) && ! ( ! row.targetId && NOT_WRITTEN_ERROR . test ( String (row.error || '' )));
50 async function importProducts ( wix , { projectDir , siteId , rows , verifyAttempts = 3 , sleep = pause, dryRun = false , seoConflictDecisions = {}, seoSleep = null , seoPacer = null }) {
51 if ( ! siteId || ! projectDir || ! Array. isArray (rows) || ! Number. isInteger (verifyAttempts) || verifyAttempts < 1 || verifyAttempts > 5 ) throw new Error ( 'Invalid product import arguments' );
52 if (rows. some ( r => ! r.sourceKey || ! r.expected || ! Object. keys (r.expected). length || ! r.marker?.path || r.marker.value == null || r.marker.value === '' || ! Array. isArray (r.variantExpectations) || ! r.variantExpectations. length ) || new Set (rows. map ( r => r.sourceKey)).size !== rows. length ) throw new Error ( 'Product source identity, projection and variant expectations are required' );
53 // A caller cannot omit a submitted field from verification merely to make it pass.
54 for ( const row of rows) {
55 v. assertProjection (row.expected);
56 for ( const variant of row.variantExpectations) v. assertProjection (variant.expected, 'variant.expected' );
57 // A row carrying an SEO plan has its seoData verified per field by the SEO stage
58 // (product read-back AND resolvedTags), so it is kept out of the generic comparator: a create
59 // that drops seoData must not fail catalog identity, it must be reconciled by a guarded PATCH.
60 if (row.seo) {
61 if ( ! row.seo.fields || ! Array. isArray (row.seo.tags)) throw new Error ( 'row.seo must be a planProductSeo() plan' );
62 if ( Object . prototype .hasOwnProperty. call (row.expected, 'seoData' )) throw new Error ( 'seoData is verified by the SEO stage; omit it from expected when row.seo is set' );
63 if (seo. canonicalJson (row.product?.seoData?.tags || []) !== seo. canonicalJson (row.seo.tags)) throw new Error ( 'product.seoData must equal the SEO plan tags' );
64 }
65 for ( const key of Object. keys (row.product || {})) if (key !== 'variantsInfo' && ! (key === 'seoData' && row.seo) && ! Object . prototype .hasOwnProperty. call (row.expected, key)) throw new Error ( `Missing product verification field: ${ key }` );
66 // A product written with modifiers carries its selection route, persisted on its verification
67 // row so the cutover storefront check can list modifier-route products.
68 const modifierCount = Array. isArray (row.product?.modifiers) ? row.product.modifiers. length : 0 ;
69 if (row.selectionRoute != null || modifierCount) {
70 const sr = row.selectionRoute;
71 if ( ! sr || ! [ 'none' , 'variants' , 'modifiers' , 'hybrid' ]. includes (sr.route) || ! Array. isArray (sr.modifiers) || sr.modifiers. length !== modifierCount
72 || (modifierCount > 0 ) !== (sr.route === 'modifiers' || sr.route === 'hybrid' )) throw new Error ( 'row.selectionRoute must be the routeRecord() of the product (route and one entry per modifier)' );
73 }
74 const sourceVariants = row.product?.variantsInfo?.variants;
75 if ( ! Array. isArray (sourceVariants) || sourceVariants. length !== row.variantExpectations. length || new Set (row.variantExpectations. map ( r => r.sourceKey)).size !== sourceVariants. length ) throw new Error ( 'Complete source variant expectations required' );
76 sourceVariants. forEach (( variant , index ) => {
77 for ( const key of Object. keys (variant)) if (key !== 'inventoryItem' && ! Object . prototype .hasOwnProperty. call (row.variantExpectations[index].expected, key)) throw new Error ( `Missing variant verification field: ${ key }` );
78 });
79 }
80 if (dryRun) {
81 const seoRows = rows. filter ( r => r.seo). map ( r => ({ sourceKey: r.sourceKey, plan: r.seo }));
82 const seoPreview = seoRows. length ? seo. previewProductSeo (seoRows, await seo. loadSeoState (projectDir, siteId)) : [];
83 return { siteId, intended: rows. length , status: 'simulated' , verified: 0 , rows: [], seoPreview };
84 }
85 return local. withStateLock (projectDir, async () => {
86 const state = await stateIO. load (projectDir, siteId), crosswalk = await local. loadCrosswalk (projectDir);
87 // A product this record's own earlier create left under a Wix-minted slug: adopt it (the writer
88 // admits it only for this record client, unclaimed, never verified, revision 1, same name) and
89 // set its slug to the marker, so the reconciliation below finds it by marker instead of
90 // reporting an identity conflict on every resume. Never creates a product.
91 for ( const r of rows) {
92 const saved = state.rows[r.sourceKey];
93 if ( ! saved?.identityConflict || ! saved.targetId || crosswalk.bySource[r.sourceKey] || r.marker?.path !== 'slug' || ! r.marker.value) continue ;
94 let adopted = null ;
95 try { adopted = await w. admitSavedProductTarget (wix, { projectDir, siteId, expectedName: r.product?.name }); }
96 catch (e) { if (e?.code === 'CAPABILITY_ENTITY_MISMATCH' ) continue ; throw e; }
97 if ( ! adopted || adopted.id !== saved.targetId || adopted.name !== r.product?.name) continue ;
98 if (adopted.slug !== r.marker.value) await w. patchStoresProduct (wix, { productId: adopted.id, revision: adopted.revision, slug: r.marker.value });
99 r.adoptedSavedTarget = adopted.id;
100 }
101 const work = rows. map ( r => ({ ... r, baseline: crosswalk.bySource[r.sourceKey] || state.rows[r.sourceKey]?.baseline || null , targetId: state.rows[r.sourceKey]?.targetId || crosswalk.bySource[r.sourceKey]?.targetId, uncertain: savedUncertain (state.rows[r.sourceKey]) }));
102 let catalog;
103 try { catalog = await w. queryAllStoresProducts (wix); }
104 catch (e) { if (wixAuth. isRunInterruption (e)) throw e; for ( const r of work) r.error = `Product reconciliation read failed: ${ e . message }` ; }
105 // A product that was on the store before this import is never a match: taking it
106 // over would report a sample or merchant product as this source record.
107 const baselineIds = catalogBaseline. protectedIds (projectDir).products;
108 for ( const r of work) {
109 if (r.error) continue ;
110 // A saved target id (crosswalk or state) is checked too: an earlier run may have adopted one.
111 const held = (r.targetId && baselineIds. has (r.targetId) ? catalog. find ( p => p.id === r.targetId) || { id: r.targetId } : null )
112 || catalog. find ( p => baselineIds. has (p.id) && at (p, r.marker.path) === r.marker.value);
113 if (held) { r.error = catalogBaseline. collisionError ( 'stores/product' , held.id).message; r.identityConflict = true ; catalogBaseline. recordCollision (projectDir, { entity: 'stores/product' , sourceKey: r.sourceKey, targetId: held.id, slug: held.slug ?? null }); continue ; }
114 const matches = catalog. filter ( p => at (p, r.marker.path) === r.marker.value);
115 if (matches. length > 1 || (r.targetId && matches. some ( p => p.id !== r.targetId))) { r.error = 'Ambiguous product marker or crosswalk' ; r.identityConflict = true ; }
116 else if ( ! r.targetId && matches. length ) r.targetId = matches[ 0 ].id;
117 else if ( ! r.targetId && r.uncertain) r.error = 'Previous product write is uncertain; reconcile before retry' ;
118 if (work. some ( other => other !== r && other.marker.path === r.marker.path && other.marker.value === r.marker.value)) { r.error = 'Duplicate source product marker' ; r.identityConflict = true ; }
119 if (r.targetId && crosswalk.rows. some ( c => c.sourceStableKey !== r.sourceKey && c.targetSystem === 'wix' && c.targetId === r.targetId)) { r.error = 'Product target belongs to another source key' ; r.identityConflict = true ; }
120 }
121 for ( const r of work) if (r.targetId && work. some ( other => other !== r && other.targetId === r.targetId)) { r.error = 'Distinct source keys share a target product' ; r.identityConflict = true ; }
122 const save = r => {
123 const previous = state.rows[r.sourceKey];
124 const lastVerifiedReceipt = v. validReceipt (previous?.receipt) ? previous.receipt : previous?.lastVerifiedReceipt || null ;
125 state.rows[r.sourceKey] = { sourceKey: r.sourceKey, targetId: r.targetId || null , baseline: r.baseline, identityConflict: Boolean (r.identityConflict),
126 uncertain: Boolean (r.uncertain), outcome: r.outcome || 'needs_verification' , error: r.error || null , receipt: r.receipt || null , lastVerifiedReceipt,
127 ... (r.selectionRoute ? { selectionRoute: r.selectionRoute } : {}) };
128 };
129 for ( const r of work) save (r);
130 await stateIO. checkpoint (projectDir, state, work);
131 // Batch by every API cap, not only product count.
132 const batches = []; let batch = [], cost = {};
133 for ( const r of work. filter ( r => ! r.error && ! r.targetId)) {
134 const next = { records: 1 , ... w. storesProductBulkCost (r.product) };
135 if (Object. entries (next). some (([ key , n ]) => n > w. BULK_PRODUCT_LIMITS [key])) { r.error = 'Product exceeds bulk limits' ; continue ; }
136 if (batch. length && Object. entries (next). some (([ key , n ]) => (cost[key] || 0 ) + n > w. BULK_PRODUCT_LIMITS [key])) { batches. push (batch); batch = []; cost = {}; }
137 batch. push (r); for ( const [ key , n ] of Object. entries (next)) cost[key] = (cost[key] || 0 ) + n;
138 }
139 if (batch. length ) batches. push (batch);
140 for ( const group of batches) {
141 for ( const r of group) { r.uncertain = true ; save (r); }
142 await stateIO. checkpoint (projectDir, state, group);
143 try {
144 const result = await w. bulkCreateStoresProductsWithInventory (wix, group. map ( r => r.product));
145 for ( const outcome of result.results) if (outcome.correlated) {
146 const r = group[outcome.index];
147 if (outcome.success) { r.targetId = outcome.productId; r.uncertain = false ; r.confirmedCreate = true ; }
148 else { r.error = outcome.errorCode || 'Product create failed' ; r.uncertain = false ; }
149 save (r);
150 }
151 for ( const index of result.unresolvedIndexes) { group[index].error = 'Unresolved bulk product result' ; save (group[index]); }
152 } catch (e) {
153 if (wixAuth. isRunInterruption (e)) throw e;
154 for ( const r of group) {
155 r.error = e.message;
156 // Refused before sending (one bulk request): nothing was written, so nothing is uncertain.
157 if (e && e.requestSent === false ) r.uncertain = false ;
158 // Still rate-limited after the client's retries: Wix refused it before processing.
159 if (e && e.requestApplied === false ) r.uncertain = false ;
160 save (r);
161 }
162 }
163 await stateIO. checkpoint (projectDir, state, group);
164 }
165 for ( const r of work) if (r.targetId && work. some ( other => other !== r && other.targetId === r.targetId)) { r.error = 'Distinct source keys share a target product' ; r.identityConflict = true ; }
166 const publications = [];
167 let verifiedBatch = [];
168 for ( const r of work) {
169 if ( ! r.error && r.targetId) Object. assign (r, await verifyProductCandidate (wix, { ... r, siteId, verifyAttempts, sleep }));
170 save (r);
171 if (r.receipt?.verified && ! r.error) catalogBaseline. clearCollision (projectDir, 'stores/product' , r.sourceKey);
172 if (r.receipt?.verified && ! r.error) publications. push ({ row: { schemaVersion: 1 , sourceSystem: r.sourceSystem || 'source' , sourceEntityType: r.sourceEntityType || 'product' , sourceId: stateIO. crosswalkSourceId (r),
173 sourceStableKey: r.sourceKey, targetSystem: 'wix' , targetEntityType: 'product' , targetId: r.targetId, status: 'imported' }, receipt: r.receipt, options: r });
174 verifiedBatch. push (r);
175 if (verifiedBatch. length === 100 ) { await stateIO. checkpoint (projectDir, state, verifiedBatch); verifiedBatch = []; }
176 }
177 await stateIO. checkpoint (projectDir, state, verifiedBatch);
178 await stateIO. persist (projectDir, state);
179 await stateIO. publishMany (projectDir, publications, work. filter ( r => r.identityConflict). map ( r => r.sourceKey));
180 const report = stateIO. report (work. map ( r => state.rows[r.sourceKey]), siteId);
181 // Product SEO after the product exists and its identity is verified. SEO
182 // failures add stage residuals; they never un-import the product.
183 const seoRows = work. filter ( r => r.seo && ! r.error && r.targetId && r.receipt?.verified). map ( r => ({
184 sourceKey: r.sourceKey, targetId: r.targetId, plan: r.seo,
185 // The revision this import owns: a product created or first adopted in this run starts from
186 // its verified read; a previously imported one from its crosswalk baseline (none = review).
187 savedRevision: r.confirmedCreate || ! r.baseline ? r.receipt.actual?.revision ?? null : r.baseline.targetRevision ?? null ,
188 }));
189 if (work. some ( r => r.seo)) {
190 report.seo = seoRows. length ? await seo. reconcileProductSeo ({ projectDir, siteId, rows: seoRows, conflictDecisions: seoConflictDecisions, sleep: seoSleep || sleep, pacer: seoPacer || w. sharedTargetReadPacer (),
191 readProduct : id => w. getStoresProductForVerification (wix, id),
192 patchProduct : payload => w. patchStoresProduct (wix, payload),
193 readItemSeoTags : id => w. getItemSeoTags (wix, seo. STORES_PRODUCT_ITEM_TYPE , id) }) : { siteId, results: [], stageResiduals: [] };
194 // Products whose identity did not verify still owe their held/accepted SEO rows.
195 const reconciled = new Set (seoRows. map ( r => r.sourceKey));
196 const unreconciled = work. filter ( r => r.seo && ! reconciled. has (r.sourceKey)). map ( r => ({ sourceKey: r.sourceKey, residuals: r.seo.residuals }));
197 report.seo.stageResiduals = seo. collectSeoStageResiduals ([ ... report.seo.results, ... unreconciled]);
198 }
199 return report;
200 });
201 }
202 module . exports = { resolveVariants, verifyProductCandidate, importProducts };