Setting the file. One moment.
Structure Bridge Reader · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page 128
function isPlainObject
— line 128
This file
Number 39.30
Position 30 of 92
Type JavaScript
Size 40 KB
Lines 755 lib/ structure-bridge-reader.js
JavaScript · 755 lines · 40 KB
15 // - rows go to `<entity>.ndjson.tmp`, one page at a time, with a checkpoint after every append;
16 // - reconciliation runs before promotion, and a failure leaves `.tmp` + checkpoint in place;
17 // - the governing artifact is re-hashed immediately before promotion, even on a run that never
18 // resumed, so a request/plan replaced mid-read blocks promotion;
19 // - the file is promoted first and the manifest written second;
20 // - an existing final file is reused only when its manifest still matches what is on disk.
21 //
22 // Complete-identity rule: an EAV plan with a non-empty
23 // `droppedIdentityColumns` is never promoted, and this reader never sets
24 // `acknowledgedDroppedColumns` -- not even when the plan on disk carries it.
25 //
26 // Every outcome is returned, never thrown, so one table's failure does not abort the others.
27 // `status` is `promoted`, `reused` or `deferred`; a deferred result names a `code` and `reasons`.
28
29 const fs = require ( 'node:fs' );
30 const fsp = require ( 'node:fs/promises' );
31 const path = require ( 'node:path' );
32 const { createHash } = require ( 'node:crypto' );
33 const client = require ( './wix-wp-plugin-v2-client.js' );
34
35 const PAGE_LIMIT = client. MAX_PAGE_LIMIT ;
36 const SIDECAR_PREFIX = 'wix-wp-plugin-v2' ;
37
38 function slugify ( value ) {
39 return String (value). toLowerCase (). replace ( / [ ^ a-z0-9] + / g , '-' ). replace ( / ^ - +| - +$ / g , '' ) || 'entity' ;
40 }
41
42 /** Artifact paths for one entity. Mirrors wp-discovery.js's sidecar naming. */
43 function structureBridgePaths ({ dataDir , entity , table }) {
44 if ( ! dataDir || ! entity || ! table) throw new Error ( 'structureBridgePaths needs dataDir, entity and table' );
45 if ( ! / ^ [A-Za-z0-9_] +$ / . test (table)) throw new Error ( 'bridge table must be a bare identifier' );
46 if ( ! / ^ [A-Za-z0-9_.-] +$ / . test (entity) || entity === '.' || entity === '..' ) throw new Error ( 'invalid bridge entity' );
47 const discoveryDir = path. join (dataDir, 'wp-discovery' );
48 const extractDir = path. join (dataDir, 'source-extract' );
49 const requestFile = `${ SIDECAR_PREFIX }--${ slugify ( table ) }.structure-request.json` ;
50 const planFile = `${ SIDECAR_PREFIX }--${ slugify ( table ) }.eav-read-plan.json` ;
51 const finalPath = path. join (extractDir, `${ entity }.ndjson` );
52 return {
53 discoveryDir,
54 extractDir,
55 requestFile,
56 requestPath: path. join (discoveryDir, requestFile),
57 planFile,
58 planPath: path. join (discoveryDir, planFile),
59 finalPath,
60 tmpPath: `${ finalPath }.tmp` ,
61 checkpointPath: `${ finalPath }.tmp.cursor.json` ,
62 manifestPath: path. join (extractDir, `${ entity }.manifest.json` ),
63 deferredPath: path. join (extractDir, `${ entity }.deferred.json` ),
64 };
65 }
66
67 /** Canonical (key-sorted) hash of an approved structureRequest, so formatting cannot change it. */
68 function structureRequestHash ( request ) {
69 const canonical = ( value ) => {
70 if (Array. isArray (value)) return value. map (canonical);
71 if (value && typeof value === 'object' ) {
72 return Object. keys (value). sort (). reduce (( out , key ) => { out[key] = canonical (value[key]); return out; }, {});
73 }
74 return value;
75 };
76 return createHash ( 'sha256' ). update ( JSON . stringify ( canonical (request))). digest ( 'hex' ). slice ( 0 , 16 );
77 }
78
79 async function readJsonIfExists ( filePath ) {
80 try {
81 return JSON . parse ( await fsp. readFile (filePath, 'utf8' ));
82 } catch (error) {
83 if (error.code === 'ENOENT' ) return null ;
84 throw error;
85 }
86 }
87
88 async function exists ( filePath ) {
89 try { await fsp. access (filePath); return true ; } catch { return false ; }
90 }
91
92 async function writeJsonAtomic ( filePath , value ) {
93 const staging = `${ filePath }.writing` ;
94 await fsp. writeFile (staging, `${ JSON . stringify ( value , null , 2 ) } \n ` , 'utf8' );
95 await fsp. rename (staging, filePath);
96 }
97
98 async function removeIfExists ( filePath ) {
99 await fsp. rm (filePath, { force: true });
100 }
101
102 /** Line count and sha256 of a file, streamed so a large extraction is never held in memory. */
103 function fileStats ( filePath ) {
104 return new Promise (( resolve , reject ) => {
105 if ( ! fs. existsSync (filePath)) { resolve ({ lines: 0 , digest: null , exists: false }); return ; }
106 const hash = createHash ( 'sha256' );
107 let lines = 0 ;
108 fs. createReadStream (filePath)
109 . on ( 'data' , ( chunk ) => {
110 hash. update (chunk);
111 for ( let i = 0 ; i < chunk. length ; i += 1 ) if (chunk[i] === 10 ) lines += 1 ;
112 })
113 . on ( 'error' , reject)
114 . on ( 'end' , () => resolve ({ lines, digest: hash. digest ( 'hex' ), exists: true }));
115 });
116 }
117
118 async function appendRows ( tmpPath , rows ) {
119 if (rows. length === 0 ) return ;
120 await fsp. appendFile (tmpPath, rows. map (( row ) => `${ JSON . stringify ( row ) } \n ` ). join ( '' ), 'utf8' );
121 }
122
123 async function discardInProgress ( paths ) {
124 await removeIfExists (paths.tmpPath);
125 await removeIfExists (paths.checkpointPath);
126 }
127
128 function isPlainObject ( value ) {
129 return Boolean (value) && typeof value === 'object' && ! Array. isArray (value);
130 }
131
132 const isCount = ( value ) => Number. isInteger (value) && value >= 0 ;
133 const isCursor = ( value ) => typeof value === 'string' && value !== '' ;
134
135 /** A checkpoint that is not even JSON is as unusable as a malformed one: restart, never throw. */
136 async function readCheckpoint ( paths ) {
137 try {
138 return await readJsonIfExists (paths.checkpointPath);
139 } catch (error) {
140 if (error instanceof SyntaxError ) return { unparseable: true };
141 throw error;
142 }
143 }
144
145 /**
146 * Schema and cross-field invariants of a persisted single-request checkpoint. A checkpoint is
147 * only ever written after a page, so `snapshotTotal` is always the first page's frozen total.
148 * Without this, a checkpoint missing `hasNext` skipped the page loop and could promote a
149 * partial file whose `rowsWritten` happened to equal `snapshotTotal`.
150 */
151 function singleRequestCheckpointProblem ( checkpoint ) {
152 if ( typeof checkpoint.hasNext !== 'boolean' ) return 'hasNext must be a boolean' ;
153 if ( ! isCount (checkpoint.rowsWritten)) return 'rowsWritten must be a non-negative integer' ;
154 if ( ! isCount (checkpoint.snapshotTotal)) return 'snapshotTotal must be a non-negative integer' ;
155 if (checkpoint.hasNext && ! isCursor (checkpoint.cursor)) return 'hasNext is true but cursor is not a non-empty string' ;
156 if ( ! checkpoint.hasNext && checkpoint.cursor !== null ) return 'hasNext is false but cursor is not null' ;
157 return null ;
158 }
159
160 function batchProblem ( batch , label ) {
161 if ( ! isPlainObject (batch)) return `${ label } must be an object` ;
162 if ( ! Number. isInteger (batch.batchIndex)) return `${ label }.batchIndex must be an integer` ;
163 if ( ! isCount (batch.rowsRead)) return `${ label }.rowsRead must be a non-negative integer` ;
164 if ( ! isCount (batch.snapshotTotal)) return `${ label }.snapshotTotal must be a non-negative integer` ;
165 return null ;
166 }
167
168 /**
169 * Schema and cross-field invariants of a persisted EAV checkpoint, against the plan it claims.
170 * Batches run in plan order, so `completedBatches` must be exactly the plan's first N batches
171 * and `activeBatch`, when present, the next one, mid-page; the batch row counts must add up to
172 * `rowsWritten`. Anything else is a checkpoint that would resume into the wrong batch layout.
173 */
174 function eavCheckpointProblem ( checkpoint , plan , planFile ) {
175 if (checkpoint.planFile !== planFile) return `planFile must be ${ planFile }` ;
176 if ( ! isCount (checkpoint.rowsWritten)) return 'rowsWritten must be a non-negative integer' ;
177 if ( ! Array. isArray (checkpoint.completedBatches)) return 'completedBatches must be an array' ;
178 if (checkpoint.activeBatch !== null && ! isPlainObject (checkpoint.activeBatch)) return 'activeBatch must be null or an object' ;
179 const order = client. eavReadPlanBatches (plan). map (( b ) => b.batchIndex);
180 let rows = 0 ;
181 for ( const [ i , batch ] of checkpoint.completedBatches. entries ()) {
182 const problem = batchProblem (batch, `completedBatches[${ i }]` );
183 if (problem) return problem;
184 if (batch.batchIndex !== order[i]) return `completedBatches[${ i }] is batch ${ batch . batchIndex }, but the plan runs batch ${ order [ i ] } there` ;
185 rows += batch.rowsRead;
186 }
187 const active = checkpoint.activeBatch;
188 if (active) {
189 const problem = batchProblem (active, 'activeBatch' );
190 if (problem) return problem;
191 const expected = order[checkpoint.completedBatches. length ];
192 if (active.batchIndex !== expected) return `activeBatch is batch ${ active . batchIndex }, but the next batch in the plan is ${ expected === undefined ? 'none' : expected }` ;
193 if (active.hasNext !== true ) return 'activeBatch.hasNext must be true (a finished batch belongs in completedBatches)' ;
194 if ( ! isCursor (active.cursor)) return 'activeBatch.cursor must be a non-empty string' ;
195 rows += active.rowsRead;
196 }
197 if (rows !== checkpoint.rowsWritten) return `the batches account for ${ rows } row(s) but rowsWritten is ${ checkpoint . rowsWritten }` ;
198 return null ;
199 }
200
201 function deferred ( variant , code , reasons , extra = {}) {
202 return { status: 'deferred' , variant, code, reasons: Array. isArray (reasons) ? reasons : [reasons], ... extra };
203 }
204
205 function makeRedactionTally () {
206 const columns = new Set ();
207 let count = 0 ;
208 return {
209 add ( meta ) {
210 if ( ! meta || ! meta.redactedValueCount) return ;
211 count += meta.redactedValueCount;
212 for ( const column of meta.redactedColumns || []) columns. add (column);
213 },
214 report () { return { redactedValueCount: count, redactedColumns: [ ... columns]. sort () }; },
215 };
216 }
217
218 /** One page, through the vendored client -- never a hand-built request or response check. */
219 async function fetchPage ( transport , structureRequest , cursor ) {
220 return client. queryStructure ({
221 httpClient: transport.httpClient,
222 siteBaseUrl: transport.siteBaseUrl,
223 auth: transport.auth,
224 structureRequest,
225 cursor,
226 limit: PAGE_LIMIT ,
227 onSourceRead: transport.onSourceRead,
228 });
229 }
230
231 function stalledCursorReason ( table , previousCursor , next ) {
232 if (next === null || next === undefined || next === '' ) {
233 return `table ${ table }: hasNext is true but no next cursor was returned -- refusing to loop or truncate` ;
234 }
235 if (next === previousCursor) {
236 return `table ${ table }: hasNext is true but the next cursor is unchanged from the one just used -- refusing to loop or truncate` ;
237 }
238 return null ;
239 }
240
241 // --- single-request variant --------------------------------------------------------------------
242
243 async function singleRequestReuse ( paths , requestHash ) {
244 if ( ! ( await exists (paths.finalPath))) return { reusable: false , reasons: [ 'no final file' ] };
245 const manifest = await readJsonIfExists (paths.manifestPath);
246 if ( ! manifest || manifest.kind !== 'structure-request' ) return { reusable: false , reasons: [ 'no structure-request manifest -- the existing file is stale' ] };
247 const reasons = [];
248 if (manifest.structureRequestHash !== requestHash) reasons. push ( `the approved query changed since this file was written (${ manifest . structureRequestHash } -> ${ requestHash })` );
249 if (manifest.sidecarFile !== paths.requestFile) reasons. push ( `manifest names ${ manifest . sidecarFile } but this entity's request is ${ paths . requestFile }` );
250 const stats = await fileStats (paths.finalPath);
251 if (manifest.ndjsonDigest !== stats.digest) reasons. push ( 'the extracted file does not match the digest its manifest recorded' );
252 if (manifest.rowsWritten !== stats.lines) reasons. push ( `the extracted file holds ${ stats . lines } row(s) but its manifest recorded ${ manifest . rowsWritten }` );
253 if (manifest.rowsWritten !== manifest.snapshotTotal) reasons. push ( 'the manifest records an unreconciled extraction' );
254 return { reusable: reasons. length === 0 , reasons, manifest };
255 }
256
257 async function extractSingleRequest ({ entity , table , paths , transport , now , redactions }) {
258 const variant = 'single-request' ;
259 const structureRequest = await readJsonIfExists (paths.requestPath);
260 if ( ! structureRequest) {
261 return deferred (variant, 'missing-artifact' , `table ${ table }: ${ paths . requestFile } does not exist -- discovery has not produced an approved query for this entity, so it cannot be extracted yet` );
262 }
263 // `table` is always the fulfillment's own, applied last, so a sidecar can never redirect the read.
264 const request = { ... structureRequest, table };
265 const requestHash = structureRequestHash (structureRequest);
266
267 const reuse = await singleRequestReuse (paths, requestHash);
268 if (reuse.reusable && ! ( await exists (paths.tmpPath))) {
269 return { status: 'reused' , variant, entity, rowsWritten: reuse.manifest.rowsWritten, manifestPath: paths.manifestPath, finalPath: paths.finalPath, reasons: [] };
270 }
271
272 await fsp. mkdir (paths.extractDir, { recursive: true });
273 let checkpoint = await readCheckpoint (paths);
274 const resumeNotes = [];
275 if (checkpoint && checkpoint.unparseable === true ) {
276 resumeNotes. push ( 'the checkpoint is not valid JSON -- discarding its rows and restarting' );
277 await discardInProgress (paths);
278 checkpoint = null ;
279 }
280 if (checkpoint && ( ! isPlainObject (checkpoint) || checkpoint.planHash !== undefined || typeof checkpoint.structureRequestHash !== 'string' )) {
281 resumeNotes. push ( 'checkpoint belongs to a different read variant -- restarting' );
282 await discardInProgress (paths);
283 checkpoint = null ;
284 }
285 if (checkpoint && checkpoint.structureRequestHash !== requestHash) {
286 resumeNotes. push ( 'the approved query was replaced since this read started -- discarding its rows and restarting under the current request' );
287 await discardInProgress (paths);
288 checkpoint = null ;
289 }
290 const malformed = checkpoint ? singleRequestCheckpointProblem (checkpoint) : null ;
291 if (malformed) {
292 resumeNotes. push ( `the checkpoint is malformed (${ malformed }) -- discarding its rows and restarting` );
293 await discardInProgress (paths);
294 checkpoint = null ;
295 }
296 if (checkpoint) {
297 const { lines } = await fileStats (paths.tmpPath);
298 if (lines !== checkpoint.rowsWritten) {
299 resumeNotes. push ( `the in-progress file holds ${ lines } row(s) but its checkpoint recorded ${ checkpoint . rowsWritten } -- restarting` );
300 await discardInProgress (paths);
301 checkpoint = null ;
302 }
303 } else if ( await exists (paths.tmpPath)) {
304 await removeIfExists (paths.tmpPath); // a .tmp with no checkpoint cannot be resumed safely
305 }
306
307 let state = checkpoint || { cursor: null , hasNext: true , rowsWritten: 0 , snapshotTotal: null , structureRequestHash: requestHash };
308 let queries = 0 ;
309 if ( ! checkpoint) await fsp. writeFile (paths.tmpPath, '' , 'utf8' );
310
311 while (state.hasNext) {
312 let page;
313 try {
314 // eslint-disable-next-line no-await-in-loop
315 page = await fetchPage (transport, request, state.cursor);
316 } catch (error) {
317 return deferred (variant, 'page-error' , `table ${ table }: ${ error . message }` , { entity, rowsWritten: state.rowsWritten, queries, resumeNotes });
318 }
319 queries += 1 ;
320 redactions. add (page.redactionMetadata);
321 const { hasNext , total , cursors } = page.pagingMetadata;
322 const stalled = hasNext ? stalledCursorReason (table, state.cursor, cursors.next) : null ;
323 if (stalled) {
324 // Nothing from this page is appended: the checkpoint still describes the file exactly.
325 return deferred (variant, 'stalled-cursor' , stalled, { entity, rowsWritten: state.rowsWritten, queries, resumeNotes });
326 }
327 // eslint-disable-next-line no-await-in-loop
328 await appendRows (paths.tmpPath, page.rows);
329 state = {
330 cursor: hasNext ? cursors.next : null ,
331 hasNext,
332 rowsWritten: state.rowsWritten + page.rows. length ,
333 snapshotTotal: state.snapshotTotal === null ? total : state.snapshotTotal,
334 structureRequestHash: requestHash,
335 };
336 // eslint-disable-next-line no-await-in-loop
337 await writeJsonAtomic (paths.checkpointPath, state);
338 }
339
340 if (state.rowsWritten !== state.snapshotTotal) {
341 return deferred (variant, 'count-mismatch' , `table ${ table }: read ${ state . rowsWritten } row(s) but the first page's frozen snapshot total was ${ state . snapshotTotal } -- not promoting` , { entity, rowsWritten: state.rowsWritten, queries, resumeNotes });
342 }
343
344 // Final re-hash: a sidecar replaced while this read ran straight through is caught only here.
345 const current = await readJsonIfExists (paths.requestPath);
346 const currentHash = current ? structureRequestHash (current) : null ;
347 if (currentHash !== state.structureRequestHash) {
348 return deferred (variant, 'request-replaced' , `table ${ table }: the approved query changed during this read (${ state . structureRequestHash } -> ${ currentHash || 'missing'}) -- not promoting; the next run restarts under the current request` , { entity, rowsWritten: state.rowsWritten, queries, resumeNotes });
349 }
350
351 // Promote first, manifest second: a crash between them leaves a file the reuse check refuses.
352 await fsp. rename (paths.tmpPath, paths.finalPath);
353 await removeIfExists (paths.checkpointPath);
354 const stats = await fileStats (paths.finalPath);
355 const manifest = {
356 kind: 'structure-request' ,
357 sidecarFile: paths.requestFile,
358 structureRequestHash: state.structureRequestHash,
359 rowsWritten: state.rowsWritten,
360 snapshotTotal: state.snapshotTotal,
361 ndjsonDigest: stats.digest,
362 extractedAt: new Date ( now ()). toISOString (),
363 };
364 await writeJsonAtomic (paths.manifestPath, manifest);
365 return { status: 'promoted' , variant, entity, rowsWritten: state.rowsWritten, queries, manifestPath: paths.manifestPath, finalPath: paths.finalPath, reasons: [], resumeNotes };
366 }
367
368 // --- EAV variant -------------------------------------------------------------------------------
369
370 const BEST_EFFORT_MARKER = 'the extraction is best-effort' ;
371
372 /** Split reconcileEavReadPlan()'s problems into blocking ones and the best-effort qualifier. */
373 function splitBestEffort ( plan , problems ) {
374 const bestEffort = plan.orderByIsUnique === false ? problems. filter (( p ) => p. includes ( BEST_EFFORT_MARKER )) : [];
375 return { blocking: problems. filter (( p ) => ! bestEffort. includes (p)), bestEffort };
376 }
377
378 /** Plan-level conditions no amount of reading can fix. Checked before any request is sent. */
379 function eavPlanBlockers ( plan , table ) {
380 const blockers = [];
381 if (Array. isArray (plan.droppedIdentityColumns) && plan.droppedIdentityColumns. length > 0 ) {
382 blockers. push ({
383 code: 'dropped-identity-columns' ,
384 reason: `table ${ table }: ${ plan . droppedIdentityColumns . length } identity column(s) do not fit the 50-column select cap (${ plan . droppedIdentityColumns . join ( ', ' ) }). `
385 + 'This fulfillment kind requires complete identity: narrow the mapping or redesign the read plan, then regenerate it. '
386 + 'An acknowledgedDroppedColumns flag on the plan does not change this.' ,
387 });
388 }
389 if (plan.keysTruncated) {
390 blockers. push ({ code: 'keys-truncated' , reason: `table ${ table }: key discovery hit its cap, so the key list is incomplete -- this extraction cannot be treated as whole-table` });
391 }
392 return blockers;
393 }
394
395 async function extractEav ({ entity , table , paths , transport , now , redactions }) {
396 const variant = 'eav' ;
397 const plan = await readJsonIfExists (paths.planPath);
398 const integrity = client. verifyEavReadPlanIntegrity (plan);
399 if (integrity) return deferred (variant, 'plan-integrity' , integrity, { entity });
400 const planHash = client. eavReadPlanHash (plan);
401
402 const blockers = eavPlanBlockers (plan, table);
403 if (blockers. length > 0 ) {
404 // In-progress files are left exactly as they are.
405 return deferred (variant, blockers[ 0 ].code, blockers. map (( b ) => b.reason), { entity });
406 }
407
408 if ( await exists (paths.finalPath) && ! ( await exists (paths.tmpPath))) {
409 const manifest = await readJsonIfExists (paths.manifestPath);
410 const stats = await fileStats (paths.finalPath);
411 const reasons = client. eavExtractionIsReusable (plan, manifest, { planFile: paths.planFile, ndjsonDigest: stats.digest, ndjsonRowCount: stats.lines });
412 const blocking = reasons. filter (( r ) => ! (plan.orderByIsUnique === false && r. includes ( BEST_EFFORT_MARKER )));
413 if (blocking. length === 0 ) {
414 return { status: 'reused' , variant, entity, rowsWritten: manifest.rowsWritten, bestEffort: plan.orderByIsUnique === false , manifestPath: paths.manifestPath, finalPath: paths.finalPath, reasons: [] };
415 }
416 }
417
418 await fsp. mkdir (paths.extractDir, { recursive: true });
419 let checkpoint = await readCheckpoint (paths);
420 const resumeNotes = [];
421 if (checkpoint && checkpoint.unparseable === true ) {
422 resumeNotes. push ( 'the checkpoint is not valid JSON -- discarding its rows and restarting' );
423 await discardInProgress (paths);
424 checkpoint = null ;
425 }
426 if (checkpoint && ( ! isPlainObject (checkpoint) || typeof checkpoint.planHash !== 'string' )) {
427 resumeNotes. push ( 'checkpoint belongs to a different read variant -- restarting' );
428 await discardInProgress (paths);
429 checkpoint = null ;
430 }
431 if (checkpoint && checkpoint.planHash !== planHash) {
432 resumeNotes. push ( 'the read plan was replaced since this read started -- discarding its rows and restarting under the current plan' );
433 await discardInProgress (paths);
434 checkpoint = null ;
435 }
436 const malformed = checkpoint ? eavCheckpointProblem (checkpoint, plan, paths.planFile) : null ;
437 if (malformed) {
438 resumeNotes. push ( `the checkpoint is malformed (${ malformed }) -- discarding its rows and restarting` );
439 await discardInProgress (paths);
440 checkpoint = null ;
441 }
442 if (checkpoint) {
443 const { lines } = await fileStats (paths.tmpPath);
444 if (lines !== checkpoint.rowsWritten) {
445 resumeNotes. push ( `the in-progress file holds ${ lines } row(s) but its checkpoint recorded ${ checkpoint . rowsWritten } -- restarting` );
446 await discardInProgress (paths);
447 checkpoint = null ;
448 }
449 } else if ( await exists (paths.tmpPath)) {
450 await removeIfExists (paths.tmpPath);
451 }
452
453 let state = checkpoint || { planFile: paths.planFile, planHash, activeBatch: null , completedBatches: [], rowsWritten: 0 };
454 if ( ! checkpoint) await fsp. writeFile (paths.tmpPath, '' , 'utf8' );
455 const done = new Set (state.completedBatches. map (( b ) => b.batchIndex));
456 let queries = 0 ;
457
458 for ( const batch of client. eavReadPlanBatches (plan)) {
459 if (done. has (batch.batchIndex)) continue ;
460 let active = state.activeBatch && state.activeBatch.batchIndex === batch.batchIndex
461 ? state.activeBatch
462 : { batchIndex: batch.batchIndex, cursor: null , hasNext: true , rowsRead: 0 , snapshotTotal: null };
463 const request = { ... batch.structureRequest, table };
464 while (active.hasNext) {
465 let page;
466 try {
467 // eslint-disable-next-line no-await-in-loop
468 page = await fetchPage (transport, request, active.cursor);
469 } catch (error) {
470 return deferred (variant, 'page-error' , `table ${ table } batch ${ batch . batchIndex }: ${ error . message }` , { entity, rowsWritten: state.rowsWritten, queries, resumeNotes });
471 }
472 queries += 1 ;
473 redactions. add (page.redactionMetadata);
474 const { hasNext , total , cursors } = page.pagingMetadata;
475 const stalled = hasNext ? stalledCursorReason ( `${ table } batch ${ batch . batchIndex }` , active.cursor, cursors.next) : null ;
476 if (stalled) return deferred (variant, 'stalled-cursor' , stalled, { entity, rowsWritten: state.rowsWritten, queries, resumeNotes });
477 // eslint-disable-next-line no-await-in-loop
478 await appendRows (paths.tmpPath, page.rows);
479 active = {
480 batchIndex: batch.batchIndex,
481 cursor: hasNext ? cursors.next : null ,
482 hasNext,
483 rowsRead: active.rowsRead + page.rows. length ,
484 snapshotTotal: active.snapshotTotal === null ? total : active.snapshotTotal,
485 };
486 const rowsWritten = state.rowsWritten + page.rows. length ;
487 state = hasNext
488 ? { ... state, activeBatch: active, rowsWritten }
489 : {
490 ... state,
491 activeBatch: null ,
492 completedBatches: [ ... state.completedBatches, { batchIndex: active.batchIndex, rowsRead: active.rowsRead, snapshotTotal: active.snapshotTotal }],
493 rowsWritten,
494 };
495 // eslint-disable-next-line no-await-in-loop
496 await writeJsonAtomic (paths.checkpointPath, state);
497 }
498 done. add (batch.batchIndex);
499 }
500
501 const problems = client. reconcileEavReadPlan (plan, { planHash: state.planHash, batches: state.completedBatches, rowsWritten: state.rowsWritten });
502 const { blocking , bestEffort } = splitBestEffort (plan, problems);
503 if (blocking. length > 0 ) {
504 return deferred (variant, 'reconcile' , blocking, { entity, rowsWritten: state.rowsWritten, queries, resumeNotes });
505 }
506
507 const currentPlan = await readJsonIfExists (paths.planPath);
508 const currentHash = currentPlan ? client. eavReadPlanHash (currentPlan) : null ;
509 if (currentHash !== state.planHash || (currentPlan && client. verifyEavReadPlanIntegrity (currentPlan))) {
510 return deferred (variant, 'plan-replaced' , `table ${ table }: the read plan changed during this read (${ state . planHash } -> ${ currentHash || 'missing'}) -- not promoting; the next run restarts under the current plan` , { entity, rowsWritten: state.rowsWritten, queries, resumeNotes });
511 }
512
513 await fsp. rename (paths.tmpPath, paths.finalPath);
514 await removeIfExists (paths.checkpointPath);
515 const stats = await fileStats (paths.finalPath);
516 // acknowledgedDroppedColumns is forced false: this reader never promotes under it.
517 const manifest = client. eavExtractionManifest ({ ... plan, acknowledgedDroppedColumns: false }, {
518 planFile: paths.planFile,
519 completedBatches: state.completedBatches,
520 rowsWritten: state.rowsWritten,
521 extractedAt: new Date ( now ()). toISOString (),
522 ndjsonDigest: stats.digest,
523 });
524 await writeJsonAtomic (paths.manifestPath, manifest);
525 return {
526 status: 'promoted' ,
527 variant,
528 entity,
529 rowsWritten: state.rowsWritten,
530 queries,
531 bestEffort: bestEffort. length > 0 ,
532 reasons: bestEffort,
533 manifestPath: paths.manifestPath,
534 finalPath: paths.finalPath,
535 resumeNotes,
536 };
537 }
538
539 // --- durable deferred-extraction record ----------------------------------------------------------
540 //
541 // A deferred result in memory dies with the session, and the person who later completes the
542 // cutover is usually a different operator on a different machine. So every deferral is also
543 // written to `data/source-extract/<entity>.deferred.json`: what was not extracted, why, what it
544 // costs, what is kept for resuming, and the exact next step. Recovery needs the original
545 // migration environment (the source site's plugin credential and these in-progress files), so
546 // the owner is always the Import Operator and the record carries a copyable prompt for them.
547 // A later promotion or reuse marks the same record `resolved`, with the manifest as evidence.
548 //
549 // deferredExtractionReportInput() turns open records into completion-report `operatorActions`
550 // rows, which the migration handoff renders above the counts. That is the integration used until
551 // the shared Cutover integration exists; this file names no CMS output yet.
552
553 const DEFERRED_RECORD_KIND = 'structure-bridge-deferred-extraction' ;
554 const DEFERRED_ACTION_CODE = 'structure-bridge-extraction-deferred' ;
555 const PRODUCER = 'rp-source-wordpress/lib/structure-bridge-reader.js' ;
556 const SOURCE_PREREQUISITES = [
557 'the source WordPress site is still online with the Wix WordPress plugin (wix-wp-plugin-v2) active' ,
558 'the WordPress Application Password this migration was configured with is still valid in the original migration environment' ,
559 ];
560 const DISCOVERY_PREREQUISITES = [ ... SOURCE_PREREQUISITES , 'WordPress discovery can be re-run from the original migration project' ];
561
562 /** What resolves each deferral code. `restart` means the rerun must pass `restart: true`. */
563 function recoveryFor ( code , { entity , table , paths }) {
564 const rerun = `re-run the extraction for ${ entity }` ;
565 const byCode = {
566 'missing-artifact' : { action: `inspect the missing artifact and its discovery producer for ${ entity }; regenerate it only if that producer supports this entity, otherwise prepare a validated entity read plan through the generic bridge, then ${ rerun }` , prerequisites: DISCOVERY_PREREQUISITES },
567 'plan-integrity' : { action: `re-run WordPress discovery for table ${ table } to regenerate ${ paths . planFile } (it was edited after discovery wrote it), then ${ rerun }` , prerequisites: DISCOVERY_PREREQUISITES },
568 'keys-truncated' : { action: `re-run key discovery for table ${ table } with a key cap high enough for the whole key list, regenerate ${ paths . planFile }, then ${ rerun }` , prerequisites: DISCOVERY_PREREQUISITES },
569 'dropped-identity-columns' : { action: `narrow the mapping for ${ entity } (or redesign its read plan) so every identity column fits each batch, regenerate ${ paths . planFile }, get the mapping change reviewed, then ${ rerun }` , prerequisites: [ ... DISCOVERY_PREREQUISITES , 'the mapping review is re-opened for this entity' ] },
570 'page-error' : { action: `check that the source site and its plugin credential work, then ${ rerun }; it resumes from the saved checkpoint` , prerequisites: SOURCE_PREREQUISITES },
571 'stalled-cursor' : { action: `check the Wix WordPress plugin version on the source site (it returned an invalid next-page cursor), then ${ rerun }; it resumes from the saved checkpoint` , prerequisites: SOURCE_PREREQUISITES },
572 'count-mismatch' : { action: `the table changed while it was read; ${ rerun } with restart: true so it reads the whole table again` , prerequisites: SOURCE_PREREQUISITES , restart: true },
573 reconcile: { action: `a batch did not read every row its snapshot promised; ${ rerun } with restart: true so it reads the whole table again` , prerequisites: SOURCE_PREREQUISITES , restart: true },
574 'request-replaced' : { action: `the approved query changed during the read; ${ rerun } -- it restarts under the current query` , prerequisites: SOURCE_PREREQUISITES },
575 'plan-replaced' : { action: `the read plan changed during the read; ${ rerun } -- it restarts under the current plan` , prerequisites: SOURCE_PREREQUISITES },
576 };
577 return { restart: false , ... (byCode[code] || { action: `fix the local cause named in the reason, then ${ rerun }; in-progress files are kept` , prerequisites: SOURCE_PREREQUISITES }) };
578 }
579
580 function projectRelative ( dataDir , filePath ) {
581 return path. relative (path. dirname (path. resolve (dataDir)), path. resolve (filePath)). split (path.sep). join ( '/' );
582 }
583
584 async function buildDeferredRecord ({ dataDir , entity , table , paths , result , previous , at }) {
585 const rel = ( p ) => projectRelative (dataDir, p);
586 const recovery = recoveryFor (result.code, { entity, table, paths });
587 let checkpoint = null ;
588 try { checkpoint = await readJsonIfExists (paths.checkpointPath); } catch { checkpoint = null ; }
589 const expectedTotal = checkpoint && isCount (checkpoint.snapshotTotal) ? checkpoint.snapshotTotal : null ;
590 const [ tmpPresent , checkpointPresent ] = await Promise . all ([ exists (paths.tmpPath), exists (paths.checkpointPath)]);
591 const reason = result.reasons[ 0 ] || result.code;
592 const resolutionCheck = `${ rel ( paths . manifestPath ) } exists, a rerun of extractStructureBridgeEntity for ${ entity } returns status promoted or reused, and this record then reads status: resolved` ;
593 const copyPrompt = `In the original migration project for this Wix site, the ${ entity } data (WordPress table ${ table }) was not extracted, so none of it was imported: ${ result . code } -- ${ reason } `
594 + `Read ${ rel ( paths . deferredPath ) }, then ${ recovery . action }. `
595 + 'Use the plugin credential already configured in this environment; never paste a credential into chat, a report or this prompt. '
596 + `Done when ${ resolutionCheck }. Then import ${ entity } to Wix and regenerate the completion report and handoff.` ;
597 return {
598 schemaVersion: 1 ,
599 kind: DEFERRED_RECORD_KIND ,
600 status: 'open' ,
601 producer: PRODUCER ,
602 entity,
603 table,
604 variant: result.variant,
605 code: result.code,
606 reasons: result.reasons,
607 firstDeferredAt: previous && previous.status === 'open' && previous.firstDeferredAt ? previous.firstDeferredAt : at,
608 lastDeferredAt: at,
609 attempts: (previous && previous.status === 'open' && isCount (previous.attempts) ? previous.attempts : 0 ) + 1 ,
610 affectedRecords: { entity, extractedSoFar: isCount (result.rowsWritten) ? result.rowsWritten : 0 , expectedTotal, imported: 0 },
611 consequence: `No ${ entity } record is imported to Wix until this extraction is promoted.` ,
612 resumable: {
613 tmpPath: rel (paths.tmpPath),
614 tmpPresent,
615 checkpointPath: rel (paths.checkpointPath),
616 checkpointPresent,
617 restartRequired: recovery.restart,
618 },
619 owner: 'Import Operator' ,
620 requiresImportOperator: true ,
621 action: recovery.action,
622 prerequisites: recovery.prerequisites,
623 timing: 'before cutover, while the source WordPress site and this migration \' s plugin credential are still available' ,
624 resolutionCheck,
625 copyPromptForImportOperator: copyPrompt,
626 cutoverIntegration: 'pending: the shared Cutover integration is not defined yet; until it is, this record reaches the Cutover Operator through the completion report \' s operatorActions and the migration handoff' ,
627 };
628 }
629
630 /** Write, refresh or resolve the entity's durable record. Never fails the extraction itself. */
631 async function recordOutcome ({ dataDir , entity , table , paths , result , now }) {
632 try {
633 const at = new Date ( now ()). toISOString ();
634 let previous = null ;
635 try { previous = await readJsonIfExists (paths.deferredPath); } catch { previous = null ; }
636 if (result.status === 'deferred' ) {
637 await fsp. mkdir (paths.extractDir, { recursive: true });
638 await writeJsonAtomic (paths.deferredPath, await buildDeferredRecord ({ dataDir, entity, table, paths, result, previous, at }));
639 return { deferredRecordPath: paths.deferredPath };
640 }
641 if (previous && previous.status === 'open' ) {
642 await writeJsonAtomic (paths.deferredPath, {
643 ... previous,
644 status: 'resolved' ,
645 resolvedAt: at,
646 resolution: { status: result.status, rowsWritten: result.rowsWritten, manifestPath: projectRelative (dataDir, paths.manifestPath) },
647 });
648 return { deferredRecordPath: paths.deferredPath };
649 }
650 return {};
651 } catch (error) {
652 return { deferredRecordError: `could not write ${ paths . deferredPath }: ${ error . message }` };
653 }
654 }
655
656 /** Every open deferred-extraction record under `data/source-extract/`. */
657 async function collectDeferredExtractions ( dataDir ) {
658 const extractDir = path. join (dataDir, 'source-extract' );
659 let names;
660 try { names = await fsp. readdir (extractDir); } catch (error) { if (error.code === 'ENOENT' ) return []; throw error; }
661 const records = [];
662 for ( const name of names. filter (( n ) => n. endsWith ( '.deferred.json' )). sort ()) {
663 // eslint-disable-next-line no-await-in-loop
664 const record = await readJsonIfExists (path. join (extractDir, name));
665 if (record && record.kind === DEFERRED_RECORD_KIND && record.status === 'open' ) records. push (record);
666 }
667 return records;
668 }
669
670 /**
671 * Completion-report input for open deferred extractions: one high-severity `operatorActions`
672 * row per entity (rendered above the counts in the report and the handoff), and the
673 * `complete_with_deferred_records` status so a deferred table never reads as a clean run.
674 */
675 function deferredExtractionReportInput ( records = []) {
676 const operatorActions = records. map (( record ) => ({
677 code: DEFERRED_ACTION_CODE ,
678 severity: 'high' ,
679 entity: record.entity,
680 affectedCount: isCount (record.affectedRecords && record.affectedRecords.expectedTotal) ? record.affectedRecords.expectedTotal : null ,
681 action: `Requires Import Operator: ${ record . entity }${ record . table ? ` (table ${ record . table })` : ''} extraction is unresolved (${ record . code }); affected new writes are held`
682 + `${ record . affectedRecords && record . affectedRecords . expectedTotal === null ? ' (row count unknown)' : ''}. ${ record . action }. `
683 + `Details: data/source-extract/${ record . entity }.deferred.json. Copy prompt for Import Operator: "${ record . copyPromptForImportOperator }"` ,
684 deadlineReason: 'the missing rows can only be read from the source WordPress site with this migration \' s plugin credential, which may be gone after cutover' ,
685 }));
686 return { operatorActions, statuses: operatorActions. length ? [ 'complete_with_deferred_records' ] : [] };
687 }
688
689 /**
690 * Extract one structure-bridge-plugin entity completely, resuming any interrupted read.
691 *
692 * @param options.dataDir the project's `data/` directory (holds wp-discovery/ and source-extract/)
693 * @param options.entity source entity name, e.g. `discount-rule-definition`
694 * @param options.table the fulfillment's bare table name, e.g. `wdr_rules`
695 * @param options.transport { httpClient, siteBaseUrl, auth, onSourceRead } -- the same bridge
696 * credential selection discovery used (see rp-import-codegen)
697 * @param options.expectEav true when discovery's schema reported an eavPair; with no read plan
698 * on disk that means key discovery failed, and the entity is deferred
699 * rather than read through the key-names-only request
700 * @param options.restart discard the in-progress `.tmp` and checkpoint first; the recovery for
701 * a `count-mismatch` or `reconcile` deferral, whose checkpoint would
702 * otherwise reconcile the same way again
703 */
704 async function extractStructureBridgeEntity ({ dataDir , entity , table , transport , expectEav = false , restart = false , readArtifacts = null , recordDeferred = true , now = () => Date. now () } = {}) {
705 if ( ! transport || ! transport.siteBaseUrl) throw new Error ( 'extractStructureBridgeEntity needs transport.siteBaseUrl' );
706 const paths = structureBridgePaths ({ dataDir, entity, table });
707 if (readArtifacts) {
708 // Bound callers supply explicit files; invalid references never select legacy sidecars.
709 const { checkedFile } = require ( './plugin-entity-read-plan.js' );
710 const projectDir = fs. realpathSync (path. dirname (path. resolve (dataDir)));
711 for ( const [ key , ref ] of Object. entries (readArtifacts)) {
712 if ( ! [ 'structureRequest' , 'eavReadPlan' ]. includes (key)) throw new Error ( 'unknown bridge read artifact' );
713 const file = checkedFile (projectDir, ref, [ 'data/wp-discovery' ]);
714 const request = JSON . parse ( await fsp. readFile (file, 'utf8' ));
715 if (request.table !== table) throw new Error ( 'read artifact physical table mismatch' );
716 if (key === 'eavReadPlan' && client. eavReadPlanBatches (request). some (( b ) => b.structureRequest.table !== table)) throw new Error ( 'EAV batch physical table mismatch' );
717 const prefix = key === 'structureRequest' ? 'request' : 'plan' ;
718 paths[ `${ prefix }Path` ] = file;
719 paths[ `${ prefix }File` ] = path. relative (projectDir, file). split (path.sep). join ( '/' );
720 }
721 if (expectEav && ! readArtifacts.eavReadPlan) throw new Error ( 'bound EAV component needs value plan' );
722 if ( ! expectEav && ! readArtifacts.structureRequest) throw new Error ( 'bound component needs request' );
723 if ( ! readArtifacts.eavReadPlan) paths.planPath = path. join (paths.extractDir, `${ entity }.no-eav-plan` );
724 }
725 const redactions = makeRedactionTally ();
726 const hasPlan = await exists (paths.planPath);
727 let result;
728 try {
729 if (restart) await discardInProgress (paths);
730 if (hasPlan) {
731 result = await extractEav ({ entity, table, paths, transport, now, redactions });
732 } else if (expectEav) {
733 result = deferred ( 'eav' , 'missing-artifact' , `table ${ table }: the schema reports a key/value pair but ${ paths . planFile } does not exist -- key discovery failed, so values cannot be extracted; the key-names request is not an extraction` );
734 } else {
735 result = await extractSingleRequest ({ entity, table, paths, transport, now, redactions });
736 }
737 } catch (error) {
738 // A local I/O or artifact-parse failure: in-progress files stay for the next attempt.
739 result = deferred (hasPlan ? 'eav' : 'single-request' , 'unexpected-error' , `table ${ table }: ${ error . message }` );
740 }
741 const recorded = recordDeferred ? await recordOutcome ({ dataDir, entity, table, paths, result, now }) : {};
742 const recovery = result.status === 'deferred' ? recoveryFor (result.code, { entity, table, paths }) : null ;
743 return { entity, table, ... result, ... recorded, recovery, redactions: redactions. report () };
744 }
745
746 module . exports = {
747 PAGE_LIMIT,
748 fileStats,
749 DEFERRED_ACTION_CODE,
750 collectDeferredExtractions,
751 deferredExtractionReportInput,
752 extractStructureBridgeEntity,
753 structureBridgePaths,
754 structureRequestHash,
755 };