Setting the file. One moment.
Plugin Entity Read Plan · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page — line 55
This file
Number 39.21
Position 21 of 92
Type JavaScript
Size 56 KB
Lines 812 lib/ plugin-entity-read-plan.js
JavaScript · 812 lines · 56 KB
'./wix-wp-plugin-v2-client.js'
);
9 const TABLE = / ^ [A-Za-z0-9_] +$ / ;
10 const TOKEN = / ^ [a-z0-9_-] +$ / ;
11 const ENTITY = / ^ plugin \. [a-z0-9_-] + \. [a-z0-9_-] +$ / ;
12 const HASH = / ^ [a-f0-9] {64}$ / ;
13 const KIND = 'structure-bridge-deferred-extraction' ;
14 const sha = ( bytes ) => crypto. createHash ( 'sha256' ). update (bytes). digest ( 'hex' );
15 const json = ( file ) => JSON . parse (fs. readFileSync (file, 'utf8' ));
16 const rel = ( root , file ) => path. relative (root, file). split (path.sep). join ( '/' );
17 const canonical = ( value ) => Array. isArray (value) ? value. map (canonical) : value && typeof value === 'object'
18 ? Object. fromEntries (Object. keys (value). sort (). map (( key ) => [key, canonical (value[key])])) : value;
19 const same = ( a , b ) => JSON . stringify ( canonical (a)) === JSON . stringify ( canonical (b));
20 function fail ( message , code = 'plan-invalid' ) { throw Object. assign ( new Error (message), { code }); }
21
22 function sourceIdentity ( url ) {
23 const parsed = new URL (url);
24 if ( ! [ 'https:' , 'http:' ]. includes (parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) fail ( 'source must be a credential-free base URL' );
25 return parsed.href. replace ( / \/ +$ / , '' );
26 }
27 function sourceEntityRef ( plugin , entity ) {
28 if ( ! TOKEN . test (plugin) || ! TOKEN . test (entity)) fail ( 'plugin/entity must use inventory tokens' );
29 return `plugin.${ plugin }.${ entity }` ;
30 }
31 function safeFile ( projectDir , relativePath , roots = [ 'data/wp-discovery' , 'data/source-extract' , 'src/extract' , 'src/lib' ]) {
32 if ( typeof relativePath !== 'string' || ! relativePath || path. isAbsolute (relativePath)
33 || relativePath. includes ( ' \\ ' ) || relativePath. split ( '/' ). some (( part ) => ! part || part === '..' || part === '.' )) fail ( 'artifact path must be contained and project-relative' );
34 if ( ! roots. some (( root ) => relativePath. startsWith ( `${ root }/` ))) fail ( 'artifact path outside permitted directories' );
35 const root = fs. realpathSync (projectDir);
36 const absolute = path. resolve (root, relativePath);
37 let cursor = root;
38 for ( const part of relativePath. split ( '/' )) {
39 cursor = path. join (cursor, part);
40 if (fs. lstatSync (cursor, { throwIfNoEntry: false })?. isSymbolicLink ()) fail ( 'symlink artifact paths are not permitted' );
41 }
42 return absolute;
43 }
44 function checkedFile ( projectDir , reference , roots ) {
45 if ( ! reference || ! HASH . test (reference.sha256)) fail ( 'artifact reference needs sha256' );
46 const file = safeFile (projectDir, reference.path, roots);
47 if ( ! fs. existsSync (file) || sha (fs. readFileSync (file)) !== reference.sha256) fail ( `missing or changed artifact: ${ reference . path }` );
48 return file;
49 }
50 function atomic ( file , bytes ) {
51 fs. mkdirSync (path. dirname (file), { recursive: true });
52 fs. writeFileSync ( `${ file }.writing` , bytes);
53 fs. renameSync ( `${ file }.writing` , file);
54 }
55 function writeJson ( projectDir , relativePath , value ) {
56 const file = safeFile (projectDir, relativePath);
57 const bytes = `${ JSON . stringify ( value , null , 2 ) } \n ` ;
58 atomic (file, bytes);
59 return { path: relativePath, sha256: sha (bytes) };
60 }
61 function referenceFor ( projectDir , relativePath ) {
62 const file = safeFile (projectDir, relativePath);
63 return { path: relativePath, sha256: sha (fs. readFileSync (file)) };
64 }
65 function entityPaths ( projectDir , entity ) {
66 if ( ! ENTITY . test (entity)) fail ( 'invalid canonical plugin entity' );
67 const base = `data/source-extract/${ entity }` ;
68 return Object. fromEntries ([ 'ndjson' , 'manifest.json' , 'deferred.json' ]. map (( suffix ) => [suffix, safeFile (projectDir, `${ base }.${ suffix }` )]));
69 }
70 function validatePlan ( projectDir , plan , expectedEntity , siteBaseUrl ) {
71 if ( ! plan || plan.schemaVersion !== 1 || ! ENTITY . test (plan.sourceEntityRef)) fail ( 'invalid entity plan version or identity' );
72 if (expectedEntity && expectedEntity !== plan.sourceEntityRef) fail ( 'entity-plan identity mismatch' );
73 if ( sourceIdentity (plan.sourceIdentity) !== sourceIdentity (siteBaseUrl || plan.sourceIdentity)) fail ( 'entity-plan source mismatch' );
74 if ( ! Array. isArray (plan.evidenceRefs) || ! plan.evidenceRefs. length ) fail ( 'storage evidence is required' );
75 plan.evidenceRefs. forEach (( ref ) => checkedFile (projectDir, ref, [ 'data/wp-discovery' ]));
76 if ( ! Array. isArray (plan.components) || ! plan.components. length ) fail ( 'components are required' );
77 const roots = plan.components. filter (( component ) => ! component.scope?.parent);
78 if (roots. length !== 1 ) fail ( 'plan requires exactly one root' );
79 const ids = new Set ();
80 for ( const component of plan.components) {
81 if ( ! TOKEN . test (component.id) || ids. has (component.id) || ! TABLE . test (component.table)) fail ( 'invalid component identity/table' );
82 ids. add (component.id);
83 if ( ! [ 'single-request' , 'eav' ]. includes (component.variant)) fail ( 'unknown component variant' );
84 const parent = component.scope?.parent;
85 if (parent && (parent.component !== roots[ 0 ].id || parent.component === component.id || ! TABLE . test (parent.column) || ! TABLE . test (parent.parentColumn))) fail ( 'only direct root-child relationships are supported' );
86 client. validateEavScope (component.scope?.where || [], (parent ? 1 : 0 ) + (component.variant === 'eav' ? 1 : 0 ));
87 if ( ! parent && ! component.scope?.where?. length && (client. SHARED_CORE_TABLES_REQUIRING_WHERE . has (component.table) || [ 'postmeta' , 'commentmeta' , 'termmeta' ]. includes (component.table))) fail ( 'shared table requires an explicit entity scope' );
88 if ( ! component.schema || ! Array. isArray (component.schema.columns) || component.schema.table !== component.table) fail ( 'component schema evidence is required' );
89 const refs = component.readArtifacts;
90 if ( ! refs || Object. keys (refs). some (( key ) => ! [ 'structureRequest' , 'eavReadPlan' ]. includes (key))) fail ( 'explicit component read artifacts required' );
91 const key = component.variant === 'eav' ? 'eavReadPlan' : 'structureRequest' ;
92 const artifact = json ( checkedFile (projectDir, refs[key], [ 'data/wp-discovery' ]));
93 if (artifact.table !== component.table) fail ( 'component physical table mismatch' );
94 const where = component.scope?.where || [];
95 if (component.variant === 'eav' ) {
96 const problem = client. verifyEavReadPlanIntegrity (artifact);
97 if (problem) fail (problem);
98 if (artifact.keysTruncated || artifact.droppedIdentityColumns?. length ) fail ( 'incomplete EAV keys/identity' );
99 if ( ! component.schema.eavPair) fail ( 'EAV schema pair is required' );
100 for ( const batch of client. eavReadPlanBatches (artifact)) {
101 client. validateStructureRequest (batch.structureRequest);
102 if (batch.structureRequest.table !== component.table || ! where. every (( condition ) => batch.structureRequest.where?. some (( c ) => same (c, condition)))) fail ( 'EAV table/scope mismatch' );
103 }
104 } else {
105 client. validateStructureRequest (artifact);
106 if ( ! same (artifact.where || [], where)) fail ( 'request scope mismatch' );
107 }
108 }
109 const assembly = plan.assembly;
110 if ( ! assembly || ! [ 'identity' , 'eav-pivot-v1' , 'module' ]. includes (assembly.kind) || ! TABLE . test (assembly.identityColumn)) fail ( 'assembly needs explicit identity' );
111 if (assembly.kind === 'module' ) {
112 checkedFile (projectDir, assembly.module, [ 'src/extract' , 'src/lib' ]);
113 if ( ! TABLE . test (assembly.export)) fail ( 'assembly export is required' );
114 }
115 if (assembly.kind === 'identity' && plan.components. length !== 1 ) fail ( 'identity assembly cannot discard child components' );
116 if (assembly.kind === 'eav-pivot-v1' && ! assembly.keyColumn) {
117 const children = assembly.children || [];
118 if (children. length !== plan.components. length - 1 || new Set (children. map (( child ) => child.component)).size !== children. length ) fail ( 'pivot must name every child exactly once' );
119 for ( const child of children) {
120 const component = plan.components. find (( item ) => item.id === child.component);
121 if ( ! component?.scope?.parent || component.scope.parent.column !== child.foreignKey || component.scope.parent.parentColumn !== assembly.identityColumn
122 || component.schema.eavPair?.keyColumn !== child.keyColumn || component.schema.eavPair?.valueColumn !== child.valueColumn) fail ( 'pivot relationship must match component schema/scope' );
123 }
124 }
125 if ( ! plan.cost || typeof plan.cost !== 'object' || ! Object. keys (plan.cost). length ) fail ( 'plan cost/unknowns are required' );
126 return plan;
127 }
128 function loadPlan ({ projectDir , sourceEntityRef : entity , planRef , siteBaseUrl }) {
129 if ( ! planRef) fail ( 'discovery has not produced an entity plan' , 'plan-unavailable' );
130 const plan = json ( checkedFile (projectDir, planRef, [ 'data/wp-discovery' ]));
131 return validatePlan (projectDir, plan, entity, siteBaseUrl);
132 }
133 function assertCanonicalBinding ({ projectDir , sourceEntityRef : entity , planRef , siteBaseUrl }) {
134 const schemaPath = path. join (projectDir, 'source-schema.json' );
135 if (fs. lstatSync (schemaPath, { throwIfNoEntry: false })?. isSymbolicLink ()) fail ( 'source schema cannot be a symlink' );
136 const schema = json (schemaPath);
137 const rows = Array. isArray (schema.entities) ? schema.entities : Object. entries (schema.entities || {}). map (([ key , row ]) => ({ sourceEntityRef: key, ... row }));
138 const source = rows. find (( row ) => row.sourceEntityRef === entity);
139 if ( ! source?.sourceMeta?.extractionPlan || ! planRef) fail ( 'current canonical entity plan is unavailable' , 'plan-unavailable' );
140 if ( ! source || ! same (source.sourceMeta?.extractionPlan, planRef)) fail ( 'canonical source binding changed or is unavailable' );
141 if (siteBaseUrl && sourceIdentity (schema.sourceUrl || schema.source?.url) !== sourceIdentity (siteBaseUrl)) fail ( 'canonical source URL changed' );
142 }
143 function invalidatePluginEntityReadPlan ({ projectDir , sourceEntityRef : entity }) {
144 const file = path. join (projectDir, 'source-schema.json' );
145 if ( ! fs. existsSync (file)) return ;
146 if (fs. lstatSync (file). isSymbolicLink ()) fail ( 'source schema cannot be a symlink' );
147 const schema = json (file);
148 const rows = Array. isArray (schema.entities) ? schema.entities : Object. entries (schema.entities || {}). map (([ key , row ]) => Object. assign (row, { sourceEntityRef: row.sourceEntityRef || key }));
149 const row = rows. find (( item ) => item.sourceEntityRef === entity);
150 if (row) {
151 row.sourceMeta = { ... row.sourceMeta, extractionPlan: null };
152 row.bridgeDesignated = true ;
153 atomic (file, `${ JSON . stringify ( schema , null , 2 ) } \n ` );
154 }
155 }
156 // An entity with no declared targets is an unprofiled plugin's own table (a profiled one always
157 // carries `candidateTargetRefs`, possibly empty). It becomes a proposed mapping entity: the native
158 // refs of the capability guess plus `cms/data-item`, decided at the mapping review. Never overwrites
159 // declared targets.
160 function proposedTargets ( sourceEntity , plan , extractionPlan ) {
161 if (sourceEntity.candidateTargetRefs !== undefined || sourceEntity.sourceMeta?.candidateTargetRefs !== undefined ) return {};
162 let capabilityRefs = new Map ();
163 try {
164 const { knowledgeRoot , knowledgeSummary } = require ( '../../rp-target-wix/lib/domain-knowledge.js' );
165 capabilityRefs = knowledgeSummary ( knowledgeRoot (path. resolve (__dirname, '..' , '..' , 'rp-target-wix' ))).capabilityRefs;
166 } catch { /* No target knowledge: only the CMS archive is proposed. */ }
167 const proposal = require ( './wp-plugin-detect.js' ). proposeBridgeEntity ({ sourceEntityRef: plan.sourceEntityRef,
168 table: plan.components[ 0 ]?.table, displayName: sourceEntity.displayName || sourceEntity.entityName }, capabilityRefs);
169 if ( ! proposal) return {};
170 return { basis: 'proposed' , recognized: false , proposedCapability: proposal.proposedCapability,
171 candidateTargetRefs: proposal.candidateTargetRefs, readPlanRef: extractionPlan };
172 }
173 function writePluginEntityReadPlan ({ projectDir , plan , sourceEntity = null , siteBaseUrl }) {
174 validatePlan (projectDir, plan, sourceEntity?.sourceEntityRef, siteBaseUrl);
175 const reference = writeJson (projectDir, `data/wp-discovery/entity-read-plans/${ sha ( plan . sourceEntityRef ) }.json` , plan);
176 const extractionPlan = { schemaVersion: 1 , ... reference };
177 if (sourceEntity) {
178 sourceEntity.sourceEntityRef = plan.sourceEntityRef;
179 sourceEntity.bridgeDesignated = true ;
180 sourceEntity.sourceMeta = { ... sourceEntity.sourceMeta, extractionPlan, ... proposedTargets (sourceEntity, plan, extractionPlan) };
181 const schemaPath = path. join (projectDir, 'source-schema.json' );
182 if (fs. existsSync (schemaPath)) {
183 if (fs. lstatSync (schemaPath). isSymbolicLink ()) fail ( 'source schema cannot be a symlink' );
184 const schema = json (schemaPath);
185 if (Array. isArray (schema.entities)) {
186 const index = schema.entities. findIndex (( row ) => row.sourceEntityRef === plan.sourceEntityRef);
187 if (index < 0 ) schema.entities. push (sourceEntity);
188 else schema.entities[index] = { ... schema.entities[index], ... sourceEntity, sourceMeta: { ... schema.entities[index].sourceMeta, ... sourceEntity.sourceMeta } };
189 } else {
190 schema.entities ||= {};
191 const key = Object. keys (schema.entities). find (( name ) => schema.entities[name].sourceEntityRef === plan.sourceEntityRef) || plan.sourceEntityRef;
192 schema.entities[key] = { ... schema.entities[key], ... sourceEntity, sourceMeta: { ... schema.entities[key]?.sourceMeta, ... sourceEntity.sourceMeta } };
193 }
194 atomic (schemaPath, `${ JSON . stringify ( schema , null , 2 ) } \n ` );
195 }
196 }
197 return extractionPlan;
198 }
199 function recordPluginExtractionDeferral ({ projectDir , sourceEntityRef : entity , plan = null , planRef = null , code , reason , recovery : componentRecovery = null , expectedTotal = null , producer = 'rp-source-wordpress/lib/plugin-entity-read-plan.js' , resolution = null , fullAttempt = false }) {
200 const file = entityPaths (projectDir, entity)[ 'deferred.json' ];
201 let previous = null ;
202 try { previous = fs. existsSync (file) ? json (file) : null ; } catch { /* A malformed task is not extraction or target evidence. */ }
203 const at = new Date (). toISOString ();
204 if (resolution) {
205 if (previous) atomic (file, `${ JSON . stringify ({ ... previous , status: 'resolved' , resolvedAt: at , resolution }, null , 2 ) } \n ` );
206 return ;
207 }
208 const actions = {
209 'plan-unavailable' : 'Inspect the entity storage/REST evidence and prepare a validated project-local read plan; identify missing capability support if a plan cannot be produced. An identical discovery rerun is insufficient.' ,
210 'plan-invalid' : 'Repair the named artifact, scope, source/table binding or unsupported relationship in its discovery/codegen producer; validate the plan and refresh mapping/execution review when changed.' ,
211 'full-extraction-unavailable' : 'Run full extraction for the affected entity using its current validated plan; retain and follow any component restart/resume instructions.' ,
212 'full-extraction-stale' : 'Rerun full extraction for the affected entity to reconcile current plan, component and output evidence; retain and follow any component restart/resume instructions.' ,
213 'assembly-failed' : 'Correct the verified identity, key/cardinality or pinned assembler; rerun affected extraction under its recorded restart policy. Do not guess joins or drop records.' ,
214 };
215 const action = actions[code] || componentRecovery?.action || 'Resolve the named component read failure using its retained diagnostic and restart/resume instruction, then rerun the affected extraction.' ;
216 const check = `Current full ${ entity }.manifest.json and component evidence reconcile against the approved plan and NDJSON, then this record is resolved.` ;
217 const record = {
218 schemaVersion: 2 , kind: KIND , status: 'open' , producer, entity, sourceEntityRef: entity,
219 table: plan?.components. length === 1 ? plan.components[ 0 ].table : null ,
220 components: (plan?.components || []). map (({ id , table }) => ({ id, table })), planRef,
221 code, reasons: [reason || code], fullAttempt: fullAttempt === true , firstDeferredAt: previous?.firstDeferredAt || at, lastDeferredAt: at,
222 attempts: (previous?.attempts || 0 ) + 1 ,
223 affectedRecords: { entity, expectedTotal: Number. isInteger (expectedTotal) && expectedTotal >= 0 ? expectedTotal : null },
224 owner: 'Import Operator' , requiresImportOperator: true , action,
225 consequence: 'Affected new writes remain held; existing target identities are retained.' ,
226 prerequisites: [ 'Original migration project and source access' , 'Configured bridge credential; never paste credentials into the task' , 'Verified storage evidence and applicable mapping approval' ],
227 timing: 'Before dependent import/cutover and source retirement' , resolutionCheck: check,
228 resumable: { restartRequired: componentRecovery?.restart === true },
229 copyPromptForImportOperator: `Requires Import Operator: inspect data/source-extract/${ entity }.deferred.json. ${ action } Verify complete extraction, then resume only affected scopes. Done when ${ check }` ,
230 cutoverIntegration: 'pending: shared Cutover integration; retained in completion operatorActions and website handoff' ,
231 };
232 atomic (file, `${ JSON . stringify ( record , null , 2 ) } \n ` );
233 return record;
234 }
235 // Import's full-evidence check with no side effects: throws on any plan, manifest, component,
236 // materialization or output mismatch. Readiness and the mapping preflight share it.
237 async function verifyFullExtraction ( options , plan ) {
238 const { projectDir , sourceEntityRef : entity } = options;
239 const paths = entityPaths (projectDir, entity);
240 const manifest = json (paths[ 'manifest.json' ]);
241 if (manifest.mode !== 'full' || manifest.sourceEntityRef !== entity || ! same (manifest.planRef, options.planRef)
242 || manifest.sourceIdentity !== plan.sourceIdentity || ! same (manifest.assembly, plan.assembly)) fail ( 'current full entity manifest required' );
243 const stats = await require ( './structure-bridge-reader.js' ). fileStats (paths.ndjson);
244 if ( ! stats.exists || stats.digest !== manifest.ndjsonDigest || stats.lines !== manifest.rowsWritten) fail ( 'full output digest/count mismatch' );
245 await verifyComponentEvidence (options, plan, manifest);
246 return { paths, manifest };
247 }
248 // The complete-component half of that check: every plan component read in full, its outputs,
249 // read artifacts, EAV reconciliation and parent-ID materializations unchanged.
250 async function verifyComponentEvidence ( options , plan , manifest ) {
251 const { projectDir , sourceEntityRef : entity } = options;
252 if ( ! Array. isArray (manifest.components) || ! manifest.components. length ) fail ( 'component evidence required' );
253 const seen = new Set ();
254 for ( const component of manifest.components) {
255 if ( ! plan.components. some (( c ) => c.id === component.id)) fail ( 'unknown manifest component' );
256 seen. add (component.id);
257 const prefix = `data/source-extract/${ entity }.${ options . planRef . sha256 }.${ component . id }.` ;
258 if ( ! component.manifest?.path. startsWith (prefix) || ! component.output?.path. startsWith (prefix)) fail ( 'component evidence namespace mismatch' );
259 const componentManifest = json ( checkedFile (projectDir, component.manifest, [ 'data/source-extract' ]));
260 if (componentManifest.kind !== 'eav-read-plan' && componentManifest.rowsWritten !== componentManifest.snapshotTotal) fail ( 'component count unreconciled' );
261 const data = await require ( './structure-bridge-reader.js' ). fileStats ( checkedFile (projectDir, component.output, [ 'data/source-extract' ]));
262 if (data.digest !== componentManifest.ndjsonDigest || data.lines !== componentManifest.rowsWritten) fail ( 'component output mismatch' );
263 for ( const reference of component.readArtifacts) checkedFile (projectDir, reference, [ 'data/wp-discovery' ]);
264 if (componentManifest.kind === 'eav-read-plan' ) {
265 const reference = component.readArtifacts. find (( ref ) => json ( checkedFile (projectDir, ref)).planVersion);
266 if ( ! reference) fail ( 'component EAV evidence missing' );
267 const eavPlan = json ( checkedFile (projectDir, reference));
268 const errors = client. reconcileEavReadPlan (eavPlan, { planHash: componentManifest.planHash, batches: componentManifest.completedBatches, rowsWritten: componentManifest.rowsWritten });
269 if (errors. some (( message ) => ! message. includes ( 'best-effort' ))) fail ( 'component EAV reconciliation failed' );
270 }
271 }
272 for ( const component of plan.components) if ( ! seen. has (component.id)) fail ( 'incomplete component set' );
273 if ( ! Array. isArray (manifest.materializations)) fail ( 'materialization evidence is required' );
274 for ( const reference of manifest.materializations) checkedFile (projectDir, reference, [ 'data/wp-discovery' ]);
275 const root = plan.components. find (( component ) => ! component.scope?.parent);
276 let rootRows;
277 for ( const component of plan.components. filter (( item ) => item.scope?.parent)) {
278 const parentPath = `data/wp-discovery/entity-read-plans/${ sha ( entity ) }/${ options . planRef . sha256 }/${ component . id }/parent-ids.json` ;
279 const parentRef = manifest.materializations. find (( ref ) => ref.path === parentPath);
280 if ( ! parentRef) fail ( 'full parent-ID materialization missing' );
281 rootRows ||= manifest.components. filter (( item ) => item.id === root.id). flatMap (( item ) => readRows ( checkedFile (projectDir, item.output, [ 'data/source-extract' ])));
282 const column = component.scope.parent.parentColumn;
283 const expectedIds = [ ...new Map (rootRows. map (( row ) => [ rowIdentity (row, column), row[column]])). values ()]. sort (( a , b ) => String (a). localeCompare ( String (b)));
284 if ( ! same ( json ( checkedFile (projectDir, parentRef)), expectedIds)) fail ( 'materialized parent IDs differ from complete root' );
285 }
286 }
287 function componentsManifestPath ( projectDir , entity ) {
288 if ( ! ENTITY . test (entity)) fail ( 'invalid canonical plugin entity' );
289 return safeFile (projectDir, `data/source-extract/${ entity }.components.json` );
290 }
291 // A project-code assembly plan's components, read in full before approval without its module.
292 async function verifyComponentRead ( options , plan ) {
293 const { projectDir , sourceEntityRef : entity } = options;
294 const record = json ( componentsManifestPath (projectDir, entity));
295 if (record.mode !== 'components' || record.sourceEntityRef !== entity || ! same (record.planRef, options.planRef)
296 || record.sourceIdentity !== plan.sourceIdentity || ! same (record.assembly, plan.assembly)) fail ( 'current component read required' );
297 await verifyComponentEvidence (options, plan, record);
298 return record;
299 }
300 async function validatePluginExtractionReadiness ( options ) {
301 const { projectDir , sourceEntityRef : entity , phase = 'import' } = options;
302 let plan;
303 let checkingExtraction = false ;
304 try {
305 assertCanonicalBinding (options);
306 plan = loadPlan (options);
307 if (phase === 'preparation' ) return { ready: true , plan };
308 if (phase !== 'import' ) fail ( 'unknown readiness phase' );
309 checkingExtraction = true ;
310 const { paths , manifest } = await verifyFullExtraction (options, plan);
311 recordPluginExtractionDeferral ({ ... options, resolution: { status: 'verified-full' , manifestPath: rel (projectDir, paths[ 'manifest.json' ]), rowsWritten: manifest.rowsWritten, planRef: options.planRef } });
312 return { ready: true , plan, manifest };
313 } catch (error) {
314 // Readiness observes extraction failure; it must not rewrite the original repair
315 // task or erase a component's restart policy when that task belongs to this plan.
316 if (checkingExtraction) {
317 let previous;
318 try { previous = json ( entityPaths (projectDir, entity)[ 'deferred.json' ]); } catch { /* No usable prior task. */ }
319 if (previous?.status === 'open' && previous.sourceEntityRef === entity && same (previous.planRef, options.planRef)) {
320 return { ready: false , code: previous.code, reasons: previous.reasons, resumable: previous.resumable };
321 }
322 }
323 const code = checkingExtraction ? (error.code === 'ENOENT' ? 'full-extraction-unavailable' : 'full-extraction-stale' )
324 : error.code === 'plan-unavailable' ? error.code : 'plan-invalid' ;
325 let reason = error.message;
326 for ( const root of new Set ([path. resolve (projectDir), fs. realpathSync (projectDir)])) reason = reason. split ( `${ root }${ path . sep }` ). join ( '' );
327 // A readiness observation never overwrites an open task recorded by a real full attempt for
328 // this plan: that task is the checkpoint's evidence that the read was tried and failed.
329 if ( ! options.fullAttempt) {
330 let previous;
331 try { previous = json ( entityPaths (projectDir, entity)[ 'deferred.json' ]); } catch { /* No usable prior task. */ }
332 if (previous?.status === 'open' && previous.fullAttempt === true && previous.sourceEntityRef === entity && same (previous.planRef, options.planRef)) {
333 return { ready: false , code, reasons: [reason] };
334 }
335 }
336 recordPluginExtractionDeferral ({ ... options, plan, code, reason });
337 return { ready: false , code, reasons: [reason] };
338 }
339 }
340 function readRows ( file ) { return fs. readFileSync (file, 'utf8' ). split ( ' \n ' ). filter (Boolean). map (( line ) => JSON . parse (line)); }
341 function rowIdentity ( row , column ) {
342 const value = row[column];
343 if ( ! [ 'string' , 'number' ]. includes ( typeof value) || value === '' ) fail ( `missing source identity ${ column }` , 'assembly-failed' );
344 return `${ typeof value }:${ value }` ;
345 }
346 function pivot ( rows , config ) {
347 const groups = new Map ();
348 for ( const row of rows) {
349 const identity = rowIdentity (row, config.identityColumn);
350 if ( ! Object. hasOwn (config.keys || {}, row[config.keyColumn])) fail ( 'unexpected metadata key' , 'assembly-failed' );
351 const key = row[config.keyColumn];
352 const cardinality = config.keys[key];
353 if ( ! [ 'scalar' , 'array' ]. includes (cardinality)) fail ( 'metadata cardinality required' , 'assembly-failed' );
354 const group = groups. get (identity) || { [config.identityColumn]: row[config.identityColumn], meta: Object. create ( null ) };
355 if (cardinality === 'array' ) (group.meta[key] ||= []). push (row[config.valueColumn]);
356 else {
357 if (Object. hasOwn (group.meta, key)) fail ( `duplicate scalar metadata key ${ key }` , 'assembly-failed' );
358 group.meta[key] = row[config.valueColumn];
359 }
360 groups. set (identity, group);
361 }
362 return groups;
363 }
364 function assemble ( plan , streams , projectDir ) {
365 const config = plan.assembly;
366 const root = plan.components. find (( c ) => ! c.scope?.parent);
367 let rows = streams[root.id];
368 if (config.kind === 'module' ) {
369 const file = checkedFile (projectDir, config.module, [ 'src/extract' , 'src/lib' ]);
370 delete require.cache[require. resolve (file)];
371 rows = require (file)[config.export]({ components: streams });
372 if ( ! Array. isArray (rows)) fail ( 'assembly module must return records' , 'assembly-failed' );
373 } else if (config.kind === 'eav-pivot-v1' ) {
374 if (config.keyColumn) rows = [ ... pivot (rows, config). values ()];
375 else {
376 const rootIds = new Set (rows. map (( row ) => rowIdentity (row, config.identityColumn)));
377 const maps = (config.children || []). map (( child ) => {
378 if ( ! streams[child.component]) fail ( 'unknown pivot child' , 'assembly-failed' );
379 const groups = pivot (streams[child.component], { ... child, identityColumn: child.foreignKey });
380 for ( const key of groups. keys ()) if ( ! rootIds. has (key)) fail ( 'orphan required relationship' , 'assembly-failed' );
381 return { child, groups };
382 });
383 rows = rows. map (( row ) => {
384 const value = { ... row };
385 for ( const { child , groups } of maps) {
386 const group = groups. get ( rowIdentity (row, config.identityColumn));
387 if ( ! group && child.required) fail ( 'missing required child' , 'assembly-failed' );
388 Object. defineProperty (value, child.outputField || child.component, { value: group?.meta || {}, enumerable: true , configurable: true , writable: true });
389 }
390 return value;
391 });
392 }
393 }
394 const identities = new Set ();
395 for ( const row of rows) {
396 const identity = rowIdentity (row, config.identityColumn);
397 if (identities. has (identity)) fail ( 'duplicate logical identity' , 'assembly-failed' );
398 identities. add (identity);
399 }
400 return rows;
401 }
402 async function extractBoundPluginEntity ( input ) {
403 // Every deferral recorded from here, including a preparation failure, is a real full attempt.
404 const options = { ... input, fullAttempt: true };
405 const { projectDir , sourceEntityRef : entity , transport , planRef , sample = false , sampleRecords = null , restart = false , assemble : runAssembly = true } = options;
406 if (sample) return { status: 'preview' , rows: sampleRecords, unavailable: ! sampleRecords }; // never promotes
407 const ready = await validatePluginExtractionReadiness ({ ... options, siteBaseUrl: transport?.siteBaseUrl, phase: 'preparation' });
408 if ( ! ready.ready) return { status: 'deferred' , ... ready };
409 const { plan } = ready;
410 const streams = Object. create ( null );
411 const components = [];
412 const materializations = [];
413 const reader = require ( './structure-bridge-reader.js' );
414 let activeComponent = null ;
415 try {
416 const root = plan.components. find (( c ) => ! c.scope?.parent);
417 for ( const component of [root, ... plan.components. filter (( c ) => c !== root)]) {
418 activeComponent = component.id;
419 streams[component.id] = [];
420 const parent = component.scope?.parent;
421 let chunks = [ null ];
422 let parentRef = null ;
423 const namespace = `data/wp-discovery/entity-read-plans/${ sha ( entity ) }/${ planRef . sha256 }/${ component . id }` ;
424 if (parent) {
425 const ids = [ ...new Map (streams[root.id]. map (( row ) => [ rowIdentity (row, parent.parentColumn), row[parent.parentColumn]])). values ()]. sort (( a , b ) => String (a). localeCompare ( String (b)));
426 parentRef = writeJson (projectDir, `${ namespace }/parent-ids.json` , ids);
427 materializations. push (parentRef);
428 chunks = [];
429 for ( let i = 0 ; i < ids. length ; i += client. MAX_IN_VALUES ) chunks. push (ids. slice (i, i + client. MAX_IN_VALUES ));
430 }
431 // Empty root still has explicit completed child evidence, without an unrestricted query.
432 if ( ! chunks. length ) {
433 const emptyData = `data/source-extract/${ entity }.${ planRef . sha256 }.${ component . id }.empty.ndjson` ;
434 atomic ( safeFile (projectDir, emptyData), '' );
435 const manifest = writeJson (projectDir, `${ emptyData }.manifest.json` , { rowsWritten: 0 , snapshotTotal: 0 , ndjsonDigest: sha ( '' ) });
436 components. push ({ id: component.id, manifest, output: referenceFor (projectDir, emptyData), readArtifacts: [] });
437 }
438 for ( const [ index , ids ] of chunks. entries ()) {
439 let artifacts = component.readArtifacts;
440 if (ids) {
441 const where = [ ... (component.scope?.where || []), { column: parent.column, op: 'in' , value: ids }];
442 client. validateEavScope (where, component.variant === 'eav' ? 1 : 0 );
443 const base = `${ namespace }/${ parentRef . sha256 }/${ index }` ;
444 const savedPath = `${ base }/${ component . variant }.json` ;
445 const absolute = safeFile (projectDir, savedPath);
446 if ( ! fs. existsSync (absolute) || restart) {
447 let request;
448 if (component.variant === 'eav' ) {
449 const pairs = [component.schema.eavPair, ... (component.schema.eavPair.additionalPairs || [])];
450 const keysByPair = {}; const countsByPair = {}; let truncated = false ;
451 for ( const pair of pairs) {
452 const keys = await client. discoverEavKeys ({ ... transport, table: component.table, keyColumn: pair.keyColumn, scope: where });
453 keysByPair[pair.keyColumn] = keys.keys; countsByPair[pair.keyColumn] = keys.keyCounts;
454 truncated ||= keys.truncated;
455 }
456 request = client. buildEavReadPlan ({ table: component.table, eavPair: component.schema.eavPair, schemaColumns: component.schema.columns, keysByPair, countsByPair, scope: where, truncated });
457 } else request = { ... json ( checkedFile (projectDir, component.readArtifacts.structureRequest)), where };
458 writeJson (projectDir, savedPath, request);
459 }
460 artifacts = { [component.variant === 'eav' ? 'eavReadPlan' : 'structureRequest' ]: referenceFor (projectDir, savedPath) };
461 materializations. push ( ... Object. values (artifacts));
462 }
463 const componentEntity = `${ entity }.${ planRef . sha256 }.${ component . id }.${ parentRef ?. sha256 || 'root'}.${ index }` ;
464 const result = await reader. extractStructureBridgeEntity ({ dataDir: path. join (projectDir, 'data' ), entity: componentEntity, table: component.table, transport, expectEav: component.variant === 'eav' , readArtifacts: artifacts, restart, recordDeferred: false });
465 if (result.status === 'deferred' ) {
466 const error = Object. assign ( new Error ( `${ component . id }: ${ result . code }; ${ result . reasons . join ( '; ' ) }; retained component state data/source-extract/${ componentEntity }.ndjson.tmp.cursor.json` ), { code: result.code, recovery: result.recovery });
467 throw error;
468 }
469 const manifest = referenceFor (projectDir, rel (projectDir, result.manifestPath));
470 const output = referenceFor (projectDir, rel (projectDir, result.finalPath));
471 components. push ({ id: component.id, manifest, output, readArtifacts: Object. values (artifacts) });
472 for ( const row of readRows (result.finalPath)) streams[component.id]. push (row);
473 }
474 }
475 activeComponent = null ;
476 if ( ! runAssembly) {
477 // Complete source evidence without running the plan's assembly (project code).
478 assertCanonicalBinding ({ ... options, siteBaseUrl: transport.siteBaseUrl });
479 loadPlan ({ ... options, siteBaseUrl: transport.siteBaseUrl });
480 materializations. forEach (( ref ) => checkedFile (projectDir, ref));
481 components. forEach (( entry ) => { checkedFile (projectDir, entry.manifest); checkedFile (projectDir, entry.output); });
482 const record = { schemaVersion: 1 , mode: 'components' , sourceEntityRef: entity, sourceIdentity: plan.sourceIdentity, planRef, components, materializations, assembly: plan.assembly,
483 rowsRead: Object. fromEntries (Object. entries (streams). map (([ id , rows ]) => [id, rows. length ])) };
484 atomic ( componentsManifestPath (projectDir, entity), `${ JSON . stringify ( record , null , 2 ) } \n ` );
485 recordPluginExtractionDeferral ({ ... options, resolution: { status: 'components-read' , manifestPath: `data/source-extract/${ entity }.components.json` , planRef } });
486 return { status: 'components-read' , rowsRead: record.rowsRead, manifestPath: componentsManifestPath (projectDir, entity) };
487 }
488 const rows = assemble (plan, streams, projectDir);
489 assertCanonicalBinding ({ ... options, siteBaseUrl: transport.siteBaseUrl });
490 loadPlan ({ ... options, siteBaseUrl: transport.siteBaseUrl }); // re-hash all governing inputs before promotion
491 materializations. forEach (( ref ) => checkedFile (projectDir, ref));
492 components. forEach (( entry ) => { checkedFile (projectDir, entry.manifest); checkedFile (projectDir, entry.output); });
493 const paths = entityPaths (projectDir, entity);
494 const bytes = rows. map (( row ) => `${ JSON . stringify ( row ) } \n ` ). join ( '' );
495 atomic (paths.ndjson, bytes);
496 const manifest = { schemaVersion: 1 , mode: 'full' , sourceEntityRef: entity, sourceIdentity: plan.sourceIdentity, planRef, components, materializations, assembly: plan.assembly, rowsWritten: rows. length , ndjsonDigest: sha (bytes) };
497 atomic (paths[ 'manifest.json' ], `${ JSON . stringify ( manifest , null , 2 ) } \n ` );
498 recordPluginExtractionDeferral ({ ... options, resolution: { manifestPath: rel (projectDir, paths[ 'manifest.json' ]), rowsWritten: rows. length , planRef } });
499 return { status: 'promoted' , rowsWritten: rows. length , manifestPath: paths[ 'manifest.json' ], finalPath: paths.ndjson };
500 } catch (error) {
501 const code = error.code || 'assembly-failed' ;
502 recordPluginExtractionDeferral ({ ... options, plan, code, recovery: error.recovery, reason: `${ activeComponent || 'assembly'}: ${ error . message }` });
503 return { status: 'deferred' , code, reasons: [error.message] };
504 }
505 }
506 // Every canonical plugin entity that currently has a read plan, with that plan's binding.
507 function plannedPluginEntities ( projectDir ) {
508 const schemaPath = path. join (projectDir, 'source-schema.json' );
509 if ( ! fs. existsSync (schemaPath)) return [];
510 const schema = json (schemaPath);
511 const rows = Array. isArray (schema.entities) ? schema.entities : Object. entries (schema.entities || {}). map (([ key , row ]) => ({ sourceEntityRef: row.sourceEntityRef || key, ... row }));
512 return rows. filter (( row ) => ENTITY . test (row.sourceEntityRef || '' ) && row.sourceMeta?.extractionPlan)
513 . map (( row ) => ({ sourceEntityRef: row.sourceEntityRef, planRef: row.sourceMeta.extractionPlan }))
514 . sort (( a , b ) => a.sourceEntityRef. localeCompare (b.sourceEntityRef));
515 }
516 // The source URL every check is bound to: the caller's transport when given, else the canonical
517 // source-schema URL. Both the schema and the plan must match it.
518 function boundSource ( projectDir , siteBaseUrl ) {
519 if (siteBaseUrl) return siteBaseUrl;
520 const schemaPath = path. join (projectDir, 'source-schema.json' );
521 const schema = fs. existsSync (schemaPath) ? json (schemaPath) : {};
522 return schema.sourceUrl || schema.source?.url || null ;
523 }
524 // What a full read left for this plan, read-only (the mapping preflight must not write).
525 // `complete`: import's own evidence check passes. `components-complete`: a project-code assembly
526 // plan whose every component was read in full (assembly runs only after approval). `deferred`: an
527 // open task recorded by an actual full attempt for this plan (readiness observations are not
528 // attempts). `stale`: full evidence exists but no longer verifies. Otherwise `not-attempted`.
529 async function pluginFullReadState ( projectDir , entity , planRef , siteBaseUrl = null ) {
530 const options = { projectDir, sourceEntityRef: entity, planRef, siteBaseUrl: boundSource (projectDir, siteBaseUrl) };
531 const paths = entityPaths (projectDir, entity);
532 let plan = null ;
533 try { assertCanonicalBinding (options); plan = loadPlan (options); } catch { /* Not a current plan for this source; classify below. */ }
534 if (plan) {
535 try { const { manifest } = await verifyFullExtraction (options, plan); return { state: 'complete' , rowsWritten: manifest.rowsWritten }; } catch { /* Not current full evidence. */ }
536 if (plan.assembly.kind === 'module' ) {
537 try { const record = await verifyComponentRead (options, plan); return { state: 'components-complete' , rowsRead: record.rowsRead }; } catch { /* Not current component evidence. */ }
538 }
539 }
540 let deferred = null ;
541 try { deferred = json (paths[ 'deferred.json' ]); } catch { /* No usable task. */ }
542 if (deferred?.status === 'open' && deferred.fullAttempt === true && same (deferred.planRef, planRef)) return { state: 'deferred' , code: deferred.code, action: deferred.action };
543 return { state: fs. existsSync (paths[ 'manifest.json' ]) || fs. existsSync ( componentsManifestPath (projectDir, entity)) ? 'stale' : 'not-attempted' };
544 }
545 // Before the mapping checkpoint: read every planned plugin entity completely, whatever its size,
546 // so the mapper maps from complete data. A current read for the same source is reused, never
547 // repeated; import later accepts the same evidence. A failed read keeps its typed deferral. A plan
548 // that assembles records with project code has every component read in full, but its module runs
549 // only in the approved extraction path.
550 async function readPluginEntitiesForMapping ({ projectDir , transport , entities = null , restart = false }) {
551 const results = [];
552 for ( const { sourceEntityRef : entity , planRef } of entities || plannedPluginEntities (projectDir)) {
553 const options = { projectDir, sourceEntityRef: entity, planRef, siteBaseUrl: transport.siteBaseUrl };
554 const { state } = await pluginFullReadState (projectDir, entity, planRef, transport.siteBaseUrl);
555 if ( ! restart && [ 'complete' , 'components-complete' ]. includes (state)) {
556 results. push ({ sourceEntityRef: entity, status: 'current' });
557 continue ;
558 }
559 let plan = null ;
560 try { plan = loadPlan (options); } catch { /* extractBoundPluginEntity records the typed plan failure. */ }
561 const result = await extractBoundPluginEntity ({ ... options, transport, restart, assemble: plan?.assembly.kind !== 'module' });
562 results. push ({ sourceEntityRef: entity, status: result.status, ... (result.code ? { code: result.code, reasons: result.reasons } : {}),
563 ... (Number. isInteger (result.rowsWritten) ? { rowsWritten: result.rowsWritten } : {}), ... (result.rowsRead ? { rowsRead: result.rowsRead } : {}) });
564 }
565 return results;
566 }
567 // Preserve the adapter's existing target dependency order; enforce transitive source
568 // prerequisites within that order as well. Validate the whole graph before any handler.
569 function dependencyOrderedEntities ( entities , entityOrder ) {
570 const explicitOrder = entityOrder !== undefined ;
571 entityOrder ??= entities. map (( entity ) => entity.sourceEntityRef);
572 const byRef = new Map (entities. map (( entity ) => [entity.sourceEntityRef, entity]));
573 if ( ! Array. isArray (entityOrder) || new Set (entityOrder).size !== entityOrder. length
574 || entityOrder. some (( ref ) => ! byRef. has (ref)) || entities. some (( entity ) => entity.importPlanned && ! entityOrder. includes (entity.sourceEntityRef))) fail ( 'adapter entityOrder must include every planned entity exactly once' );
575 const ordered = []; const visited = new Set (); const active = new Set ();
576 const visit = ( ref ) => {
577 if (active. has (ref)) fail ( 'cyclic source prerequisites' );
578 const entity = byRef. get (ref);
579 if ( ! entity) fail ( 'missing source prerequisite' );
580 if (visited. has (ref)) return ;
581 active. add (ref);
582 for ( const prerequisite of entity.sourcePrerequisites || []) visit (prerequisite);
583 active. delete (ref); visited. add (ref); ordered. push (entity);
584 };
585 for ( const ref of [ ... entityOrder, ... byRef. keys ()]) visit (ref);
586 if (explicitOrder && ! same (ordered. filter (( entity ) => entityOrder. includes (entity.sourceEntityRef)). map (( entity ) => entity.sourceEntityRef), entityOrder)) fail ( 'adapter dependency order conflicts with source prerequisites; repair codegen ordering before import' );
587 return ordered;
588 }
589 // `results` collects each entity's outcome as it lands, so a stopped run still reports the
590 // entities it finished; `stopRequested` ends dispatch before the next entity.
591 async function dispatchWithPluginExtractionReadiness ({ entities , selection , projectDir , siteBaseUrl , entityOrder , results = [], stopRequested = () => false }, handlers ) {
592 const checks = new Map ();
593 const selected = selectPluginEntities ( dependencyOrderedEntities (entities, entityOrder), selection). filter (( entity ) => entity.dispatchSelected !== false );
594 for ( const entity of selected) {
595 if ( stopRequested ()) break ;
596 const required = [];
597 const visit = ( source , stack = new Set ()) => {
598 if ( ! source || stack. has (source.sourceEntityRef)) fail ( 'missing/cyclic source prerequisite' );
599 if (required. includes (source)) return ;
600 required. push (source);
601 for ( const ref of source.sourcePrerequisites || []) visit (entities. find (( row ) => row.sourceEntityRef === ref), new Set ([ ... stack, source.sourceEntityRef]));
602 };
603 visit (entity);
604 let blocked = false ;
605 for ( const source of required) {
606 if ( ! source.bridgeDesignated && ! source.sourceMeta?.extractionPlan) continue ;
607 const options = { projectDir, sourceEntityRef: source.sourceEntityRef, planRef: source.sourceMeta?.extractionPlan, siteBaseUrl };
608 let fingerprint;
609 try { fingerprint = readinessFingerprint (options); } catch { fingerprint = 'invalid' ; }
610 if ( ! checks. has (source.sourceEntityRef) || checks. get (source.sourceEntityRef).fingerprint !== fingerprint) {
611 checks. set (source.sourceEntityRef, { fingerprint, result: await validatePluginExtractionReadiness (options) });
612 }
613 if ( ! checks. get (source.sourceEntityRef).result.ready) blocked = true ;
614 }
615 if (blocked) results. push ({ sourceEntityRef: entity.sourceEntityRef, status: 'deferred' });
616 else {
617 if ( typeof handlers[entity.sourceEntityRef] !== 'function' ) fail ( 'selected import handler missing' );
618 results. push ( await handlers[entity.sourceEntityRef](entity, selection));
619 }
620 }
621 return results;
622 }
623 function selectPluginEntities ( entities , selection = {}) {
624 // Record status filters are deliberately left to the existing per-record selectors AFTER
625 // source readiness. An entity without extracted rows cannot disappear from this inventory.
626 const filters = recovery (). normalizeSelectionFilters (selection);
627 return entities. filter (( entity ) => ( ! filters.entity || filters.entity === entity.sourceEntityRef || filters.entity === entity.sourceEntity)
628 && ( ! filters.sourceType || ( ! entity.sourceType && ! entity.sourceTypes?. length )
629 || filters.sourceType === entity.sourceType || entity.sourceTypes?. includes (filters.sourceType)));
630 }
631 function selectExtractionEntities ( entities , selection ) {
632 const byRef = new Map (entities. map (( entity ) => [entity.sourceEntityRef, entity]));
633 const approved = new Map ();
634 const visit = ( entity , into , stack = new Set ()) => {
635 if ( ! entity || stack. has (entity.sourceEntityRef)) fail ( 'missing/cyclic extraction prerequisite' );
636 if (into. has (entity.sourceEntityRef)) return ;
637 into. set (entity.sourceEntityRef, entity);
638 for ( const ref of entity.sourcePrerequisites || []) visit (byRef. get (ref), into, new Set ([ ... stack, entity.sourceEntityRef]));
639 };
640 entities. filter (( entity ) => entity.importPlanned). forEach (( entity ) => visit (entity, approved));
641 const selected = new Map ();
642 selectPluginEntities ([ ... approved. values ()], selection). forEach (( entity ) => visit (entity, selected));
643 return [ ... selected. values ()];
644 }
645 function planFreshnessReferences ({ projectDir , entities }) {
646 const refs = new Map ();
647 for ( const entity of entities) {
648 const reference = entity.sourceMeta?.extractionPlan;
649 if ( ! reference) continue ;
650 safeFile (projectDir, reference.path, [ 'data/wp-discovery' ]);
651 refs. set (reference.path, reference);
652 let plan;
653 try { plan = loadPlan ({ projectDir, sourceEntityRef: entity.sourceEntityRef, planRef: reference }); }
654 catch { continue ; } // Invalid preparation is held by per-entity readiness, not a global crash.
655 const all = [reference, ... plan.evidenceRefs, ... plan.components. flatMap (( component ) => Object. values (component.readArtifacts)), ... (plan.assembly.module ? [plan.assembly.module] : [])];
656 for ( const ref of all) refs. set (ref.path, ref);
657 }
658 return [ ... refs. values ()]. sort (( a , b ) => a.path. localeCompare (b.path));
659 }
660 function readinessFingerprint ({ projectDir , sourceEntityRef : entity , planRef }) {
661 const files = new Set ([path. join (projectDir, 'source-schema.json' )]);
662 const add = ( ref ) => { if (ref?.path) files. add ( safeFile (projectDir, ref.path)); };
663 const paths = entityPaths (projectDir, entity);
664 files. add (paths.ndjson); files. add (paths[ 'manifest.json' ]); add (planRef);
665 try {
666 const plan = json ( checkedFile (projectDir, planRef));
667 for ( const reference of [ ... plan.evidenceRefs, ... plan.components. flatMap (( component ) => Object. values (component.readArtifacts)), ... (plan.assembly.module ? [plan.assembly.module] : [])]) add (reference);
668 const manifest = json (paths[ 'manifest.json' ]);
669 for ( const component of manifest.components) { add (component.manifest); add (component.output); component.readArtifacts. forEach (add); }
670 manifest.materializations. forEach (add);
671 } catch { /* Missing/invalid files force the full validator; they never become evidence. */ }
672 return sha ( JSON . stringify ([ ... files]. sort (). map (( file ) => {
673 const stat = fs. statSync (file, { throwIfNoEntry: false });
674 return [file, stat?.size, stat?.mtimeMs, stat?.ctimeMs, stat?.ino];
675 })));
676 }
677 function recovery () {
678 return require (fs. existsSync (path. join (__dirname, 'import-recovery.js' )) ? './import-recovery.js' : '../../../lib/import-recovery.js' );
679 }
680 function interruptionLib () {
681 return require (fs. existsSync (path. join (__dirname, 'import-interruption.js' )) ? './import-interruption.js' : '../../../lib/import-interruption.js' );
682 }
683 function wixAuthLib () {
684 return require (fs. existsSync (path. join (__dirname, 'wix-auth.js' )) ? './wix-auth.js' : '../../../lib/wix-auth.js' );
685 }
686 function configEnv () {
687 return require (fs. existsSync (path. join (__dirname, 'config-env.js' )) ? './config-env.js' : '../../../lib/config-env.js' );
688 }
689 function fieldContract () {
690 return require (fs. existsSync (path. join (__dirname, 'mapping-field-contract.js' )) ? './mapping-field-contract.js' : '../../../lib/mapping-field-contract.js' );
691 }
692 function mappingPlanOf ( projectDir ) {
693 const file = path. join (projectDir, 'mapping/mapping-plan.json' );
694 return fs. existsSync (file) ? json (file) : null ;
695 }
696 function parseSelectionArgs ( args ) {
697 const raw = {};
698 for ( let index = 0 ; index < args. length ; index += 1 ) {
699 const key = args[index];
700 if ([ '--entity' , '--source-type' ]. includes (key)) {
701 const value = args[ ++ index];
702 if ( ! value || value. startsWith ( '--' )) fail ( `value required for ${ key }` );
703 raw[key === '--entity' ? 'entity' : 'sourceType' ] = value;
704 } else if ([ '--missing-only' , '--failed-only' , '--deferred-only' , '--unresolved-only' ]. includes (key)) raw[key. slice ( 2 )] = true ;
705 else if ([ '--sample' , '--dry-run' , '--rerun' , '--describe' ]. includes (key)) raw[key. slice ( 2 )] = true ;
706 else fail ( `unsupported selection argument ${ key }` );
707 }
708 return { ... raw, ... recovery (). normalizeSelectionFilters (raw) };
709 }
710 function entryDescriptor ( runtime , entities , mappingPlan , projectDir ) {
711 if ( typeof runtime.describeImport !== 'function' ) fail ( 'project adapter must describe its actual importer contract' );
712 const descriptor = runtime. describeImport ();
713 recovery (). assertGeneratedImporterContract (descriptor, { mappingPlan, projectDir });
714 const readiness = require (fs. existsSync (path. join (__dirname, 'import-readiness-contract.js' )) ? './import-readiness-contract.js' : '../../../lib/import-readiness-contract.js' ). verifyReadinessContract (projectDir, descriptor);
715 if ( ! readiness.ok) fail (readiness.errors. join ( ';' ));
716 if ( ! Array. isArray (descriptor.entityOrder)) fail ( 'project adapter must declare its existing dependency order in entityOrder' );
717 dependencyOrderedEntities (entities, descriptor.entityOrder);
718 return descriptor;
719 }
720 async function runGeneratedPluginEntrypoint ({ kind , projectDir , entities }, args ) {
721 // A wrapper cannot vouch for a project's ledger or selectors. Keep the existing
722 // importer self-check against the adapter's real configuration before any I/O hooks.
723 const adapter = () => require (path. join (projectDir, 'src/project/plugin-runtime.js' ));
724 const mappingPlan = kind === 'import' ? mappingPlanOf (projectDir) : null ;
725 const descriptor = kind === 'import' ? entryDescriptor ( adapter (), entities, mappingPlan, projectDir) : null ;
726 // An exact field contract decides write scope: a held-back entity is never dispatched, and
727 // every write re-checks the shared scope guard against the importer's pinned digest.
728 const exactScope = mappingPlan?.fieldContractVersion === 1 ;
729 const inScope = ( entity ) => ! exactScope || (mappingPlan.executionScope?.resolvedEntityRefs || []). includes (entity.sourceEntityRef);
730 // The declared migration-review probe belongs to the adapter that owns real evidence
731 // capture; the wrapper only routes it, after the same contract self-check.
732 if (descriptor && args. includes ( '--migration-review-probe' )) {
733 const runtime = adapter ();
734 if ( typeof runtime.migrationReviewProbe !== 'function' ) fail ( 'project adapter must implement its declared migration review probe' );
735 const scratch = args[args. indexOf ( '--project' ) + 1 ];
736 if ( ! args. includes ( '--project' ) || ! scratch || scratch. startsWith ( '--' )) fail ( 'migration review probe requires --project <scratch>' );
737 await runtime. migrationReviewProbe ({ probeProjectDir: path. resolve (scratch), args }); return ;
738 }
739 const runId = crypto. randomUUID ();
740 const selection = parseSelectionArgs (args);
741 if (selection.describe) {
742 if ( ! descriptor) fail ( 'describe is supported only by the import entrypoint' );
743 process.stdout. write ( `${ JSON . stringify ( descriptor ) } \n ` ); return ;
744 }
745 const schema = json (path. join (projectDir, 'source-schema.json' ));
746 const siteBaseUrl = sourceIdentity (schema.sourceUrl || schema.source?.url);
747 // Loading the adapter/its pure descriptor must have no side effects. Target hooks
748 // remain behind readiness; the adapter retains ledger/idempotency/record selectors.
749 let results;
750 if (selection.sample) {
751 results = selectPluginEntities (entities, selection). map (( entity ) => ({ status: 'preview' , sourceEntityRef: entity.sourceEntityRef, rows: adapter (). preview ?.(entity) || null }));
752 } else if (kind === 'import' ) {
753 const approvalsPath = path. join (projectDir, 'orchestration/approvals.json' );
754 const approval = fs. existsSync (approvalsPath) ? json (approvalsPath) : {};
755 if ( ! [ 'approved' , 'provisional' ]. includes (approval.mapping?.status)) fail ( 'existing mapping review approval is required before import' );
756 const runtime = adapter ();
757 if ( typeof runtime.preflight !== 'function' || typeof runtime.finalizeImport !== 'function' || typeof runtime.importEntity !== 'function' ) fail ( 'generated adapter must retain existing preflight, import and finalization' );
758 const selectedEntities = selectPluginEntities (entities. filter (( entity ) => entity.importPlanned && inScope (entity)), selection);
759 const dryRun = await configEnv (). resolveDryRun (projectDir, { argv: args });
760 let preflight;
761 const handlers = Object. fromEntries (entities. map (( entity ) => [entity.sourceEntityRef,
762 async ( row , filters ) => {
763 // Run-scoped authorization/mute checks happen once, before the first ready write.
764 preflight ||= Promise . resolve (). then (() => runtime. preflight ({ entities: selectedEntities, selection: filters, projectDir, runId }));
765 await preflight;
766 if (exactScope) fieldContract (). assertExecutionScope (mappingPlan, row.sourceEntityRef, descriptor.mappingPlanDigest, projectDir);
767 return runtime. importEntity ({ entity: row, selection: filters, projectDir, runId, dryRun });
768 }]));
769 // Keep the complete source inventory for prerequisite lookup; select only mapped target
770 // scopes for dispatch. Discovery alone never grants permission to write that entity.
771 const plannedEntities = entities. filter (( entity ) => entity.importPlanned && inScope (entity));
772 const selectedRefs = new Set ( selectPluginEntities (plannedEntities, selection). map (( entity ) => entity.sourceEntityRef));
773 const dispatchEntities = entities. map (( entity ) => ({ ... entity, dispatchSelected: selectedRefs. has (entity.sourceEntityRef) }));
774 // One completion call on every end: a stop or a throw still reaches finalize,
775 // with an `interruption`, and the report is stamped or rebuilt if finalize cannot record it.
776 const reader = require ( './structure-bridge-reader.js' );
777 const stops = interruptionLib ();
778 const collected = [];
779 const finalize = async ( interruption ) => {
780 const deferredExtraction = reader. deferredExtractionReportInput ( await reader. collectDeferredExtractions (path. join (projectDir, 'data' )));
781 await runtime. finalizeImport ({ results: collected, selection, projectDir, runId, deferredExtraction, ... (interruption ? { interruption } : {}) });
782 };
783 try {
784 // RP_IMPORT_STOP_GRACE_MS shortens or lengthens how long a stop waits for in-flight writes.
785 const grace = Number. parseInt (process.env. RP_IMPORT_STOP_GRACE_MS || '' , 10 );
786 results = await stops. runImportWithFinalize ({
787 projectDir,
788 ... (Number. isInteger (grace) && grace >= 0 ? { graceMs: grace } : {}),
789 dispatch : ({ stopRequested }) => dispatchWithPluginExtractionReadiness ({ entities: dispatchEntities, selection, projectDir, siteBaseUrl, entityOrder: descriptor.entityOrder, results: collected, stopRequested }, handlers),
790 finalize,
791 onInterrupted : ( interruption ) => (dryRun ? undefined : stops. recordInterruption (projectDir, { runId, interruption, mappingPlanDigest: descriptor.mappingPlanDigest || null })),
792 });
793 } catch (error) {
794 error.exitCode = await wixAuthLib (). stopExitCode (error, projectDir);
795 throw error;
796 }
797 } else {
798 results = [];
799 for ( const entity of selectExtractionEntities (entities, selection)) {
800 if ( ! entity.bridgeDesignated) { results. push ( await adapter (). extractNative ({ entity, selection, projectDir, runId })); continue ; }
801 const preparation = await validatePluginExtractionReadiness ({ projectDir, sourceEntityRef: entity.sourceEntityRef, planRef: entity.sourceMeta?.extractionPlan, siteBaseUrl, phase: 'preparation' });
802 if ( ! preparation.ready) { results. push ({ status: 'deferred' , ... preparation }); continue ; }
803 const transport = await adapter (). sourceTransport ({ entity, projectDir, siteBaseUrl, runId });
804 results. push ( await extractBoundPluginEntity ({ projectDir, sourceEntityRef: entity.sourceEntityRef, planRef: entity.sourceMeta.extractionPlan, transport }));
805 }
806 try { await adapter (). afterExtraction ?.({ results, selection, projectDir, runId }); }
807 catch { process.stderr. write ( 'Source-read telemetry could not be filed; extraction evidence is retained. \n ' ); }
808 }
809 process.stdout. write ( `${ JSON . stringify ({ runId , results }) } \n ` );
810 return results;
811 }
812 module . exports = { plannedPluginEntities, pluginFullReadState, readPluginEntitiesForMapping, sourceIdentity, sourceEntityRef, sha, safeFile, checkedFile, referenceFor, writeJson, writePluginEntityReadPlan, invalidatePluginEntityReadPlan, loadPlan, validatePlan, recordPluginExtractionDeferral, validatePluginExtractionReadiness, extractBoundPluginEntity, dispatchWithPluginExtractionReadiness, selectPluginEntities, planFreshnessReferences, assemble, parseSelectionArgs, runGeneratedPluginEntrypoint };