Setting the file. One moment.
Plugin Data Presence · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page 39.41
PLUGIN READ PLANS
189
async function observePostMeta
— line 189
This file
Number 39.19
Position 19 of 92
Type JavaScript
Size 21 KB
Lines 424 lib/ plugin-data-presence.js
JavaScript · 424 lines · 21 KB
15 // require "any": present when any check is present (several places the plugin may write).
16 // require "all": present only when every check is present (the plugin needs all of them to
17 // do anything, e.g. a shipping class AND an enabled shipping method).
18
19 const { phpUnserialize } = require ( './wp-redirect-sources.js' );
20
21 const PRESENCE_KINDS = Object. freeze ({
22 postMeta: [ 'key' ],
23 postType: [ 'type' ],
24 shippingClassProducts: [ 'slug' ],
25 shippingMethod: [ 'methodId' ],
26 });
27 const REQUIRE_MODES = new Set ([ 'any' , 'all' ]);
28 const RESULTS = new Set ([ 'present' , 'absent' , 'unknown' ]);
29 // A distinct-value sweep that is still paging after this many pages is reported as unknown,
30 // never as absent: a cap reached is a read we did not finish.
31 const MAX_META_VALUE_PAGES = 25 ;
32 const BRIDGE_PAGE_LIMIT = 200 ;
33
34 function checkTarget ( check ) {
35 const fields = PRESENCE_KINDS [check?.kind] || [];
36 return fields. map (( field ) => String (check[field])). join ( '|' );
37 }
38
39 function checkId ( plugin , check ) {
40 return `${ plugin }::${ check . kind }::${ checkTarget ( check ) }` ;
41 }
42
43 // Where a plugin keeps its data. An empty presence check means "no data" only for
44 // `wordpress`: a plugin may keep its records on the vendor's service, where a WordPress read
45 // sees nothing. `external` / `both` add a cutover task "Data lives in <service>" listing the
46 // options the profile knows for getting that data out (`options[]`), or "options unknown:
47 // research needed"; `unknown` (or no field) keeps the normal path and is never "no need".
48 const DATA_LOCATIONS = new Set ([ 'wordpress' , 'external' , 'both' , 'unknown' ]);
49
50 function validateDataLocation ( dataLocation , label , errors , { required = false } = {}) {
51 if (dataLocation === undefined ) {
52 if (required) errors. push ( `${ label }: a profile with dataPresence needs dataLocation { where, evidence } (where: ${ [ ... DATA_LOCATIONS ]. join ( ', ' ) })` );
53 return ;
54 }
55 if ( ! dataLocation || typeof dataLocation !== 'object' || Array. isArray (dataLocation)) {
56 errors. push ( `${ label }: dataLocation must be an object { where, service?, options?, evidence }` );
57 return ;
58 }
59 const extra = Object. keys (dataLocation). filter (( key ) => ! [ 'where' , 'service' , 'options' , 'evidence' ]. includes (key));
60 if (extra. length ) errors. push ( `${ label }: dataLocation has unknown field(s) ${ extra . join ( ', ' ) }` );
61 if ( ! DATA_LOCATIONS . has (dataLocation.where)) errors. push ( `${ label }: dataLocation.where must be one of ${ [ ... DATA_LOCATIONS ]. join ( ', ' ) }` );
62 if (dataLocation.where !== 'unknown' && ( typeof dataLocation.evidence !== 'string' || ! dataLocation.evidence. trim ())) {
63 errors. push ( `${ label }: dataLocation.evidence is required (vendor docs or plugin source) unless where is unknown` );
64 }
65 const needsService = dataLocation.where === 'external' || dataLocation.where === 'both' ;
66 if (needsService && ( typeof dataLocation.service !== 'string' || ! dataLocation.service. trim ())) {
67 errors. push ( `${ label }: dataLocation.service (the vendor service name) is required when where is ${ dataLocation . where }` );
68 }
69 if ( ! needsService && dataLocation.service !== undefined ) errors. push ( `${ label }: dataLocation.service only applies to external or both` );
70 if (dataLocation.options !== undefined ) {
71 if ( ! needsService) errors. push ( `${ label }: dataLocation.options only applies to external or both` );
72 else if ( ! Array. isArray (dataLocation.options) || dataLocation.options. length === 0
73 || dataLocation.options. some (( option ) => typeof option !== 'string' || ! option. trim ())) {
74 errors. push ( `${ label }: dataLocation.options must be a non-empty array of strings when present` );
75 }
76 }
77 }
78
79 const OPTIONS_UNKNOWN = 'options unknown: research needed' ;
80 function serviceTaskText ( dataLocation ) {
81 const options = Array. isArray (dataLocation.options) ? dataLocation.options. filter (( option ) => typeof option === 'string' && option. trim ()) : [];
82 return `Data lives in ${ dataLocation . service }: ${ options . length ? options . join ( '; ' ) : OPTIONS_UNKNOWN }` ;
83 }
84
85 function dataLocationOf ( plugin ) {
86 const where = plugin?.dataLocation?.where;
87 return DATA_LOCATIONS . has (where) ? where : 'unknown' ;
88 }
89
90 // Cutover items for plugins whose data lives (also) on a vendor service. Never a "no need":
91 // the row keeps its status and gets a named cutover task. Deliberately NOT a `blocked[]` entry:
92 // blockers feed the batched ask at discovery, and this must never stop or question the import.
93 function externalDataTasks ( plugins ) {
94 const external = (plugins || []). filter (( plugin ) => [ 'external' , 'both' ]. includes ( dataLocationOf (plugin)));
95 if ( ! external. length ) return { externalData: [], cutoverTask: null };
96 const externalData = external. map (( plugin ) => ({
97 plugin: plugin.plugin,
98 where: plugin.dataLocation.where,
99 service: plugin.dataLocation.service,
100 knownOptions: Array. isArray (plugin.dataLocation.options) ? [ ... plugin.dataLocation.options] : [],
101 }));
102 const services = [ ...new Set (external. map (( plugin ) => plugin.dataLocation.service))]. join ( ', ' );
103 const texts = [ ...new Set (external. map (( plugin ) => serviceTaskText (plugin.dataLocation)))];
104 return {
105 externalData,
106 cutoverTask: {
107 workType: 'manual-setup' ,
108 task: texts. join ( ' | ' ),
109 doneWhen: `The ${ services } data is with the Import Operator in a form the import can read, or the owner confirms in writing that this data is not needed on Wix.` ,
110 },
111 };
112 }
113
114 function validateDataPresence ( dataPresence , label , errors ) {
115 if (dataPresence === undefined ) return ;
116 if ( ! dataPresence || typeof dataPresence !== 'object' || Array. isArray (dataPresence)) {
117 errors. push ( `${ label }: dataPresence must be an object { require, checks }` );
118 return ;
119 }
120 const extra = Object. keys (dataPresence). filter (( key ) => ! [ 'require' , 'checks' , 'note' ]. includes (key));
121 if (extra. length ) errors. push ( `${ label }: dataPresence has unknown field(s) ${ extra . join ( ', ' ) }` );
122 if ( ! REQUIRE_MODES . has (dataPresence.require)) errors. push ( `${ label }: dataPresence.require must be any or all` );
123 if ( ! Array. isArray (dataPresence.checks) || dataPresence.checks. length === 0 ) {
124 errors. push ( `${ label }: dataPresence.checks must be a non-empty array` );
125 return ;
126 }
127 dataPresence.checks. forEach (( check , index ) => {
128 const at = `${ label }: dataPresence.checks[${ index }]` ;
129 if ( ! check || typeof check !== 'object' || ! Object. hasOwn ( PRESENCE_KINDS , check.kind)) {
130 errors. push ( `${ at }: kind must be one of ${ Object . keys ( PRESENCE_KINDS ). join ( ', ' ) }` );
131 return ;
132 }
133 for ( const field of PRESENCE_KINDS [check.kind]) {
134 if ( typeof check[field] !== 'string' || ! check[field]. trim ()) errors. push ( `${ at }: ${ check . kind } needs a non-empty ${ field }` );
135 }
136 if (check.kind === 'postType' && check.statuses !== undefined
137 && ( ! Array. isArray (check.statuses) || check.statuses. length === 0 || check.statuses. some (( status ) => typeof status !== 'string' || ! status))) {
138 errors. push ( `${ at }: postType statuses must be a non-empty array of strings when present` );
139 }
140 const allowed = new Set ([ 'kind' , 'note' , 'statuses' , ... PRESENCE_KINDS [check.kind]]);
141 const unknownFields = Object. keys (check). filter (( key ) => ! allowed. has (key));
142 if (unknownFields. length ) errors. push ( `${ at }: unknown field(s) ${ unknownFields . join ( ', ' ) }` );
143 if (check.kind !== 'postType' && check.statuses !== undefined ) errors. push ( `${ at }: statuses only applies to postType` );
144 });
145 }
146
147 // A meta value counts as empty when, once decoded, it holds nothing but empty strings, nulls
148 // or empty containers. `a:1:{i:0;s:0:"";}` (an array of one empty URL) is empty; that exact
149 // value sat on 382 products of one store and is the reason this exists.
150 function isEmptyDecoded ( value ) {
151 if (value === null || value === undefined ) return true ;
152 if ( typeof value === 'string' ) return value. trim () === '' ;
153 if (Array. isArray (value)) return value. every (isEmptyDecoded);
154 if ( typeof value === 'object' ) return Object. values (value). every (isEmptyDecoded);
155 return false ; // numbers and booleans are values someone set
156 }
157
158 function isEmptyMetaValue ( raw ) {
159 if (raw === null || raw === undefined ) return true ;
160 if ( typeof raw !== 'string' ) return isEmptyDecoded (raw);
161 const text = raw. trim ();
162 if (text === '' ) return true ;
163 if ( / ^ (a | s | i | d | b | N):/ . test (text) || text === 'N;' ) {
164 try { return isEmptyDecoded ( phpUnserialize (text)); } catch { return false ; }
165 }
166 if (text. startsWith ( '{' ) || text. startsWith ( '[' )) {
167 try { return isEmptyDecoded ( JSON . parse (text)); } catch { return false ; }
168 }
169 return false ;
170 }
171
172 function observation ( plugin , check , result , extra = {}) {
173 return { id: checkId (plugin, check), plugin, kind: check.kind, target: checkTarget (check), result, ... extra };
174 }
175
176 async function pagedBridgeRows ( bridgeQuery , structureRequest , maxPages ) {
177 const rows = [];
178 let cursor;
179 for ( let page = 0 ; page < maxPages; page += 1 ) {
180 // eslint-disable-next-line no-await-in-loop
181 const response = await bridgeQuery (structureRequest, cursor, BRIDGE_PAGE_LIMIT );
182 rows. push ( ... (response.rows || []));
183 cursor = response.pagingMetadata?.cursors?.next || null ;
184 if ( ! cursor) return { rows, complete: true };
185 }
186 return { rows, complete: false };
187 }
188
189 async function observePostMeta ( plugin , check , { bridgeQuery }) {
190 if ( ! bridgeQuery) return observation (plugin, check, 'unknown' , { reason: 'bridge-unavailable' });
191 // Key-scoped distinct-value read: one row per distinct value with its count, so a key set
192 // on thousands of products to the same empty default costs one page, not thousands of rows.
193 const request = {
194 table: 'postmeta' ,
195 select: [{ column: 'meta_value' }, { column: 'meta_value' , aggregate: 'count' , as: 'n' }],
196 where: [{ column: 'meta_key' , op: 'in' , value: [check.key] }],
197 groupBy: [ 'meta_value' ],
198 orderBy: [{ column: 'meta_value' , direction: 'asc' }],
199 };
200 try {
201 const { rows , complete } = await pagedBridgeRows (bridgeQuery, request, MAX_META_VALUE_PAGES );
202 let rowCount = 0 ;
203 let nonEmpty = 0 ;
204 for ( const row of rows) {
205 const n = Number (row.n);
206 const count = Number. isFinite (n) ? n : 1 ;
207 rowCount += count;
208 if ( ! isEmptyMetaValue (row.meta_value)) nonEmpty += count;
209 }
210 if (nonEmpty > 0 ) return observation (plugin, check, 'present' , { count: nonEmpty, rows: rowCount });
211 if ( ! complete) return observation (plugin, check, 'unknown' , { reason: 'read-truncated' , rows: rowCount });
212 return observation (plugin, check, 'absent' , { count: 0 , rows: rowCount });
213 } catch (error) {
214 return observation (plugin, check, 'unknown' , { reason: `bridge-read-failed: ${ error . message }` });
215 }
216 }
217
218 async function observePostType ( plugin , check , { bridgeQuery }) {
219 if ( ! bridgeQuery) return observation (plugin, check, 'unknown' , { reason: 'bridge-unavailable' });
220 const where = [{ column: 'post_type' , op: 'eq' , value: check.type }];
221 if (check.statuses) where. push ({ column: 'post_status' , op: 'in' , value: check.statuses });
222 const request = {
223 table: 'posts' ,
224 select: [{ column: 'post_type' }, { column: 'post_type' , aggregate: 'count' , as: 'n' }],
225 where,
226 groupBy: [ 'post_type' ],
227 orderBy: [{ column: 'post_type' , direction: 'asc' }],
228 };
229 try {
230 const { rows , complete } = await pagedBridgeRows (bridgeQuery, request, 2 );
231 const count = rows. reduce (( total , row ) => total + ( Number (row.n) || 0 ), 0 );
232 if (count > 0 ) return observation (plugin, check, 'present' , { count });
233 if ( ! complete) return observation (plugin, check, 'unknown' , { reason: 'read-truncated' });
234 return observation (plugin, check, 'absent' , { count: 0 });
235 } catch (error) {
236 return observation (plugin, check, 'unknown' , { reason: `bridge-read-failed: ${ error . message }` });
237 }
238 }
239
240 async function observeShippingClassProducts ( plugin , check , { fetchRest }) {
241 if ( ! fetchRest) return observation (plugin, check, 'unknown' , { reason: 'rest-unavailable' });
242 try {
243 const response = await fetchRest ( '/wc/v3/products/shipping_classes' , { slug: check.slug, per_page: 100 });
244 if ( ! response?.ok || ! Array. isArray (response.json)) {
245 return observation (plugin, check, 'unknown' , { reason: `rest-read-failed: HTTP ${ response ?. status ?? 'n/a'}` });
246 }
247 const match = response.json. find (( term ) => term && term.slug === check.slug);
248 if ( ! match) return observation (plugin, check, 'absent' , { count: 0 , reason: 'class-not-defined' });
249 const count = Number (match.count);
250 if ( ! Number. isFinite (count)) return observation (plugin, check, 'unknown' , { reason: 'class-count-missing' });
251 return observation (plugin, check, count > 0 ? 'present' : 'absent' , { count });
252 } catch (error) {
253 return observation (plugin, check, 'unknown' , { reason: `rest-read-failed: ${ error . message }` });
254 }
255 }
256
257 async function observeShippingMethod ( plugin , check , { fetchRest }) {
258 if ( ! fetchRest) return observation (plugin, check, 'unknown' , { reason: 'rest-unavailable' });
259 try {
260 const zones = await fetchRest ( '/wc/v3/shipping/zones' , {});
261 if ( ! zones?.ok || ! Array. isArray (zones.json)) {
262 return observation (plugin, check, 'unknown' , { reason: `rest-read-failed: HTTP ${ zones ?. status ?? 'n/a'}` });
263 }
264 const ids = Array. from ( new Set ([ 0 , ... zones.json. map (( zone ) => zone?.id). filter (( id ) => Number. isInteger (id))]));
265 let enabled = 0 ;
266 let disabled = 0 ;
267 for ( const id of ids) {
268 // eslint-disable-next-line no-await-in-loop
269 const methods = await fetchRest ( `/wc/v3/shipping/zones/${ id }/methods` , {});
270 if ( ! methods?.ok || ! Array. isArray (methods.json)) {
271 return observation (plugin, check, 'unknown' , { reason: `rest-read-failed: zone ${ id } HTTP ${ methods ?. status ?? 'n/a'}` });
272 }
273 for ( const method of methods.json) {
274 if (method?.method_id !== check.methodId) continue ;
275 if (method.enabled === true ) enabled += 1 ; else disabled += 1 ;
276 }
277 }
278 return observation (plugin, check, enabled > 0 ? 'present' : 'absent' , { count: enabled, disabled });
279 } catch (error) {
280 return observation (plugin, check, 'unknown' , { reason: `rest-read-failed: ${ error . message }` });
281 }
282 }
283
284 const OBSERVERS = {
285 postMeta: observePostMeta,
286 postType: observePostType,
287 shippingClassProducts: observeShippingClassProducts,
288 shippingMethod: observeShippingMethod,
289 };
290
291 // The profiles that need a check on this run: detected AND declaring dataPresence.
292 function presenceTargets ( profiles , detection ) {
293 const detected = new Set ((detection?.detected || []). map (( plugin ) => plugin.plugin));
294 return (profiles || []). filter (( profile ) => detected. has (profile.plugin) && profile.dataPresence);
295 }
296
297 // Reads the site. `fetchRest(path, query)` -> { ok, status, json }; `bridgeQuery(request,
298 // cursor, limit)` -> { rows, pagingMetadata } (wix-wp-plugin-v2 queryStructure). Either may be
299 // null; checks that need it come back unknown.
300 async function collectPresenceObservations ({ profiles , detection , fetchRest = null , bridgeQuery = null }) {
301 const observations = [];
302 for ( const profile of presenceTargets (profiles, detection)) {
303 for ( const check of profile.dataPresence.checks) {
304 // eslint-disable-next-line no-await-in-loop
305 observations. push ( await OBSERVERS [check.kind](profile.plugin, check, { fetchRest, bridgeQuery }));
306 }
307 }
308 return observations;
309 }
310
311 function combine ( results , require ) {
312 if (require === 'all' ) {
313 if (results. some (( result ) => result === 'absent' )) return 'absent' ;
314 if (results. every (( result ) => result === 'present' )) return 'present' ;
315 return 'unknown' ;
316 }
317 if (results. some (( result ) => result === 'present' )) return 'present' ;
318 if (results. every (( result ) => result === 'absent' )) return 'absent' ;
319 return 'unknown' ;
320 }
321
322 function describeCheck ( entry ) {
323 const counted = Number. isFinite (entry.count) ? entry.count : null ;
324 switch (entry.kind) {
325 case 'postMeta' :
326 return entry.result === 'unknown'
327 ? `product/post field ${ entry . target }: could not be read`
328 : `${ counted } record(s) with a value in ${ entry . target }${ Number . isFinite ( entry . rows ) ? ` (of ${ entry . rows } carrying the field)` : ''}` ;
329 case 'postType' :
330 return entry.result === 'unknown' ? `${ entry . target } records: could not be counted` : `${ counted } ${ entry . target } record(s)` ;
331 case 'shippingClassProducts' :
332 return entry.result === 'unknown' ? `shipping class ${ entry . target }: could not be read` : `${ counted } product(s) in shipping class ${ entry . target }` ;
333 case 'shippingMethod' :
334 return entry.result === 'unknown'
335 ? `shipping method ${ entry . target }: could not be read`
336 : `${ counted } enabled ${ entry . target } shipping method(s)${ entry . disabled ? `, ${ entry . disabled } disabled` : ''}` ;
337 default :
338 return `${ entry . kind } ${ entry . target }: ${ entry . result }` ;
339 }
340 }
341
342 // Per detected plugin: { result, active, require, checks[] }. A profile that declares
343 // dataPresence but has no stored observation for a check gets `unknown` for it.
344 function presenceByPlugin ({ profiles , detection , observations = [] }) {
345 const byId = new Map ((observations || []). map (( entry ) => [entry.id, entry]));
346 const activeByPlugin = new Map ((detection?.detected || []). map (( plugin ) => [plugin.plugin, plugin.active]));
347 const out = new Map ();
348 for ( const profile of presenceTargets (profiles, detection)) {
349 const checks = profile.dataPresence.checks. map (( check ) => {
350 const stored = byId. get ( checkId (profile.plugin, check));
351 const entry = stored && RESULTS . has (stored.result)
352 ? stored
353 : observation (profile.plugin, check, 'unknown' , { reason: 'not-observed' });
354 return { ... entry, summary: describeCheck (entry) };
355 });
356 out. set (profile.plugin, {
357 plugin: profile.plugin,
358 displayName: profile.displayName || profile.plugin,
359 active: activeByPlugin. get (profile.plugin),
360 require: profile.dataPresence.require,
361 result: combine (checks. map (( check ) => check.result), profile.dataPresence.require),
362 checks,
363 });
364 }
365 return out;
366 }
367
368 // The verdict for one capability, over every detected plugin that declares it. Null means
369 // "no presence verdict": the capability takes its normal profile path.
370 // Both outcomes need every plugin's dataLocation to be `wordpress`.
371 // no-data-found every plugin's checks came back absent.
372 // inactive-leftover nothing unknown, every plugin inactive, data present (product
373 // decision: don't move it, show it as leftover).
374 function capabilityPresence ( plugins , presence ) {
375 if ( ! presence || presence.size === 0 || ! plugins?. length ) return null ;
376 const summaries = plugins. map (( plugin ) => presence. get (plugin.plugin));
377 if (summaries. some (( summary ) => ! summary)) return null ;
378 const checks = summaries. flatMap (( summary ) => summary.checks. map (( check ) => ({
379 plugin: check.plugin, kind: check.kind, target: check.target, result: check.result,
380 ... (Number. isFinite (check.count) ? { count: check.count } : {}),
381 ... (check.reason ? { reason: check.reason } : {}),
382 summary: check.summary,
383 })));
384 // An empty WordPress read says nothing about data kept elsewhere: only plugins that keep
385 // their data in WordPress may be ruled "no data" or "leftover".
386 if (plugins. some (( plugin ) => dataLocationOf (plugin) !== 'wordpress' )) {
387 return { outcome: null , checks, heldBack: 'data-location-not-wordpress' };
388 }
389 const evidence = summaries. map (( summary ) => summary.checks. map (( check ) => check.summary). join ( '; ' )). join ( ' | ' );
390 const names = summaries. map (( summary ) => summary.displayName). join ( ', ' );
391 if (summaries. every (( summary ) => summary.result === 'absent' )) {
392 return {
393 outcome: 'no-data-found' ,
394 checks,
395 rationale: `${ names } is installed but holds no data on this site. Checked: ${ evidence }.` ,
396 };
397 }
398 if (summaries. every (( summary ) => summary.result !== 'unknown' )
399 && summaries. every (( summary ) => summary.active === false )) {
400 return {
401 outcome: 'inactive-leftover' ,
402 checks,
403 rationale: `${ names } is inactive — leftover data, not used by the store, so it is not moved. Found: ${ evidence }.` ,
404 };
405 }
406 return { outcome: null , checks };
407 }
408
409 module . exports = {
410 PRESENCE_KINDS,
411 DATA_LOCATIONS,
412 MAX_META_VALUE_PAGES,
413 checkId,
414 validateDataPresence,
415 validateDataLocation,
416 dataLocationOf,
417 externalDataTasks,
418 OPTIONS_UNKNOWN,
419 isEmptyMetaValue,
420 collectPresenceObservations,
421 presenceByPlugin,
422 capabilityPresence,
423 combine,
424 };