Setting the file. One moment.
Stores Category Attach · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
This file
Number 41.165
Position 165 of 174
Type JavaScript
Size 12 KB
Lines 224 lib/ stores-category-attach.js
JavaScript · 224 lines · 12 KB
14 // step at all, so the loss was invisible end to end: no retro, no QA, no completion-report
15 // mismatch, ever. This file exists so that failure mode cannot recur unseen: the write call below
16 // takes the exact shape the writer declares, and nothing is marked attached until a fresh read
17 // confirms it.
18 //
19 // `bulkAddItemToCategories` is an ADD, not a replace/set -- a product may legitimately carry other
20 // category memberships from elsewhere (or from an earlier partial run). The read-back check below
21 // only ever confirms "every expected categoryId is present in the actual set", never exact
22 // equality; never do a set-equality check here.
23
24 const w = require ( './wix-writers.js' );
25 const wixAuth = require ( '../../../lib/wix-auth.js' );
26
27 const OUTCOMES = [ 'category-attach-written' , 'category-attach-reconciled-existing' , 'category-attach-unverified' , 'category-attach-failed' ];
28
29 function identity ( value , name ) {
30 if ( typeof value !== 'string' || ! value. trim ()) throw new Error ( `Category attachment requires ${ name }` );
31 return value;
32 }
33
34 // A row exists only for a product that needs at least one category attached. A product with
35 // nothing to attach is never given a row at all -- it is not a gap, there is nothing orthogonal
36 // to track -- so an empty/missing categoryIds array is a caller bug, not a valid empty state.
37 function categoryIdsOf ( value ) {
38 if ( ! Array. isArray (value) || ! value. length ) {
39 throw new Error ( 'Category attachment requires at least one categoryId; a product with nothing to attach should not be passed as a row' );
40 }
41 return [ ...new Set (value. map (( id , index ) => identity (id, `categoryIds[${ index }]` )))];
42 }
43
44 function hasAllCategories ( actual , expected ) {
45 if ( ! actual) return false ;
46 return expected. every (( id ) => actual. has (id));
47 }
48
49 // VERIFIED (2026-08-12, wix-writers.js queryAllStoresProducts): `fields: ['DIRECT_CATEGORIES_INFO']`
50 // as a sibling of `query` (not nested inside it) on POST /stores/v3/products/query returns
51 // `directCategoriesInfo.categories[]` per product; a plain query omits categories entirely. The
52 // per-item shape of that array is not itself pinned down by that verification, so this treats an
53 // entry as either a bare category-id string or an `{ id }` object and takes whichever is present
54 // -- defensive, not a guess that silently drops a real id either way.
55 async function queryOneProductCategories ( wix , productId ) {
56 const response = await wix. send (w. buildQueryStoresProductsRequest (
57 { filter: { id: { $eq: productId } }, cursorPaging: { limit: 1 } },
58 [ 'DIRECT_CATEGORIES_INFO' ],
59 ));
60 if ( ! response || ! Array. isArray (response.products)) throw new Error ( 'Malformed category query response' );
61 const product = response.products. find (( p ) => p && p.id === productId);
62 if ( ! product) return null ;
63 const categories = (product.directCategoriesInfo && Array. isArray (product.directCategoriesInfo.categories))
64 ? product.directCategoriesInfo.categories : [];
65 return new Set (categories. map (( c ) => ( typeof c === 'string' ? c : c && c.id)). filter (Boolean));
66 }
67
68 // Best-effort only: a failed or inconclusive pre-read never blocks the write path, it only costs
69 // the no-write optimization below. Correctness lives entirely in the post-write verify step.
70 async function preCheckExisting ( wix , rows ) {
71 const pending = rows. slice ();
72 let index = 0 ;
73 async function worker () {
74 while (index < pending. length ) {
75 const row = pending[index ++ ];
76 try {
77 const actual = await queryOneProductCategories (wix, row.productId);
78 if ( hasAllCategories (actual, row.categoryIds)) row.alreadySatisfied = true ;
79 } catch (error) { if (wixAuth. isRunInterruption (error)) throw error; /* unknown -- fall through to the write path */ }
80 }
81 }
82 await Promise . all (Array. from ({ length: Math. min ( 4 , pending. length ) }, () => worker ()));
83 }
84
85 // A write receipt is never proof -- see stores-inventory.js's verifyCatalogInventory for the same
86 // discipline applied to stock. Bounded retries, worker-pool concurrency, independent per product.
87 async function verifyCategoryAttachment ( wix , rows , { verifyAttempts = 3 , sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms)) } = {}) {
88 const pending = rows. slice ();
89 let index = 0 ;
90 async function worker () {
91 while (index < pending. length ) {
92 const row = pending[index ++ ];
93 for ( let attempt = 0 ; attempt < verifyAttempts; attempt ++ ) {
94 try {
95 const actual = await queryOneProductCategories (wix, row.productId);
96 if ( hasAllCategories (actual, row.categoryIds)) {
97 row.outcome = 'category-attach-written' ;
98 delete row.error;
99 break ;
100 }
101 row.outcome = 'category-attach-unverified' ;
102 row.error = actual ? 'Category read-back is missing one or more expected categoryIds' : 'Product missing from category read-back' ;
103 } catch (error) {
104 if (wixAuth. isRunInterruption (error)) throw error;
105 row.outcome = 'category-attach-unverified' ;
106 row.error = error.message;
107 }
108 if (attempt + 1 < verifyAttempts) await sleep ( 250 * (attempt + 1 ));
109 }
110 }
111 }
112 await Promise . all (Array. from ({ length: Math. min ( 4 , pending. length ) }, () => worker ()));
113 }
114
115 // rows: [{ productId, categoryIds, sourceKey? }] -- one row per product needing at least one
116 // category attached. Never swallows a write error silently: every per-row failure is caught,
117 // recorded with its message, and left `failed` rather than disappearing into a wrapping catch.
118 async function reconcileCategoryAttachment ( wix , rows , { verifyAttempts = 3 , sleep = ( ms ) => new Promise (( r ) => setTimeout (r, ms)) } = {}) {
119 if ( ! Array. isArray (rows)) throw new Error ( 'Category attachment rows must be an array' );
120 if ( ! Number. isInteger (verifyAttempts) || verifyAttempts < 1 || verifyAttempts > 5 ) throw new Error ( 'verifyAttempts must be 1-5' );
121 const seen = new Set ();
122 const normalized = rows. map (( row , index ) => {
123 if ( ! row || typeof row !== 'object' || Array. isArray (row)) throw new Error ( `Category attachment rows[${ index }] must be an object` );
124 const productId = identity (row.productId, 'productId' );
125 if (seen. has (productId)) throw new Error ( 'Duplicate input product identity' );
126 seen. add (productId);
127 const categoryIds = categoryIdsOf (row.categoryIds);
128 const sourceKey = row.sourceKey === undefined || row.sourceKey === null ? null : identity (row.sourceKey, 'sourceKey' );
129 return { productId, categoryIds, sourceKey, outcome: 'category-attach-unverified' };
130 });
131 if ( ! normalized. length ) return buildCategoryAttachReport (normalized);
132
133 // Step 1: skip the write for a product whose existing categories already satisfy the intended
134 // set -- the "reconciled-existing" twin of stores-inventory's no-write rerun.
135 await preCheckExisting (wix, normalized);
136 const pending = [];
137 for ( const row of normalized) {
138 if (row.alreadySatisfied) { row.outcome = 'category-attach-reconciled-existing' ; delete row.alreadySatisfied; }
139 else pending. push (row);
140 }
141
142 // Step 2: write. The endpoint takes exactly ONE item plus its category ids -- never a batch of
143 // products -- so a per-row try/catch IS the correlation; there is no bulk response to misread.
144 // This is the exact call the failure above got wrong: { productId, categoryIds }, nothing else.
145 const written = [];
146 for ( const row of pending) {
147 try {
148 await w. bulkAddItemToCategories (wix, { productId: row.productId, categoryIds: row.categoryIds });
149 written. push (row);
150 } catch (error) {
151 if (wixAuth. isRunInterruption (error)) throw error;
152 row.outcome = 'category-attach-failed' ;
153 row.error = error.message;
154 }
155 }
156
157 // Step 3: read-back verify. Never mark a row attached off the write call's own success alone.
158 await verifyCategoryAttachment (wix, written, { verifyAttempts, sleep });
159
160 return buildCategoryAttachReport (normalized);
161 }
162
163 function buildCategoryAttachReport ( rows ) {
164 const counts = Object. fromEntries ( OUTCOMES . map (( key ) => [key, 0 ]));
165 for ( const row of rows) {
166 if ( ! OUTCOMES . includes (row.outcome)) throw new Error ( 'Unknown category-attach outcome' );
167 counts[row.outcome] ++ ;
168 }
169 return {
170 rows,
171 counts,
172 complete: rows. length > 0 && counts[ 'category-attach-failed' ] === 0 && counts[ 'category-attach-unverified' ] === 0 ,
173 status: rows. length === 0 ? 'no-data' : (counts[ 'category-attach-failed' ] || counts[ 'category-attach-unverified' ]) ? 'partial' : 'complete' ,
174 };
175 }
176
177 async function persistCategoryAttachReport ( file , report ) {
178 const fs = require ( 'node:fs/promises' );
179 const path = require ( 'node:path' );
180 const crypto = require ( 'node:crypto' );
181 await fs. mkdir (path. dirname (file), { recursive: true });
182 const temp = `${ file }.${ crypto . randomUUID () }.tmp` ;
183 try {
184 await fs. writeFile (temp, `${ JSON . stringify ( report , null , 2 ) } \n ` , { mode: 0o600 });
185 await fs. rename (temp, file);
186 } finally { await fs. rm (temp, { force: true }); }
187 }
188
189 // Turns a buildCategoryAttachReport() result into stageResiduals[] rows -- the orthogonal-axis
190 // mechanism completion-report.js already applies to orders' payment/refund/invoice sub-stages
191 // (see its doctrine comment above STAGE_RESIDUAL_OUTCOMES). A product whose import the entity row
192 // already counts as `imported` can still carry zero of its categories; the completeness arithmetic
193 // cannot see that on its own, so this is what makes it visible. Only ever emitted for the two
194 // failure-shaped outcomes -- a product that attached cleanly, or already carried its categories,
195 // contributes no residual row at all.
196 //
197 // `sourceKey` is optional on an input row (see reconcileCategoryAttachment's normalization), but a
198 // residual group must always carry SOME identifier a human/operator can act on -- a nonzero
199 // `count` with an empty `sourceKeys[]` is unactionable. `productId` is always present on every
200 // row (it is required, never optional), so it is the fallback identifier when no sourceKey was
201 // supplied: a target id is still enough to act on, and silently dropping it would be strictly
202 // worse.
203 function collectCategoryAttachStageResiduals ( report , { entity = 'product' , stage = 'category-attach' } = {}) {
204 if ( ! report || ! Array. isArray (report.rows)) throw new Error ( 'category-attach report rows must be an array' );
205 const groups = new Map ();
206 for ( const row of report.rows) {
207 if (row.outcome !== 'category-attach-unverified' && row.outcome !== 'category-attach-failed' ) continue ;
208 if ( ! groups. has (row.outcome)) groups. set (row.outcome, { entity, stage, outcome: row.outcome, count: 0 , sourceKeys: new Set () });
209 const group = groups. get (row.outcome);
210 group.count += 1 ;
211 group.sourceKeys. add (row.sourceKey || row.productId);
212 }
213 return [ ... groups. values ()]. map (( row ) => ({ ... row, sourceKeys: [ ... row.sourceKeys] }));
214 }
215
216 module . exports = {
217 OUTCOMES,
218 hasAllCategories,
219 queryOneProductCategories,
220 reconcileCategoryAttachment,
221 buildCategoryAttachReport,
222 persistCategoryAttachReport,
223 collectCategoryAttachStageResiduals,
224 };