Setting the file. One moment.
Structure Bridge Planning · Rp Mapper · wix/skills · Skills Docs
Structure Bridge Planning ContentsBack to the top of the page This file
Number 27.7
Position 7 of 8
Type JavaScript
Size 18 KB
Lines 323 lib/ structure-bridge-planning.js
JavaScript · 323 lines · 18 KB
// is planned through another source (for example gift-card balance). That stays on the
15 // blockedSourceDependencies / blocked-data-requests.js path, unchanged.
16 //
17 // A target entity may declare a blockedSourceDependency on a source that is ALSO planned here
18 // as its own entity (ecom/discount-rule on discount-rule-definition). Routing it through both
19 // paths would offer the same table as a one-shot blocked-data request that can never resolve,
20 // so partitionBlockedSourceDependencies() removes that dependency before the blocked-data
21 // builder runs and records it on the planned entity's decision instead.
22 //
23 // Readiness is the only thing checked through blocked-data-handlers.js; data is never resolved
24 // through blocked-data-requests.js's snapshot mechanism.
25
26 const fsp = require ( 'node:fs/promises' );
27 const path = require ( 'node:path' );
28 const { fulfillmentReadiness } = require ( '../../rp-source-wordpress/lib/blocked-data-handlers.js' );
29 const { readEntityByRef } = require ( '../../rp-target-wix/lib/domain-knowledge.js' );
30 const { verifyEavReadPlanIntegrity } = require ( '../../rp-source-wordpress/lib/wix-wp-plugin-v2-client.js' );
31
32 const DEFAULT_DOMAINS_DIR = path. join (__dirname, '..' , '..' , 'rp-target-wix' , 'domains' );
33 const REVIEW_GATE = Object. freeze ({ artifact: 'orchestration/approvals.json' , field: 'mapping.status' });
34 const PLAN_ROW_KEYS = [ 'entityMappings' , 'entityDecisions' , 'mappings' , 'entities' ];
35
36 function slugify ( value ) {
37 return String (value). toLowerCase (). replace ( / [ ^ a-z0-9] + / g , '-' ). replace ( / ^ - +| - +$ / g , '' ) || 'entity' ;
38 }
39
40 function sourceEntityRefOf ( plugin , entity ) {
41 return `plugin.${ plugin }.${ entity }` ;
42 }
43
44 function structureBridgeFulfillment ( entity ) {
45 const blockers = [ ... (entity.blocked || []), ... ((entity.pitfalls || []). flatMap (( p ) => p.blocked || []))];
46 const hit = blockers. find (( b ) => b.fulfillment && b.fulfillment.kind === 'structure-bridge-plugin' );
47 return hit ? hit.fulfillment : null ;
48 }
49
50 async function readJsonIfExists ( filePath ) {
51 try {
52 return JSON . parse ( await fsp. readFile (filePath, 'utf8' ));
53 } catch (error) {
54 if (error.code === 'ENOENT' ) return null ;
55 throw error;
56 }
57 }
58
59 /** A discovery artifact that is not valid JSON is a pending reason, not a crashed mapping run. */
60 async function readArtifact ( filePath , fileName , pendingReasons ) {
61 try {
62 return await readJsonIfExists (filePath);
63 } catch (error) {
64 if ( ! (error instanceof SyntaxError )) throw error;
65 pendingReasons. push ( `discovery artifact ${ fileName } is not valid JSON -- re-run discovery for this table` );
66 return null ;
67 }
68 }
69
70 /** Why an EAV read plan cannot drive extraction, or null. Mirrors the reader's own checks. */
71 function eavPlanIntegrityReason ( plan , planFile ) {
72 if ( ! plan || typeof plan !== 'object' || Array. isArray (plan)) return `read plan ${ planFile } is not an object -- re-run discovery for this table` ;
73 let problem;
74 try { problem = verifyEavReadPlanIntegrity (plan); } catch (error) { problem = error.message; }
75 if ( ! Array. isArray (plan.pairs)) problem = problem || 'it has no pairs array' ;
76 return problem ? `read plan ${ planFile } fails its integrity check, so extraction would defer: ${ problem }` : null ;
77 }
78
79 function loadTarget ( domainsDir , ref ) {
80 try { return readEntityByRef (domainsDir, ref); } catch { return null ; }
81 }
82
83 /**
84 * Classify every structure-bridge-plugin entity of the detected plugins.
85 *
86 * @param plugins [{ plugin, entities: [...] }] -- profiles or wp-plugin-detect's detected list
87 * @param dataDir the project's `data/` directory (discovery sidecars live in wp-discovery/)
88 * @param readiness optional override of blocked-data-handlers.js's fulfillmentReadiness
89 * @returns { decisions, blockedFieldSources }
90 */
91 async function planStructureBridgeEntities ({ plugins = [], sourceEntities = null , dataDir , readiness = fulfillmentReadiness, domainsDir = DEFAULT_DOMAINS_DIR } = {}) {
92 if ( ! dataDir) throw new Error ( 'planStructureBridgeEntities needs dataDir' );
93 if (sourceEntities === null ) {
94 const schema = await readJsonIfExists (path. join (path. dirname (path. resolve (dataDir)), 'source-schema.json' ));
95 sourceEntities = Array. isArray (schema?.entities) ? schema.entities
96 : Object. entries (schema?.entities || {}). map (([ key , row ]) => ({ sourceEntityRef: key, ... row }));
97 }
98 const decisions = [];
99 const blockedFieldSources = [];
100 const boundRefs = new Set ();
101 const bound = require ( '../../rp-source-wordpress/lib/plugin-entity-read-plan.js' );
102 for ( const entity of sourceEntities) {
103 const planRef = entity.sourceMeta?.extractionPlan;
104 if ( ! planRef && ! entity.bridgeDesignated) continue ;
105 const sourceEntityRef = entity.sourceEntityRef;
106 boundRefs. add (sourceEntityRef);
107 const pendingReasons = [];
108 let plan;
109 try { plan = bound. loadPlan ({ projectDir: path. dirname (dataDir), sourceEntityRef, planRef }); }
110 catch (error) {
111 pendingReasons. push (error.message);
112 bound. recordPluginExtractionDeferral ({ projectDir: path. dirname (dataDir), sourceEntityRef, planRef, code: error.code || 'plan-invalid' , reason: error.message });
113 }
114 const candidates = entity.candidateTargetRefs || entity.sourceMeta?.candidateTargetRefs || [];
115 if ( ! candidates. length ) {
116 blockedFieldSources. push ({ sourceEntityRef, sourceEntity: entity.entity || sourceEntityRef, table: plan?.components. length === 1 ? plan.components[ 0 ].table : null , route: 'blocked-source-dependency' });
117 continue ;
118 }
119 const targetRef = candidates[ 0 ];
120 const target = loadTarget (domainsDir, targetRef);
121 if ( ! target) pendingReasons. push ( `candidate target ${ targetRef } is not in the Wix domain knowledge` );
122 decisions. push ({ schemaVersion: 1 , sourceEntityRef, sourceEntity: sourceEntityRef,
123 sourcePlugin: sourceEntityRef. split ( '.' )[ 1 ], sourceMeta: { ... entity.sourceMeta }, bridgeDesignated: true ,
124 sourceType: entity.sourceType || null , sourcePrerequisites: entity.sourcePrerequisites || [],
125 resolution: 'structure-bridge-reader' , fulfillment: { kind: 'structure-bridge-plugin' , handlerId: 'wix-wp-plugin-v2' },
126 targetRef, targetDomain: targetRef. split ( '/' )[ 0 ], targetEntity: targetRef. split ( '/' )[ 1 ], targetClassification: target?.target?.classification || null , candidateTargetRefs: candidates,
127 confidence: 'proposed' , status: pendingReasons. length ? 'pending' : 'migration-planned' , pendingReasons,
128 readiness: { ready: Boolean (plan), reason: pendingReasons. join ( '; ' ) || null }, readVariant: 'entity-plan' ,
129 readArtifacts: { extractionPlan: planRef || null },
130 sourceExtract: { ndjson: `data/source-extract/${ sourceEntityRef }.ndjson` , manifest: `data/source-extract/${ sourceEntityRef }.manifest.json` },
131 supersedesBlockedDependencies: [], reviewGate: { ... REVIEW_GATE } });
132 }
133 for ( const plugin of plugins) {
134 for ( const entity of plugin.entities || []) {
135 const fulfillment = structureBridgeFulfillment (entity);
136 if ( ! fulfillment) continue ;
137 const sourceEntityRef = sourceEntityRefOf (plugin.plugin, entity.entity);
138 if (boundRefs. has (sourceEntityRef)) continue ;
139 const candidateTargetRefs = [ ... (entity.candidateTargetRefs || [])];
140 if (candidateTargetRefs. length === 0 ) {
141 blockedFieldSources. push ({ sourceEntityRef, sourceEntity: entity.entity, table: fulfillment.table, route: 'blocked-source-dependency' });
142 continue ;
143 }
144
145 const requestFile = `wix-wp-plugin-v2--${ slugify ( fulfillment . table ) }.structure-request.json` ;
146 const planFile = `wix-wp-plugin-v2--${ slugify ( fulfillment . table ) }.eav-read-plan.json` ;
147 const artifactReasons = [];
148 // eslint-disable-next-line no-await-in-loop
149 const [ request , plan , ready ] = await Promise . all ([
150 readArtifact (path. join (dataDir, 'wp-discovery' , requestFile), requestFile, artifactReasons),
151 readArtifact (path. join (dataDir, 'wp-discovery' , planFile), planFile, artifactReasons),
152 readiness (fulfillment),
153 ]);
154 const targetRef = candidateTargetRefs[ 0 ];
155 const target = loadTarget (domainsDir, targetRef);
156 const pendingReasons = [ ... artifactReasons];
157 if ( ! ready || ready.ready !== true ) pendingReasons. push ( `handler not ready: ${ ( ready && ready . reason ) || 'unknown'}` );
158 if ( ! target) pendingReasons. push ( `candidate target ${ targetRef } is not in the Wix domain knowledge` );
159 if ( ! request && ! plan && artifactReasons. length === 0 ) pendingReasons. push ( `discovery produced no read artifact (${ requestFile }) -- the table was not sampled successfully` );
160 const integrity = plan ? eavPlanIntegrityReason (plan, planFile) : null ;
161 if (integrity) pendingReasons. push (integrity);
162 if (plan && Array. isArray (plan.droppedIdentityColumns) && plan.droppedIdentityColumns. length > 0 ) {
163 pendingReasons. push ( `read plan drops identity column(s) ${ plan . droppedIdentityColumns . join ( ', ' ) }; complete identity is required -- narrow the mapping or redesign the read plan` );
164 }
165 if (plan && plan.keysTruncated) pendingReasons. push ( 'read plan key list is truncated, so the extraction cannot be whole-table' );
166
167 decisions. push ({
168 schemaVersion: 1 ,
169 sourceEntity: entity.entity,
170 sourceEntityRef,
171 sourcePlugin: plugin.plugin,
172 capability: entity.capability || null ,
173 channel: entity.channel || null ,
174 resolution: 'structure-bridge-reader' ,
175 fulfillment: { kind: fulfillment.kind, handlerId: fulfillment.handlerId, table: fulfillment.table },
176 readiness: { ready: Boolean (ready && ready.ready), reason: (ready && ready.reason) || null },
177 targetRef,
178 targetDomain: targetRef. split ( '/' )[ 0 ],
179 targetEntity: targetRef. split ( '/' )[ 1 ],
180 targetClassification: target && target.target ? target.target.classification : null ,
181 candidateTargetRefs,
182 confidence: 'proposed' ,
183 status: pendingReasons. length === 0 ? 'migration-planned' : 'pending' ,
184 pendingReasons,
185 readVariant: plan ? 'eav' : 'single-request' ,
186 readArtifacts: {
187 structureRequest: request ? `data/wp-discovery/${ requestFile }` : null ,
188 eavReadPlan: plan ? `data/wp-discovery/${ planFile }` : null ,
189 },
190 sourceExtract: {
191 ndjson: `data/source-extract/${ entity . entity }.ndjson` ,
192 manifest: `data/source-extract/${ entity . entity }.manifest.json` ,
193 },
194 supersedesBlockedDependencies: [],
195 reviewGate: { ... REVIEW_GATE },
196 });
197 }
198 }
199 return { decisions, blockedFieldSources };
200 }
201
202 /**
203 * Remove blockedSourceDependencies whose source is planned as its own entity, so the
204 * blocked-data builder never also requests it. Returns new target objects (inputs untouched) and
205 * records each removed dependency on the matching decision.
206 */
207 function partitionBlockedSourceDependencies ( targetEntities = [], decisions = []) {
208 const byRef = new Map (decisions. map (( d ) => [d.sourceEntityRef, d]));
209 const targets = targetEntities. map (( target ) => {
210 const deps = Array. isArray (target.blockedSourceDependencies) ? target.blockedSourceDependencies : [];
211 const kept = [];
212 for ( const dep of deps) {
213 const decision = byRef. get (dep.sourceEntityRef);
214 if ( ! decision) { kept. push (dep); continue ; }
215 const targetEntity = target.ref || `${ target . domain }/${ target . entity }` ;
216 if ( ! decision.supersedesBlockedDependencies. some (( s ) => s.targetEntity === targetEntity && s.degradedField === dep.degradedField)) {
217 decision.supersedesBlockedDependencies. push ({ targetEntity, degradedField: dep.degradedField, pitfallCode: dep.pitfallCode });
218 }
219 }
220 return { ... target, blockedSourceDependencies: kept };
221 });
222 return { targetEntities: targets, decisions };
223 }
224
225 // Fields rp-mapper or the operator may already have set on a row, which a planning rerun keeps.
226 // Everything else planning emits is bridge-owned structure (target, readiness, read variant,
227 // artifacts, planner-owned status) and is refreshed; fields planning never emits -- field mappings,
228 // transformations, relationships, notes, operator edits -- are always preserved.
229 const PRESERVED_WHEN_SET = [ 'confidence' ];
230
231 /** Merge a bridge decision into an existing row without erasing the mapping work on it. */
232 function mergeBridgeRow ( existing , decision ) {
233 if ( ! existing || typeof existing !== 'object' || Array. isArray (existing)) return { ... decision };
234 const merged = { ... existing, ... decision };
235 // Only the planner's own provisional states may refresh. Reviewed exclusions,
236 // manual routing and other mapping decisions retain their status and reason.
237 if (existing.status && ! [ 'pending' , 'migration-planned' ]. includes (existing.status)) merged.status = existing.status;
238 if (decision.readVariant === 'entity-plan' && existing.targetRef) {
239 for ( const key of [ 'targetRef' , 'targetDomain' , 'targetEntity' , 'targetClassification' ]) {
240 if (existing[key] !== undefined ) merged[key] = existing[key];
241 }
242 }
243 for ( const key of PRESERVED_WHEN_SET ) {
244 if (existing[key] !== undefined && existing[key] !== null ) merged[key] = existing[key];
245 }
246 return merged;
247 }
248
249 function upsertPlanRows ( plan , decisions ) {
250 if (plan.entities && typeof plan.entities === 'object' && ! Array. isArray (plan.entities)
251 && ! PLAN_ROW_KEYS . some (( k ) => Array. isArray (plan[k]))) {
252 for ( const d of decisions) plan.entities[d.sourceEntity] = mergeBridgeRow (plan.entities[d.sourceEntity], d);
253 return plan;
254 }
255 const key = [ 'entities' , ... PLAN_ROW_KEYS ]. find (( k ) => Array. isArray (plan[k])) || 'entityDecisions' ;
256 const rows = Array. isArray (plan[key]) ? plan[key] : [];
257 for ( const d of decisions) {
258 const index = rows. findIndex (( row ) => row && (row.sourceEntityRef === d.sourceEntityRef || (row.sourceEntity || row.entityName || row.entityKey) === d.sourceEntity));
259 const decision = key === 'entities' ? { entityKey: index >= 0 ? rows[index].entityKey || d.sourceEntity : d.sourceEntity, ... d } : d;
260 if (index >= 0 ) rows[index] = mergeBridgeRow (rows[index], decision); else rows. push ( mergeBridgeRow ( null , decision));
261 }
262 plan[key] = rows;
263 return plan;
264 }
265
266 async function writeJson ( filePath , value ) {
267 await fsp. mkdir (path. dirname (filePath), { recursive: true });
268 const staging = `${ filePath }.writing` ;
269 await fsp. writeFile (staging, `${ JSON . stringify ( value , null , 2 ) } \n ` , 'utf8' );
270 await fsp. rename (staging, filePath);
271 }
272
273 /**
274 * Write the planned entities into the mapping artifacts. Refuses once the mapping review has
275 * been approved: adding an entity after the gate would skip the review it depends on.
276 */
277 async function writeStructureBridgePlanning ( projectDir , decisions ) {
278 if ( ! projectDir) throw new Error ( 'writeStructureBridgePlanning needs projectDir' );
279 const approvals = await readJsonIfExists (path. join (projectDir, 'orchestration' , 'approvals.json' ));
280 const gate = approvals && approvals.mapping && approvals.mapping.status;
281 if (gate === 'approved' || gate === 'provisional' ) {
282 throw new Error ( `mapping review is already ${ gate }; re-open the mapping review before adding structure-bridge-plugin entities` );
283 }
284 const decisionPaths = [];
285 const planPath = path. join (projectDir, 'mapping' , 'mapping-plan.json' );
286 const plan = ( await readJsonIfExists (planPath)) || {};
287 const canonical = [];
288 for ( const decision of decisions) {
289 const filePath = path. join (projectDir, 'mapping' , 'entity-decisions' , `${ slugify ( decision . sourceEntity ) }.json` );
290 const prior = require ( '../../../lib/mapping-field-contract.js' ). entityRows (plan). find ( r => r.sourceEntity === decision.sourceEntity);
291 const legacy =await readJsonIfExists (filePath);
292 if (legacy) delete legacy.executionScope;
293 const row = mergeBridgeRow ({ ... legacy, ... prior},decision);
294 if (plan.fieldContractVersion === 1 && ! prior?.fieldMappings) {
295 row.fieldMappingStatus = 'pending' ;row.fieldMappings = [];row.requiredSourceEntityRefs = [];
296 row.discoveryCoverage = {paths:[],uninspectedAreas:[ 'Bridge source fields have not been inspected for exact mapping.' ],sourceSnapshotDigest: null ,inventoryDigest: null ,evidence:[{ref:row.structureReadPlan || row.sourceEntityRef || row.sourceEntity}]};
297 }
298 canonical. push (row);
299 decisionPaths. push (filePath);
300 }
301 upsertPlanRows (plan,canonical);
302 if (plan.fieldContractVersion === 1 ) {
303 const contract = require ( '../../../lib/mapping-field-contract.js' );
304 plan.executionScope = {selectedEntityRefs:[ ...new Set ([ ... (plan.executionScope?.selectedEntityRefs || contract. entityRows (plan). map (contract.entityRef)), ... canonical. map (contract.entityRef)])]};
305 await contract. writeMappingAuthority (projectDir,plan);
306 return {decisionPaths,planPath};
307 }
308 await writeJson (planPath,plan);
309 for ( let i = 0 ;i < canonical. length ;i ++ ) {
310 const rows = require ( '../../../lib/mapping-field-contract.js' ). entityRows (plan);
311 const row = rows. find ( r => r.sourceEntity === canonical[i].sourceEntity);
312 await writeJson (decisionPaths[i],plan.fieldContractVersion === 1 ? { ... row,executionScope:plan.executionScope} : row);
313 }
314 return { decisionPaths, planPath };
315 }
316
317 module . exports = {
318 REVIEW_GATE,
319 planStructureBridgeEntities,
320 partitionBlockedSourceDependencies,
321 writeStructureBridgePlanning,
322 sourceEntityRefOf,
323 };