Setting the file. One moment.
Product SEO · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
function importOwnedIdentity
— line 129
This file
Number 41.156
Position 156 of 174
Type JavaScript
Size 39 KB
Lines 754 lib/ product-seo.js
JavaScript · 754 lines · 39 KB
14
// call reported per-entry success for STORES_PRODUCT and persisted nothing. A success response
15 // is never proof; only the read-backs below are.
16
17 const crypto = require ( 'node:crypto' );
18 const fs = require ( 'node:fs/promises' );
19 const path = require ( 'node:path' );
20 const conflicts = require ( '../../../lib/rerun-conflicts' );
21 const wixAuth = require ( '../../../lib/wix-auth.js' );
22 const { SEO_OUTCOMES , SEO_ACCEPTED_OUTCOMES } = require ( '../../../lib/seo-outcome-registry' );
23
24 const TAG_FIELDS = Object. freeze ([ 'title' , 'description' ]);
25 const SEO_FIELDS = Object. freeze ([ 'title' , 'description' , 'focusKeyword' ]);
26 const SEO_VALUES = Object. freeze ([ 'acquire-evidence' , 'omit' , 'materialize' ]);
27 const FOCUS_VALUES = Object. freeze ([ 'acquire-evidence' , 'omit' ]);
28 const DISPOSITION_PREFIX = 'productSeoDisposition:' ;
29 const FOCUS_PREFIX = 'productSeoDisposition:focusKeywords:' ;
30 const INHERIT_SOURCES = Object. freeze ([ 'TAG_SOURCE_DEFAULT_PATTERN' , 'TAG_SOURCE_USER_PATTERN' , 'TAG_SOURCE_SITE' ]);
31 const RESOLUTION_DELAYS_MS = Object. freeze ([ 1000 , 2000 , 4000 , 8000 , 16000 ]);
32 const STORES_PRODUCT_ITEM_TYPE = 'STORES_PRODUCT' ;
33 // Products in flight in the SEO stage; matches the Stores write-pool guidance.
34 const SEO_STAGE_CONCURRENCY = 3 ;
35 const pause = ( ms ) => new Promise (( resolve ) => setTimeout (resolve, ms));
36
37 function canonicalJson ( value ) {
38 if (value === undefined ) return 'null' ;
39 if (value === null || typeof value !== 'object' ) return JSON . stringify (value);
40 if (Array. isArray (value)) return `[${ value . map ( canonicalJson ). join ( ',' ) }]` ;
41 return `{${ Object . keys ( value ). filter (( k ) => value [ k ] !== undefined ). sort (). map (( k ) => `${ JSON . stringify ( k ) }:${ canonicalJson ( value [ k ]) }` ). join ( ',' ) }}` ;
42 }
43 const sha256 = ( text ) => crypto. createHash ( 'sha256' ). update (text). digest ( 'hex' );
44
45 // --- evidence projection and hashing --------------------------------------------------
46 function fieldProjection ( field ) {
47 if ( ! field || ! [ 'override' , 'inherit' , 'unknown' ]. includes (field.state)) return { state: 'unknown' };
48 if (field.state === 'override' ) {
49 if ( typeof field.value !== 'string' || field.value. trim () === '' ) return { state: 'unknown' };
50 return { state: 'override' , value: field.value };
51 }
52 return { state: field.state };
53 }
54 function fieldEvidenceHash ( field ) {
55 return sha256 ( canonicalJson ( fieldProjection (field)));
56 }
57
58 // --- dispositions ---------------------------------------------------------------------
59 function normalizePairs ( pairs ) {
60 return pairs. map (( p ) => ({ productId: String (p.productId), field: p.field }))
61 . sort (( a , b ) => (a.productId === b.productId ? a.field. localeCompare (b.field) : a.productId. localeCompare (b.productId)));
62 }
63 function scopeIdFor ( pairs ) {
64 return sha256 ( canonicalJson ( normalizePairs (pairs))). slice ( 0 , 16 );
65 }
66 function dispositionKey ( scopeId , { focusKeywords = false } = {}) {
67 return `${ focusKeywords ? FOCUS_PREFIX : DISPOSITION_PREFIX }${ scopeId }` ;
68 }
69
70 // Fail closed: anything malformed applies to nothing.
71 function validateDispositionEntry ( key , entry ) {
72 const focus = key. startsWith ( FOCUS_PREFIX );
73 const scopeId = key. slice (focus ? FOCUS_PREFIX . length : DISPOSITION_PREFIX . length );
74 if ( ! / ^ [0-9a-f] {16}$ / . test (scopeId)) return { ok: false , reason: 'malformed-scope-id' };
75 if ( ! entry || typeof entry !== 'object' || Array. isArray (entry)) return { ok: false , reason: 'not-an-object' };
76 if (entry.source !== 'user' ) return { ok: false , reason: 'not-a-user-decision' };
77 if ( ! (focus ? FOCUS_VALUES : SEO_VALUES ). includes (entry.value)) return { ok: false , reason: 'unknown-value' };
78 if ( typeof entry.decidedAt !== 'string' || Number. isNaN (Date. parse (entry.decidedAt))) return { ok: false , reason: 'missing-decidedAt' };
79 const allowedFields = focus ? [ 'focusKeyword' ] : TAG_FIELDS ;
80 if ( ! Array. isArray (entry.items) || ! entry.items. length ) return { ok: false , reason: 'no-items' };
81 const seen = new Set ();
82 for ( const item of entry.items) {
83 if ( ! item || typeof item !== 'object' || item.productId == null || String (item.productId) === ''
84 || ! allowedFields. includes (item.field) || typeof item.evidenceHash !== 'string' || ! / ^ [0-9a-f] {64}$ / . test (item.evidenceHash)) {
85 return { ok: false , reason: 'malformed-item' };
86 }
87 const pairKey = `${ item . productId } \u0000 ${ item . field }` ;
88 if (seen. has (pairKey)) return { ok: false , reason: 'duplicate-item' };
89 seen. add (pairKey);
90 }
91 if ( scopeIdFor (entry.items) !== scopeId) return { ok: false , reason: 'scope-id-mismatch' };
92 return { ok: true , scopeId, focus, value: entry.value, items: entry.items };
93 }
94
95 // Which recorded owner disposition applies to each field of one product. A pair matches only
96 // when it is listed and its evidenceHash equals the field's CURRENT projection hash; a new
97 // product, a newly affected field or changed evidence is asked again.
98 function matchDispositions ( decisions , productId , seoEvidence ) {
99 const id = String (productId);
100 const matched = { title: null , description: null , focusKeyword: null };
101 const ambiguous = new Set ();
102 const ignored = [];
103 for ( const key of Object. keys (decisions || {}). sort ()) {
104 if ( ! key. startsWith ( DISPOSITION_PREFIX )) continue ;
105 const check = validateDispositionEntry (key, decisions[key]);
106 if ( ! check.ok) { ignored. push ({ key, reason: check.reason }); continue ; }
107 for ( const item of check.items) {
108 if ( String (item.productId) !== id) continue ;
109 if (item.evidenceHash !== fieldEvidenceHash (seoEvidence && seoEvidence[item.field])) {
110 ignored. push ({ key, field: item.field, reason: 'evidence-changed' });
111 continue ;
112 }
113 const candidate = { scopeId: check.scopeId, value: check.value, key };
114 const prior = matched[item.field];
115 if (prior && (prior.scopeId !== candidate.scopeId || prior.value !== candidate.value)) ambiguous. add (item.field);
116 else matched[item.field] = candidate;
117 }
118 }
119 for ( const field of ambiguous) { matched[field] = null ; ignored. push ({ field, reason: 'conflicting-dispositions' }); }
120 return { matched, ignored };
121 }
122
123 // --- plan -----------------------------------------------------------------------------------
124 function tagFor ( field , value ) {
125 return field === 'title'
126 ? { type: 'title' , children: value }
127 : { type: 'meta' , props: { name: 'description' , content: value } };
128 }
129 function importOwnedIdentity ( tag ) {
130 if ( ! tag || typeof tag !== 'object' ) return null ;
131 if (tag.type === 'title' ) return 'title' ;
132 if (tag.type === 'meta' && tag.props && String (tag.props.name || '' ). toLowerCase () === 'description' ) return 'description' ;
133 return null ;
134 }
135 function tagValue ( tag ) {
136 if ( ! tag) return undefined ;
137 return tag.type === 'title' ? tag.children : tag.props && tag.props.content;
138 }
139
140 // Per field:
141 // write -- a proven override, or the owner's `materialize` of an unknown field; verified.
142 // inherit -- proven "no override"; no tag; verified to inherit.
143 // omit -- the owner accepted the loss of an unknown field; nothing written.
144 // held -- unknown evidence with rendered SEO and no applicable disposition.
145 // none -- unknown evidence but the source renders nothing for the field: nothing to hold.
146 function planProductSeo ({ productId , seoEvidence , decisions = {} } = {}) {
147 if (productId == null || String (productId) === '' ) throw new Error ( 'planProductSeo: productId is required' );
148 if ( ! seoEvidence || typeof seoEvidence !== 'object' ) throw new Error ( 'planProductSeo: seoEvidence is required' );
149 const id = String (productId);
150 const { matched , ignored } = matchDispositions (decisions, id, seoEvidence);
151 const fields = {};
152 const residuals = [];
153 const heldPairs = [];
154 const projection = {};
155 for ( const field of TAG_FIELDS ) {
156 const f = fieldProjection (seoEvidence[field]);
157 const disposition = matched[field];
158 projection[field] = { ... f, ... (disposition ? { disposition: { scopeId: disposition.scopeId, value: disposition.value } } : {}) };
159 if (f.state === 'override' ) { fields[field] = { action: 'write' , value: f.value, verify: 'override' }; continue ; }
160 if (f.state === 'inherit' ) { fields[field] = { action: 'inherit' , verify: 'inherit' }; continue ; }
161 const rendered = seoEvidence.rendered && typeof seoEvidence.rendered[field] === 'string' && seoEvidence.rendered[field]. trim () !== ''
162 ? seoEvidence.rendered[field]. trim () : null ;
163 if ( ! rendered) { fields[field] = { action: 'none' }; continue ; }
164 if (disposition && disposition.value === 'omit' ) {
165 fields[field] = { action: 'omit' , disposition };
166 residuals. push ({ outcome: 'seo_disposition_accepted_gap' , field });
167 } else if (disposition && disposition.value === 'materialize' ) {
168 // The value that will be written is part of the projection, so a changed rendered value
169 // under a standing materialize choice is reconciled on the next rerun.
170 projection[field].materializedValue = rendered;
171 fields[field] = { action: 'write' , value: rendered, verify: 'override' , materialized: true , disposition };
172 residuals. push ({ outcome: 'seo_materialized' , field });
173 } else {
174 fields[field] = { action: 'held' , disposition: disposition || null };
175 residuals. push ({ outcome: 'seo_disposition_required' , field });
176 heldPairs. push ({ productId: id, field, evidenceHash: fieldEvidenceHash (seoEvidence[field]) });
177 }
178 }
179 const focus = fieldProjection (seoEvidence.focusKeyword);
180 const focusDisposition = matched.focusKeyword;
181 projection.focusKeyword = { ... focus, ... (focusDisposition ? { disposition: { scopeId: focusDisposition.scopeId, value: focusDisposition.value } } : {}) };
182 if (focus.state === 'override' ) {
183 // No verified product write path exists for focus keyphrases; the owner may accept the loss.
184 if (focusDisposition && focusDisposition.value === 'omit' ) {
185 fields.focusKeyword = { action: 'omit' , disposition: focusDisposition };
186 residuals. push ({ outcome: 'focus_keywords_accepted_gap' , field: 'focusKeyword' });
187 } else {
188 fields.focusKeyword = { action: 'held' , disposition: focusDisposition || null };
189 residuals. push ({ outcome: 'focus_keywords_disposition_required' , field: 'focusKeyword' });
190 heldPairs. push ({ productId: id, field: 'focusKeyword' , evidenceHash: fieldEvidenceHash (seoEvidence.focusKeyword) });
191 }
192 } else {
193 fields.focusKeyword = { action: 'none' };
194 }
195 if (seoEvidence.canonical === 'pending_policy' ) residuals. push ({ outcome: 'canonical_pending_policy' , field: 'canonical' });
196 const tags = TAG_FIELDS . filter (( field ) => fields[field].action === 'write' ). map (( field ) => tagFor (field, fields[field].value));
197 return {
198 productId: id,
199 fields,
200 tags,
201 residuals,
202 heldPairs,
203 ignoredDispositions: ignored,
204 projection,
205 projectionHash: sha256 ( canonicalJson (projection)),
206 };
207 }
208
209 // The only SEO input to a product's sourceHash: per-field {state, value}, plus the
210 // applied disposition's {scopeId, value}. No capture time, channel or read metadata.
211 function seoHashProjection ( input ) {
212 return planProductSeo (input).projection;
213 }
214 function seoProjectionHash ( input ) {
215 return planProductSeo (input).projectionHash;
216 }
217
218 // `seoData` for a CREATE body. Omitted entirely when nothing is written, so a both-inherit
219 // product sends no seoData.
220 function seoDataForCreate ( plan ) {
221 return plan && plan.tags. length ? { tags: plan.tags. map (( tag ) => JSON . parse ( JSON . stringify (tag))) } : null ;
222 }
223
224 // --- owner question ------------------------------------------------------------
225 // One batched question per kind. Never answered by the runtime, in any automation mode.
226 function buildSeoDispositionQuestions ( plans ) {
227 const seoItems = [];
228 const focusItems = [];
229 for ( const plan of plans || []) {
230 for ( const pair of plan.heldPairs || []) (pair.field === 'focusKeyword' ? focusItems : seoItems). push (pair);
231 }
232 const questions = [];
233 const question = ( items , focus ) => {
234 const scopeId = scopeIdFor (items);
235 const sorted = normalizePairs (items). map (( p ) => items. find (( i ) => String (i.productId) === p.productId && i.field === p.field));
236 return {
237 key: dispositionKey (scopeId, { focusKeywords: focus }),
238 scopeId,
239 kind: focus ? 'focusKeywords' : 'productSeo' ,
240 items: sorted. map (( i ) => ({ productId: String (i.productId), field: i.field, evidenceHash: i.evidenceHash })),
241 allowedValues: focus ? [ ... FOCUS_VALUES ] : [ ... SEO_VALUES ],
242 affectedProducts: [ ...new Set (sorted. map (( i ) => String (i.productId)))],
243 prompt: focus
244 ? 'Yoast focus keyphrases have no verified Wix product write path. Accept the loss for these products (omit), or keep them held while you look for evidence (acquire-evidence)?'
245 : 'These products show SEO in the source, but the source cannot prove which title/description the merchant overrode. Supply the raw Yoast evidence (acquire-evidence), let them inherit Wix SEO patterns and record the loss (omit), or write the rendered title/description as item overrides (materialize)?' ,
246 };
247 };
248 if (seoItems. length ) questions. push ( question (seoItems, false ));
249 if (focusItems. length ) questions. push ( question (focusItems, true ));
250 return questions;
251 }
252
253 // Records the OWNER's answer. There is deliberately no default: a missing or unknown answer throws.
254 function recordSeoDisposition ({ question , value , decidedAt } = {}) {
255 if ( ! question || ! question.key || ! Array. isArray (question.items)) throw new Error ( 'recordSeoDisposition: a question from buildSeoDispositionQuestions is required' );
256 if ( ! question.allowedValues. includes (value)) throw new Error ( `recordSeoDisposition: value must be one of ${ question . allowedValues . join ( ', ' ) }` );
257 if ( ! decidedAt) throw new Error ( 'recordSeoDisposition: decidedAt is required' );
258 const entry = { value, source: 'user' , decidedAt, items: question.items. map (( i ) => ({ ... i })) };
259 const check = validateDispositionEntry (question.key, entry);
260 if ( ! check.ok) throw new Error ( `recordSeoDisposition: invalid entry (${ check . reason })` );
261 return { key: question.key, entry };
262 }
263
264 // --- tag merge and product read-back --------------------------------------------------------
265 // tags[] is replaced whole, so the current non-import-owned tags are sent back unchanged and only
266 // the import-owned title/description tags are replaced. `removeFields` names import-owned tags
267 // the source now says to drop (override -> inherit); anything else unmanaged is left as it is.
268 function mergeImportOwnedTags ( currentTags , plan , { removeFields = [] } = {}) {
269 const writes = new Map ( TAG_FIELDS . filter (( f ) => plan.fields[f].action === 'write' ). map (( f ) => [f, tagFor (f, plan.fields[f].value)]));
270 const out = [];
271 for ( const tag of currentTags || []) {
272 const identity = importOwnedIdentity (tag);
273 if (identity && (writes. has (identity) || removeFields. includes (identity))) continue ;
274 out. push (tag);
275 }
276 for ( const field of TAG_FIELDS ) if (writes. has (field)) out. push (writes. get (field));
277 return out;
278 }
279 function ownTagsByIdentity ( tags ) {
280 const by = { title: [], description: [] };
281 for ( const tag of tags || []) {
282 const identity = importOwnedIdentity (tag);
283 if (identity) by[identity]. push (tag);
284 }
285 return by;
286 }
287 // Import-owned write fields that are missing or different on the product.
288 function writeFieldsDiffer ( productTags , plan ) {
289 const by = ownTagsByIdentity (productTags);
290 return TAG_FIELDS . filter (( f ) => plan.fields[f].action === 'write' )
291 . filter (( f ) => by[f]. length !== 1 || tagValue (by[f][ 0 ]) !== plan.fields[f].value);
292 }
293 function importOwnedEqual ( tagsA , tagsB ) {
294 const a = ownTagsByIdentity (tagsA);
295 const b = ownTagsByIdentity (tagsB);
296 return TAG_FIELDS . every (( f ) => canonicalJson (a[f]. map (tagValue)) === canonicalJson (b[f]. map (tagValue)));
297 }
298
299 // --- verification ---------------------------------------------------------------------
300 function verifiedFields ( plan ) {
301 return TAG_FIELDS . filter (( f ) => plan.fields[f].verify);
302 }
303 function checkProductTags ( product , plan ) {
304 const by = ownTagsByIdentity (product && product.seoData && product.seoData.tags);
305 const failures = {};
306 for ( const field of verifiedFields (plan)) {
307 const own = by[field];
308 if (plan.fields[field].verify === 'override' ) {
309 if (own. length !== 1 || tagValue (own[ 0 ]) !== plan.fields[field].value) failures[field] = 'seo_write_unverified' ;
310 } else if (own. length ) {
311 failures[field] = 'seo_inheritance_unverified' ;
312 }
313 }
314 return failures;
315 }
316 function checkResolution ( itemSeoTags , plan ) {
317 const own = ownTagsByIdentity (itemSeoTags && itemSeoTags.tags);
318 const resolved = { title: [], description: [] };
319 for ( const entry of (itemSeoTags && itemSeoTags.resolvedTags) || []) {
320 const identity = importOwnedIdentity (entry && entry.tag);
321 if (identity) resolved[identity]. push (entry);
322 }
323 const failures = {};
324 for ( const field of verifiedFields (plan)) {
325 const entries = resolved[field];
326 if (plan.fields[field].verify === 'override' ) {
327 const ok = entries. length === 1 && entries[ 0 ].source === 'TAG_SOURCE_ITEM' && tagValue (entries[ 0 ].tag) === plan.fields[field].value;
328 if ( ! ok) failures[field] = 'seo_write_unverified' ;
329 } else {
330 // Inherited: no own tag, and any resolved value comes from a pattern or site setting. An
331 // absent or blank resolved tag passes; no equality with the source template is required.
332 // hasOverride alone is never evidence either way.
333 const bad = own[field]. length > 0 || entries. some (( e ) => {
334 if (e.source === 'TAG_SOURCE_ITEM' ) return true ;
335 const value = tagValue (e.tag);
336 const blank = value == null || String (value). trim () === '' ;
337 return ! blank && ! INHERIT_SOURCES . includes (e.source);
338 });
339 if (bad) failures[field] = 'seo_inheritance_unverified' ;
340 }
341 }
342 return failures;
343 }
344
345 // One product's resolution verification, step by step, so the stage can interleave products:
346 // the first resolution read happens with no wait; a product whose read fails a
347 // check or throws is re-read up to delaysMs.length more times, each no earlier than its delay
348 // after that product's previous read. A pass needs the exact expected state, so a stale read can
349 // pass only when that state was already present.
350 function startResolution ( plan , product , delaysMs ) {
351 return { plan, delaysMs, productFailures: checkProductTags (product, plan), resolutionFailures: null , lastError: null , attempts: 0 , productRevision: product.revision ?? null };
352 }
353 // Only the LATEST read decides the outcome. A failed read clears any earlier mismatch: that
354 // mismatch may just have been eventual consistency, so an unreadable final attempt is
355 // `seo_resolution_unverified` (re-verify next run), never a stale `seo_write_unverified`.
356 function applyResolutionRead ( run , read ) {
357 run.attempts += 1 ;
358 if (read.ok) { run.resolutionFailures = checkResolution (read.value, run.plan); run.lastError = null ; }
359 else {
360 run.resolutionFailures = null ;
361 run.lastError = read.error && read.error.message ? read.error.message : String (read.error);
362 }
363 const passed = read.ok && ! Object. keys (run.resolutionFailures). length ;
364 return passed || run.attempts > run.delaysMs. length ;
365 }
366 function finishResolution ( run ) {
367 const residuals = [];
368 for ( const field of verifiedFields (run.plan)) {
369 const outcome = run.productFailures[field] || (run.resolutionFailures === null ? 'seo_resolution_unverified' : run.resolutionFailures[field]);
370 if (outcome) residuals. push ({ outcome, field });
371 }
372 return { verified: residuals. length === 0 , residuals, attempts: run.attempts, productRevision: run.productRevision, ... (run.lastError ? { error: run.lastError } : {}) };
373 }
374 const asRead = async ( fn ) => { try { return { ok: true , value: await fn () }; } catch (error) { if (wixAuth. isRunInterruption (error)) throw error; return { ok: false , error }; } };
375
376 // Serial single-product form of the same rule (used by callers outside the batched stage).
377 async function verifyProductSeo ({ plan , readProduct , readItemSeoTags , product = null , sleep = pause, delaysMs = RESOLUTION_DELAYS_MS } = {}) {
378 const fields = verifiedFields (plan);
379 if ( ! fields. length ) return { verified: true , skipped: true , residuals: [], attempts: 0 , productRevision: product ? product.revision : null };
380 let current = product;
381 if ( ! current) {
382 try { current = await readProduct (); } catch (error) {
383 if (wixAuth. isRunInterruption (error)) throw error;
384 return { verified: false , residuals: fields. map (( field ) => ({ outcome: 'seo_resolution_unverified' , field })), attempts: 0 , error: error.message, productRevision: null };
385 }
386 }
387 const run = startResolution (plan, current, delaysMs);
388 for (;;) {
389 if ( applyResolutionRead (run, await asRead (readItemSeoTags))) break ;
390 await sleep (delaysMs[run.attempts - 1 ]);
391 }
392 return finishResolution (run);
393 }
394
395 // --- persisted SEO state (persistence + journal) ----------------------------------------
396 const stateFile = ( projectDir ) => path. join (projectDir, 'state' , 'product-seo.json' );
397 async function loadSeoState ( projectDir , siteId ) {
398 try {
399 const state = JSON . parse ( await fs. readFile ( stateFile (projectDir), 'utf8' ));
400 if (state.schemaVersion !== 1 || state.siteId !== siteId || ! state.rows || Array. isArray (state.rows)) throw new Error ( 'Invalid or wrong-site product SEO state' );
401 return state;
402 } catch (error) {
403 if (error.code === 'ENOENT' ) return { schemaVersion: 1 , siteId, rows: {} };
404 throw error;
405 }
406 }
407 async function saveSeoState ( projectDir , state ) {
408 const dest = stateFile (projectDir);
409 const tmp = `${ dest }.${ crypto . randomUUID () }.tmp` ;
410 await fs. mkdir (path. dirname (dest), { recursive: true });
411 try { await fs. writeFile (tmp, `${ JSON . stringify ( state ) } \n ` , { mode: 0o600 }); await fs. rename (tmp, dest); } finally { await fs. rm (tmp, { force: true }); }
412 }
413
414 // Before writes: what each product's SEO will do (added, changed, removed override -> inherit,
415 // held, unchanged). Pure; compares against the saved state only.
416 function previewProductSeo ( rows , state = { rows: {} }) {
417 return rows. map (({ sourceKey , plan }) => {
418 const prev = state.rows[sourceKey];
419 const held = TAG_FIELDS . filter (( f ) => plan.fields[f].action === 'held' );
420 if (prev && prev.verified && prev.projectionHash === plan.projectionHash) return { sourceKey, change: 'unchanged' , held };
421 const before = new Set (prev && prev.writtenFields || []);
422 const after = new Set ( TAG_FIELDS . filter (( f ) => plan.fields[f].action === 'write' ));
423 const removed = [ ... before]. filter (( f ) => ! after. has (f) && plan.fields[f].action === 'inherit' );
424 const added = [ ... after]. filter (( f ) => ! before. has (f));
425 const change = removed. length ? 'removed' : added. length && ! before.size ? 'added' : after.size ? 'changed' : held. length ? 'held' : 'unchanged' ;
426 return { sourceKey, change, added, removed, held };
427 });
428 }
429
430 // --- reconcile (create fallback, reruns, journal) -------------------------------------------
431 // Runs after the product exists. The caller holds the project state lock. Each row:
432 // { sourceKey, targetId, plan, savedRevision } savedRevision = the Wix revision recorded at
433 // the last import-owned write (this run's post-create read for a new product).
434 // Dependencies are injected: readProduct(id), patchProduct({ productId, revision, seoData }),
435 // readItemSeoTags(id). `conflictDecisions` maps sourceKey -> a recorded user decision.
436 //
437 // Bounded execution: at most `concurrency` (3) products in flight, covering the product
438 // read, the optional PATCH and the first resolution read. Every Wix read goes through `pacer`
439 // (the process-wide target read pacer unless injected); a read still failing after the pacer's
440 // attempts counts as one failed resolution attempt. Products still pending after their first
441 // read are re-read in retry rounds by the same pool. The stage sleeps for Wix data to settle
442 // only when nothing is in flight, no first read is left and no re-read is due, and only until
443 // the earliest pending product is due -- never once per product -- so total settle wait stays
444 // within one product's delays (31 s). `clock` is milliseconds; `now` is the ISO timestamp.
445 async function reconcileProductSeo ({ projectDir , siteId , rows , readProduct , patchProduct , readItemSeoTags , conflictDecisions = {}, sleep = pause, delaysMs = RESOLUTION_DELAYS_MS , pacer = null , concurrency = SEO_STAGE_CONCURRENCY , clock = () => Date. now (), now = () => new Date (). toISOString () } = {}) {
446 if ( ! projectDir || ! siteId || ! Array. isArray (rows)) throw new Error ( 'reconcileProductSeo: projectDir, siteId and rows are required' );
447 for ( const fn of [readProduct, patchProduct, readItemSeoTags]) if ( typeof fn !== 'function' ) throw new Error ( 'reconcileProductSeo: readProduct, patchProduct and readItemSeoTags are required' );
448 if ( ! Number. isInteger (concurrency) || concurrency < 1 ) throw new Error ( 'reconcileProductSeo: concurrency must be a positive integer' );
449 for ( const row of rows) if ( ! row || ! row.sourceKey || ! row.targetId || ! row.plan || ! row.plan.fields) throw new Error ( 'reconcileProductSeo: every row needs sourceKey, targetId and plan' );
450 // Lazy: wix-writers requires this module for its bulk-SEO guard.
451 const readPacer = pacer || require ( './wix-writers' ). sharedTargetReadPacer ();
452 const pacerBefore = readPacer. stats ();
453 const state = await loadSeoState (projectDir, siteId);
454
455 // One serialized writer: one atomic state write at a time, each a snapshot taken in turn.
456 let writeChain = Promise . resolve ();
457 const persist = () => {
458 const next = writeChain. then (() => saveSeoState (projectDir, state));
459 writeChain = next. catch (() => {});
460 return next;
461 };
462 // Stage time: the injected clock, never behind the settle sleeps already taken (a test sleep
463 // that does not advance the clock still makes the re-reads it waited for due).
464 let sleptUntil = - Infinity ;
465 const stageNow = () => Math. max ( clock (), sleptUntil);
466 const paced = ( fn ) => readPacer. read (fn);
467
468 const results = new Array (rows. length );
469 const pending = []; // { index, run, finish, dueAt }
470
471 // Everything one product does before its first resolution read; returns a finisher for the
472 // verification outcome, or null when the product is already final.
473 async function firstPass ( index ) {
474 const row = rows[index];
475 const { sourceKey , targetId , plan } = row;
476 let prev = state.rows[sourceKey];
477 if (prev && prev.targetId !== targetId) prev = null ;
478 const result = { sourceKey, targetId, wrote: false , verified: false , residuals: [ ... plan.residuals] };
479 results[index] = result;
480 const done = async ( patch ) => {
481 if (patch) { state.rows[sourceKey] = { ... (state.rows[sourceKey] || {}), targetId, ... patch }; await persist (); }
482 };
483 const tagFail = ( outcome , extra = {}) => { for ( const field of verifiedFields (plan)) result.residuals. push ({ outcome, field, ... extra }); };
484
485 // Unchanged projection and a passed verification: no read, no write, no re-verification.
486 if (prev && ! prev.journal && prev.verified && prev.projectionHash === plan.projectionHash) {
487 result.unchanged = true ;
488 result.verified = true ;
489 return ;
490 }
491
492 const productRead = await paced (() => readProduct (targetId));
493 if ( ! productRead.ok) { tagFail ( 'seo_resolution_unverified' ); result.error = productRead.error.message; return ; }
494 const current = productRead.value;
495 const currentTags = (current && current.seoData && current.seoData.tags) || [];
496
497 // Journal: a write that started but was never verified is reconciled by read-back, never replayed.
498 let landedJournal = null ;
499 if (prev && prev.journal) {
500 const j = prev.journal;
501 if ( importOwnedEqual (currentTags, j.intendedTags)) {
502 landedJournal = j;
503 } else if ( String (current.revision) === String (j.baseRevision)) {
504 prev = { ... prev, journal: null };
505 } else {
506 tagFail ( 'seo_write_unverified' , { reason: 'journaled write neither landed nor left the saved revision in place' });
507 return ;
508 }
509 }
510
511 const projectionChanged = Boolean (prev && prev.projectionHash && prev.projectionHash !== plan.projectionHash);
512 const by = ownTagsByIdentity (currentTags);
513 const removeFields = projectionChanged
514 ? TAG_FIELDS . filter (( f ) => [ 'inherit' , 'omit' ]. includes (plan.fields[f].action) && by[f]. length && (prev.writtenFields || []). includes (f))
515 : [];
516 const needsWrite = ! landedJournal && ( writeFieldsDiffer (currentTags, plan). length > 0 || removeFields. length > 0 );
517 let productForVerify = current;
518
519 if (needsWrite) {
520 const desiredTags = mergeImportOwnedTags (currentTags, plan, { removeFields });
521 const baseRevision = (prev && prev.revision) || row.savedRevision || null ;
522 let writeRevision = baseRevision;
523 const review = () => conflicts. buildConflictReview ({
524 sourceStableKey: sourceKey,
525 targetId,
526 entity: 'stores/product' ,
527 error: { code: 'INVALID_REVISION' , message: baseRevision ? 'The Wix product revision changed since the last import' : 'No saved Wix revision to protect this update' },
528 savedTargetRevision: baseRevision,
529 currentTargetRevision: current.revision ?? null ,
530 sourceHash: plan.projectionHash,
531 proposedFields: { 'seoData.tags' : desiredTags },
532 currentTargetValues: { 'seoData.tags' : currentTags },
533 });
534 if ( ! baseRevision || String (current.revision) !== String (baseRevision)) {
535 const conflictReview = review ();
536 const decision = conflictDecisions[sourceKey];
537 const approval = decision ? conflicts. approvalApplies ({ decision, review: conflictReview }) : { applies: false };
538 if (approval.applies && decision.choice === 'override' ) {
539 const writePlan = conflicts. buildOverrideWritePlan ({ decision, review: conflictReview });
540 if ( ! writePlan.ok) { result.residuals. push ({ outcome: 'seo_conflict' , field: 'seoData' , review: conflictReview }); return ; }
541 writeRevision = writePlan.revision;
542 } else if (decision && conflicts. keepDecisionApplies ({ decision, sourceHash: plan.projectionHash, currentTargetRevision: current.revision ?? null }).applies) {
543 result.residuals. push ({ outcome: 'seo_conflict_kept_wix' , field: 'seoData' });
544 return ;
545 } else {
546 result.residuals. push ({ outcome: 'seo_conflict' , field: 'seoData' , review: conflictReview });
547 return ;
548 }
549 }
550 const journal = { intendedTags: desiredTags, intendedHash: plan.projectionHash, baseRevision: writeRevision, startedAt: now () };
551 state.rows[sourceKey] = { ... (prev || {}), targetId, journal };
552 await persist (); // the journal is durable before its PATCH
553 try {
554 const patched = await patchProduct ({ productId: targetId, revision: writeRevision, seoData: { ... (current.seoData || {}), tags: desiredTags } });
555 result.wrote = true ;
556 productForVerify = null ;
557 if (patched && patched.revision != null ) state.rows[sourceKey].journal = { ... journal, writtenRevision: patched.revision };
558 } catch (error) {
559 if (wixAuth. isRunInterruption (error)) throw error;
560 const kind = conflicts. classifyUpdateError (error, { sentRevision: true }).kind;
561 if (kind === 'revision_conflict' || kind === 'possible_revision_conflict' ) {
562 // Rejected on revision: nothing landed, so the journal is cleared and the owner decides.
563 state.rows[sourceKey] = { ... (prev || {}), targetId, journal: null };
564 result.residuals. push ({ outcome: 'seo_conflict' , field: 'seoData' , review: review (), error: error.message });
565 } else {
566 tagFail ( 'seo_write_unverified' , { error: error.message });
567 }
568 await persist ();
569 return ;
570 }
571 await persist ();
572 }
573
574 const finish = async ( verification ) => {
575 result.attempts = verification.attempts;
576 result.residuals. push ( ... verification.residuals);
577 if (verification.verified) {
578 result.verified = true ;
579 await done ({
580 projectionHash: plan.projectionHash,
581 revision: verification.productRevision ?? (landedJournal && landedJournal.writtenRevision) ?? current.revision ?? null ,
582 verified: true ,
583 verifiedAt: now (),
584 writtenFields: TAG_FIELDS . filter (( f ) => plan.fields[f].action === 'write' ),
585 journal: null ,
586 openResiduals: null ,
587 });
588 } else {
589 // Nothing advances until verification passes; a written-but-unverified change keeps its journal.
590 // The open residuals are kept on the row so a later pass that does not reconcile this
591 // product still reports them (outstandingSeoResiduals) until a verification clears them.
592 await done ({ verified: false });
593 }
594 };
595
596 const fields = verifiedFields (plan);
597 if ( ! fields. length ) { await finish ({ verified: true , residuals: [], attempts: 0 , productRevision: current.revision ?? null }); return ; }
598 if ( ! productForVerify) {
599 const reread = await paced (() => readProduct (targetId));
600 if ( ! reread.ok) {
601 await finish ({ verified: false , residuals: fields. map (( field ) => ({ outcome: 'seo_resolution_unverified' , field })), attempts: 0 , productRevision: null });
602 return ;
603 }
604 productForVerify = reread.value;
605 }
606 const run = startResolution (plan, productForVerify, delaysMs);
607 await resolutionRead ({ run, finish, targetId, index });
608 }
609
610 async function resolutionRead ( item ) {
611 const read = await paced (() => readItemSeoTags (item.targetId));
612 if ( applyResolutionRead (item.run, read)) { await item. finish ( finishResolution (item.run)); return ; }
613 item.dueAt = stageNow () + item.run.delaysMs[item.run.attempts - 1 ];
614 pending. push (item);
615 }
616
617 // The pool: due re-reads first, then first passes; settle-sleep only when nothing can start.
618 let nextFirst = 0 ;
619 let settleWaitMs = 0 ;
620 let maxInFlight = 0 ;
621 const inFlight = new Set ();
622 let failure = null ;
623 const nextTask = () => {
624 const t = stageNow ();
625 let due = - 1 ;
626 for ( let i = 0 ; i < pending. length ; i += 1 ) if (pending[i].dueAt <= t && (due < 0 || pending[i].dueAt < pending[due].dueAt)) due = i;
627 if (due >= 0 ) { const item = pending. splice (due, 1 )[ 0 ]; return () => resolutionRead (item); }
628 if (nextFirst < rows. length ) { const index = nextFirst ++ ; return () => firstPass (index); }
629 return null ;
630 };
631 while ( ! failure && (nextFirst < rows. length || pending. length || inFlight.size)) {
632 let task;
633 while ( ! failure && inFlight.size < concurrency && (task = nextTask ())) {
634 const p = task (). catch (( error ) => { if ( ! failure) failure = error; }). finally (() => inFlight. delete (p));
635 inFlight. add (p);
636 maxInFlight = Math. max (maxInFlight, inFlight.size);
637 }
638 if (failure) break ;
639 if (inFlight.size) { await Promise . race (inFlight); continue ; }
640 if (pending. length ) {
641 const t = stageNow ();
642 const target = Math. min ( ... pending. map (( item ) => item.dueAt));
643 if (target > t) {
644 try { await sleep (target - t); } catch (error) { failure = error; break ; }
645 settleWaitMs += target - t;
646 sleptUntil = Math. max (sleptUntil, target);
647 }
648 }
649 }
650 await Promise . allSettled ([ ... inFlight]);
651 await writeChain;
652 if (failure) throw failure;
653
654 const pacerAfter = readPacer. stats ();
655 const timing = {
656 concurrency,
657 maxInFlight,
658 settleWaitMs,
659 pacingWaitMs: (pacerAfter.pacingWaitMs || 0 ) - (pacerBefore.pacingWaitMs || 0 ),
660 backoffWaitMs: (pacerAfter.backoffWaitMs || 0 ) - (pacerBefore.backoffWaitMs || 0 ),
661 reads: (pacerAfter.reads || 0 ) - (pacerBefore.reads || 0 ),
662 };
663 // Every product this pass could not verify keeps its open residuals on its saved row (early
664 // failures included), so a later pass that does not reconcile it still reports them through
665 // outstandingSeoResiduals until a verification clears them.
666 let carry = false ;
667 for ( const result of results) {
668 if ( ! result || result.verified) continue ;
669 // A row saved for another target is not this product's history (firstPass ignores it too).
670 const saved = state.rows[result.sourceKey];
671 const own = saved && saved.targetId === result.targetId ? saved : {};
672 state.rows[result.sourceKey] = { ... own, targetId: result.targetId, verified: false , openResiduals: result.residuals. filter (( r ) => SEO_OUTCOMES . includes (r.outcome)) };
673 carry = true ;
674 }
675 if (carry) await persist ();
676 return { siteId, results, stageResiduals: collectSeoStageResiduals (results), timing };
677 }
678
679 // --- completion rows ------------------------------------------------------------------
680 function collectSeoStageResiduals ( results ) {
681 const groups = new Map ();
682 for ( const result of results || []) {
683 const seen = new Set ();
684 for ( const residual of result.residuals || []) {
685 if ( ! SEO_OUTCOMES . includes (residual.outcome)) throw new Error ( `collectSeoStageResiduals: unknown outcome ${ residual . outcome }` );
686 if (seen. has (residual.outcome)) continue ;
687 seen. add (residual.outcome);
688 if ( ! groups. has (residual.outcome)) groups. set (residual.outcome, []);
689 groups. get (residual.outcome). push (result.sourceKey);
690 }
691 }
692 return [ ... groups. entries ()]. sort (([ a ], [ b ]) => a. localeCompare (b)). map (([ outcome , sourceKeys ]) => ({
693 entity: 'product' , stage: 'seo' , outcome, count: sourceKeys. length , sourceKeys: sourceKeys. sort (),
694 }));
695 }
696 // Residuals still owed by products a pass did not reconcile: every saved row that has not passed
697 // verification, with its last recorded outcomes (seo_resolution_unverified when none were kept).
698 // Feed the result, with this pass's own results, to collectSeoStageResiduals.
699 function outstandingSeoResiduals ( state , { exclude = [] } = {}) {
700 const skip = new Set (exclude);
701 return Object. entries ((state && state.rows) || {})
702 . filter (([ sourceKey , row ]) => ! skip. has (sourceKey) && row && row.verified !== true )
703 . map (([ sourceKey , row ]) => ({ sourceKey, targetId: row.targetId || null , carried: true ,
704 residuals: Array. isArray (row.openResiduals) && row.openResiduals. length ? row.openResiduals : [{ outcome: 'seo_resolution_unverified' , field: 'seoData' }] }))
705 . sort (( a , b ) => a.sourceKey. localeCompare (b.sourceKey));
706 }
707 function seoStageBlocksCompletion ( stageResiduals ) {
708 return (stageResiduals || []). some (( row ) => row.count > 0 && ! SEO_ACCEPTED_OUTCOMES . includes (row.outcome));
709 }
710
711 // Executable negative guard: no Stores product write routes through the generic Item SEO
712 // bulk writer. wix-writers' bulk builder calls this.
713 function assertNotStoresProductBulkSeo ( itemType ) {
714 if ( String (itemType || '' ). toUpperCase () === STORES_PRODUCT_ITEM_TYPE ) {
715 throw new Error ( 'Generic Item SEO bulk writes are refused for STORES_PRODUCT: on 2026-08-27 that call reported per-entry success and persisted nothing. Write product SEO through Catalog V3 product.seoData (create, or a revision-protected PATCH) and verify by read-back.' );
716 }
717 }
718
719 module . exports = {
720 outstandingSeoResiduals,
721 TAG_FIELDS,
722 SEO_FIELDS,
723 SEO_VALUES,
724 FOCUS_VALUES,
725 INHERIT_SOURCES,
726 RESOLUTION_DELAYS_MS,
727 SEO_STAGE_CONCURRENCY,
728 STORES_PRODUCT_ITEM_TYPE,
729 canonicalJson,
730 fieldProjection,
731 fieldEvidenceHash,
732 scopeIdFor,
733 dispositionKey,
734 validateDispositionEntry,
735 matchDispositions,
736 planProductSeo,
737 seoHashProjection,
738 seoProjectionHash,
739 seoDataForCreate,
740 buildSeoDispositionQuestions,
741 recordSeoDisposition,
742 importOwnedIdentity,
743 mergeImportOwnedTags,
744 writeFieldsDiffer,
745 checkProductTags,
746 checkResolution,
747 verifyProductSeo,
748 loadSeoState,
749 previewProductSeo,
750 reconcileProductSeo,
751 collectSeoStageResiduals,
752 seoStageBlocksCompletion,
753 assertNotStoresProductBulkSeo,
754 };