Setting the file. One moment.
Shopify Discovery · Rp Source Shopify · wix/skills · Skills Docs
ContentsBack to the top of the page 204
function errorEntries
— line 204
This file
Number 38.1
Position 1 of 51
Type JavaScript
Size 33 KB
Lines 703 scripts/ shopify-discovery.js
JavaScript · 703 lines · 33 KB
// writes data/shopify-discovery/source-coverage.json (rows: [] when clean). All recorded state is
15 // bound to the one store it was read from: a run for a different store stops with
16 // shopify_discovery_store_mismatch instead of mixing files, and --restart is the explicit way to
17 // discard the capture and begin again. source-profile.md and source-schema.json are synthesized
18 // from the capture afterwards and promoted with rp-discovery's promote-source-coverage.js --adapter shopify.
19 //
20 // Exit codes: 0 complete; 3 waiting on a merchant question (the run holds at
21 // awaiting_user_action); 2 stopped (the printed code says why; recorded steps are kept);
22 // 1 usage or unexpected error.
23
24 const crypto = require ( 'node:crypto' );
25 const fs = require ( 'node:fs/promises' );
26 const path = require ( 'node:path' );
27 const { createProgressLogger , parseProgressArgs } = require ( '../../../lib/progress-log.js' );
28 const { buildShopifyCoverageRows , buildCoverageFile , ADAPTER_RAW_DIRS , SOURCE_COVERAGE_FILE , writeJsonAtomic } = require ( '../../../lib/source-coverage.js' );
29 const { createUserAction , readUserAction } = require ( '../../../lib/user-actions.js' );
30 const bulk = require ( '../lib/shopify-bulk.js' );
31 const giftCards = require ( '../lib/shopify-gift-cards.js' );
32 const { createStoreExecutor , ShopifyCliError } = require ( '../lib/shopify-cli.js' );
33 const { ALL_READS , ORDER_WINDOW_READS , SAMPLE_LIMIT , operationName , at } = require ( '../lib/discovery-reads.js' );
34 const { ShopifySessionStop , assertConnectionReady , recordAuthExpired , runSessionCheck } = require ( '../lib/shopify-session.js' );
35 const protectedFields = require ( '../lib/shopify-protected-fields.js' );
36 const { regionalStep } = require ( '../lib/shopify-regional.js' );
37
38 const RAW_DIR = ADAPTER_RAW_DIRS .shopify;
39 const STEPS_FILE = 'discovery-steps.json' ;
40 const ENTITIES_DIR = 'entities' ;
41 const STEPS_SCHEMA_VERSION = 1 ;
42 // What discovery hands to the mapper once it has been synthesized and promoted. They live outside
43 // the raw directory, so a restart cannot clear them: it refuses to switch stores once they exist.
44 const HANDOFF_FILES = Object. freeze ([ 'source-profile.md' , 'source-schema.json' , SOURCE_COVERAGE_FILE ]);
45 const STORE_PATTERN = / ^ [a-z0-9][a-z0-9-] * \. myshopify \. com $ / ;
46 // Connection records, not capture: a restart keeps them. The staff-account record
47 // outlives any capture -- the account exists until the owner removes it.
48 const CONNECTION_FILES = Object. freeze ([ 'session-receipt.json' , 'staff-access.json' ]);
49 const ORDER_WINDOW_FILE = 'order-window.json' ;
50 const ORDER_WINDOW_DAYS = 60 ;
51
52 class ShopifyDiscoveryStop extends Error {
53 constructor ( code , message , details = {}) {
54 super (message);
55 this .name = 'ShopifyDiscoveryStop' ;
56 this .code = code;
57 this .details = details;
58 }
59 }
60
61 // ---------------------------------------------------------------------------------------------
62 // Store identity -- every recorded artifact belongs to exactly one store
63 // ---------------------------------------------------------------------------------------------
64
65 function normalizeStore ( store ) {
66 const value = typeof store === 'string' ? store. trim (). toLowerCase () : '' ;
67 if ( ! STORE_PATTERN . test (value)) {
68 throw new ShopifyDiscoveryStop ( 'shopify_store_invalid' , 'store must be the *.myshopify.com domain the Shopify CLI session is bound to' );
69 }
70 return value;
71 }
72
73 function storeMismatch ( recorded , store , what ) {
74 return new ShopifyDiscoveryStop (
75 'shopify_discovery_store_mismatch' ,
76 `Shopify discovery data in this project (${ what }) was read from ${ recorded || 'an unrecorded store'}, not ${ store }. Nothing was read or changed. Run discovery for ${ recorded || 'the original store'}, or restart discovery with --restart to discard the recorded capture and read ${ store } from scratch.` ,
77 { recorded: recorded || null , store },
78 );
79 }
80
81 // ---------------------------------------------------------------------------------------------
82 // Step state
83 // ---------------------------------------------------------------------------------------------
84
85 async function readJson ( file ) {
86 try {
87 return JSON . parse ( await fs. readFile (file, 'utf8' ));
88 } catch (error) {
89 if (error.code === 'ENOENT' ) return undefined ;
90 throw error;
91 }
92 }
93
94 async function readSteps ( rawDir , now , store ) {
95 const state = await readJson (path. join (rawDir, STEPS_FILE ));
96 if (state && state.schemaVersion === STEPS_SCHEMA_VERSION && state.steps && typeof state.steps === 'object' ) return state;
97 return { schemaVersion: STEPS_SCHEMA_VERSION , platform: 'shopify' , store, startedAt: now (), steps: {} };
98 }
99
100 // Refuse to continue on anything recorded for another store (or for none): the steps file, and
101 // every entity file already on disk, which is what a resume would otherwise accept.
102 async function assertBoundToStore ( rawDir , store ) {
103 const steps = await readJson (path. join (rawDir, STEPS_FILE ));
104 if (steps && steps.store !== store) throw storeMismatch (steps.store, store, STEPS_FILE );
105 let names = [];
106 try {
107 names = await fs. readdir (path. join (rawDir, ENTITIES_DIR ));
108 } catch (error) {
109 if (error.code !== 'ENOENT' ) throw error;
110 }
111 for ( const name of names. filter (( entry ) => entry. endsWith ( '.json' ))) {
112 const record = await readJson (path. join (rawDir, ENTITIES_DIR , name));
113 if (record && record.store !== store) throw storeMismatch (record.store, store, `${ ENTITIES_DIR }/${ name }` );
114 }
115 }
116
117 // The store the recorded capture belongs to: the steps file's, else any entity file's, else none.
118 async function recordedStore ( rawDir ) {
119 const steps = await readJson (path. join (rawDir, STEPS_FILE ));
120 if (steps && typeof steps.store === 'string' ) return steps.store;
121 let names = [];
122 try {
123 names = await fs. readdir (path. join (rawDir, ENTITIES_DIR ));
124 } catch (error) {
125 if (error.code !== 'ENOENT' ) throw error;
126 }
127 for ( const name of names. filter (( entry ) => entry. endsWith ( '.json' ))) {
128 const record = await readJson (path. join (rawDir, ENTITIES_DIR , name));
129 if (record && typeof record.store === 'string' ) return record.store;
130 }
131 return null ;
132 }
133
134 async function existingHandoffFiles ( projectDir ) {
135 const found = [];
136 for ( const rel of HANDOFF_FILES ) {
137 try {
138 await fs. access (path. join (projectDir, rel));
139 found. push (rel);
140 } catch (error) {
141 if (error.code !== 'ENOENT' ) throw error;
142 }
143 }
144 return found;
145 }
146
147 // --restart: discard the whole Shopify raw directory so the next run reads from scratch and no
148 // file from the previous capture -- including files the technical steps add -- can survive by
149 // omission from a list. The mapper's handoff (source-profile.md, source-schema.json and the
150 // promoted coverage file) is outside that directory, so a restart that would leave the previous
151 // store's handoff in place for a different store is refused: that project belongs to the first
152 // store, and the other one needs its own migration project. A restart for the same store is
153 // allowed. Questions the previous run asked are tied to it; while one is still pending the run is
154 // holding at awaiting_user_action, so the restart also waits until it is answered.
155 async function restartDiscovery ( projectDir , rawDir , store ) {
156 const handoff = await existingHandoffFiles (projectDir);
157 const previousStore = await recordedStore (rawDir);
158 if (handoff. length && previousStore !== store) {
159 throw new ShopifyDiscoveryStop (
160 'shopify_restart_blocked_handoff' ,
161 `this project already hands ${ previousStore || 'an unrecorded store'}'s discovery to mapping (${ handoff . join ( ', ' ) }), so ${ store } cannot be read into it. Nothing was read or changed. Start a new migration project for ${ store }.` ,
162 { recorded: previousStore, store, handoff },
163 );
164 }
165 const previous = await readJson (path. join (rawDir, STEPS_FILE ));
166 if (previous && previous.steps && previous.startedAt) {
167 for ( const entry of Object. values (previous.steps)) {
168 for ( const question of (entry.record && entry.record.questions) || []) {
169 try {
170 const action = await readUserAction (projectDir, questionRequestId (previous.startedAt, question.key));
171 if (action.status !== 'answered' ) {
172 throw new ShopifyDiscoveryStop ( 'shopify_restart_blocked' , `a merchant question from the previous discovery is still pending (${ action . requestId }); answer it first, then restart` , { requestId: action.requestId });
173 }
174 } catch (error) {
175 if (error.code !== 'ENOENT' ) throw error;
176 }
177 }
178 }
179 }
180 let names = [];
181 try {
182 names = await fs. readdir (rawDir);
183 } catch (error) {
184 if (error.code !== 'ENOENT' ) throw error;
185 }
186 for ( const name of names) {
187 if ( CONNECTION_FILES . includes (name) || name. startsWith ( 'staff-access.json.unreadable-' )) continue ;
188 await fs. rm (path. join (rawDir, name), { recursive: true , force: true });
189 }
190 }
191
192 async function writeSteps ( rawDir , state ) {
193 await writeJsonAtomic (path. join (rawDir, STEPS_FILE ), state);
194 }
195
196 function entityFile ( entity ) {
197 return path. join ( ENTITIES_DIR , `${ entity }.json` );
198 }
199
200 // ---------------------------------------------------------------------------------------------
201 // Step 1 -- capture: one count (where one exists) and up to SAMPLE_LIMIT samples per entity
202 // ---------------------------------------------------------------------------------------------
203
204 function errorEntries ( read , errors ) {
205 return (errors || []). map (( error ) => ({
206 read,
207 code: (error && error.extensions && error.extensions.code) || 'GRAPHQL_ERROR' ,
208 message: String ((error && error.message) || 'GraphQL error' ). slice ( 0 , 500 ),
209 }));
210 }
211
212 // A read Shopify throttled on all its calls is a failed read of that entity: a
213 // capture-failed row, never an empty entity. Every other CLI failure stops the step.
214 async function readOrThrottled ( execute , query , variables = null ) {
215 try {
216 return await execute (query, variables);
217 } catch (error) {
218 if (error instanceof ShopifyCliError && error.code === 'shopify_throttled' ) {
219 return { data: null , errors: [{ message: 'Shopify throttled this read on every call (31 s of backoff)' , extensions: { code: 'shopify_throttled' } }] };
220 }
221 throw error;
222 }
223 }
224
225 async function captureEntity ( read , { execute : rawExecute , sampleLimit , store }) {
226 const execute = ( query , variables ) => readOrThrottled (rawExecute, query, variables);
227 const record = { entity: read.entity, store, scopes: read.scopes, reads: [], count: null , countPrecision: null , countExact: false , samples: [], sampleCount: 0 , errors: [] };
228 if (read.count) {
229 const name = operationName (read.count);
230 record.reads. push (name);
231 const result = await execute (read.count);
232 if (result.errors && result.errors. length ) {
233 record.errors. push ( ... errorEntries (name, result.errors));
234 } else {
235 const counted = at (result.data, read.countPath);
236 record.count = counted && Number. isInteger (counted.count) ? counted.count : null ;
237 record.countPrecision = counted && typeof counted.precision === 'string' ? counted.precision : null ;
238 // Only an EXACT answer is a record count. Anything else (AT_LEAST, a missing precision) is a
239 // lower bound at best: `count` keeps it, `countExact` says it is not the store's total.
240 record.countExact = record.count !== null && record.countPrecision === 'EXACT' ;
241 if (record.count === null ) record.errors. push ({ read: name, code: 'COUNT_MISSING' , message: 'the count read returned no count' });
242 }
243 }
244 const name = operationName (read.sample);
245 record.reads. push (name);
246 const result = await execute (read.sample, / \$ first \b / . test (read.sample) ? { first: sampleLimit } : null );
247 if (result.errors && result.errors. length ) {
248 record.errors. push ( ... errorEntries (name, result.errors));
249 } else {
250 const value = at (result.data, read.samplePath);
251 if (value === undefined || value === null ) {
252 record.errors. push ({ read: name, code: 'SAMPLE_MISSING' , message: 'the sample read returned nothing at its path' });
253 } else if (Array. isArray (value.nodes)) {
254 record.samples = value.nodes. slice ( 0 , sampleLimit);
255 } else if (Array. isArray (value)) {
256 record.samples = value. slice ( 0 , sampleLimit);
257 } else {
258 record.samples = [value];
259 }
260 record.sampleCount = record.samples. length ;
261 }
262 return record;
263 }
264
265 async function captureStep ( ctx ) {
266 const failed = [];
267 const entities = [];
268 for ( const read of ctx.reads) {
269 const rel = entityFile (read.entity);
270 const file = path. join (ctx.rawDir, rel);
271 let record = await readJson (file);
272 if (record && record.entity === read.entity && record.store !== ctx.store) {
273 throw storeMismatch (record.store, ctx.store, rel);
274 }
275 if ( ! record || record.entity !== read.entity) {
276 ctx. progress ( 'Reading Shopify entity' , { entity: read.entity });
277 record = { ... ( await captureEntity (read, ctx)), capturedAt: ctx. now () };
278 await writeJsonAtomic (file, record);
279 }
280 entities. push ({ entity: read.entity, count: record.count, countExact: record.countExact === true , sampleCount: record.sampleCount, file: rel });
281 if (record.errors. length ) {
282 failed. push ({ entity: read.entity, artifact: `${ RAW_DIR }/${ rel }` , reads: record.reads, errors: record.errors });
283 }
284 }
285 return { entities, failed };
286 }
287
288 // ---------------------------------------------------------------------------------------------
289 // Steps owned by the technical specs. Until a bundle ships a step's implementation, discovery
290 // stops at it: a run that skipped it would look complete while missing what it checks.
291 // ---------------------------------------------------------------------------------------------
292
293 function unavailableStep ( id , checks ) {
294 return {
295 id,
296 run : async () => {
297 throw new ShopifyDiscoveryStop (
298 'shopify_discovery_step_unavailable' ,
299 `Shopify discovery step "${ id }" (${ checks }) is not available in this version of the Shopify adapter, so Shopify private-data discovery cannot finish. Recorded steps are kept; re-run discovery once the step ships.` ,
300 { step: id },
301 );
302 },
303 };
304 }
305
306 const WITHHELD_DETAILS_STEP = Object. freeze ({ id: 'withheld-details' , run: protectedFields.runDiscoveryProbes });
307
308 // ---------------------------------------------------------------------------------------------
309 // Step 4 -- the order-history window
310 // ---------------------------------------------------------------------------------------------
311
312 // Through the CLI connector Shopify shows only the last ~60 days of orders, on every read path.
313 // Orders are complete only when the shop itself is younger than the window and no imported
314 // (back-dated) orders exist: the visible orders' dates are never used as proof.
315 const IMPORTED_ORDERS_KEY = 'imported-orders' ;
316 const ORDER_WINDOW_ROW_ID = 'shopify:orders-before-window' ;
317
318 function orderWindowRow ( evidence ) {
319 return {
320 coverageId: ORDER_WINDOW_ROW_ID ,
321 platform: 'shopify' ,
322 capability: 'commerce.orders' ,
323 sourceEntity: 'orders-before-window' ,
324 routes: [ 'orders' ],
325 reason: 'auth-gated' ,
326 evidence,
327 recoverability: 'requires-development' ,
328 disposition: null ,
329 };
330 }
331
332 function windowEvidencePointer ( record , answer ) {
333 const parts = [
334 `shopCreatedAt=${ record . shopCreatedAt || 'unreadable'}` ,
335 `windowStart=${ record . windowStart }` ,
336 'session-receipt.json grantedScopes lacks read_all_orders' ,
337 ];
338 if (record.backdatedOrder) parts. push ( `order processedAt ${ record . oldestProcessedAt } precedes shopCreatedAt` );
339 if (answer) parts. push ( `imported orders: ${ answer }` );
340 return parts. join ( '; ' );
341 }
342
343 async function orderWindowStep ( ctx ) {
344 const [ createdRead , oldestRead ] = ORDER_WINDOW_READS ;
345 const nowIso = ctx. now ();
346 const windowStart = new Date (Date. parse (nowIso) - ORDER_WINDOW_DAYS * 24 * 60 * 60 * 1000 ). toISOString ();
347 const record = { store: ctx.store, readAt: nowIso, windowDays: ORDER_WINDOW_DAYS , windowStart, shopCreatedAt: null , young: false , oldestProcessedAt: null , backdatedOrder: false , errors: [], rowRequired: true };
348 const created = await readOrThrottled (ctx.execute, createdRead.query);
349 const shopCreatedAt = created.data && created.data.shop && typeof created.data.shop.createdAt === 'string' ? created.data.shop.createdAt : null ;
350 if (created.errors && created.errors. length ) record.errors. push ( ... errorEntries ( operationName (createdRead.query), created.errors));
351 if (shopCreatedAt && Number. isFinite (Date. parse (shopCreatedAt))) {
352 record.shopCreatedAt = shopCreatedAt;
353 record.young = Date. parse (shopCreatedAt) > Date. parse (windowStart);
354 } else if ( ! record.errors. length ) {
355 record.errors. push ({ read: operationName (createdRead.query), code: 'CREATED_AT_MISSING' , message: 'shop.createdAt was not readable' });
356 }
357 if (record.young) {
358 const oldest = await readOrThrottled (ctx.execute, oldestRead.query);
359 if (oldest.errors && oldest.errors. length ) {
360 record.errors. push ( ... errorEntries ( operationName (oldestRead.query), oldest.errors));
361 } else {
362 const node = oldest.data && oldest.data.orders && Array. isArray (oldest.data.orders.nodes) ? oldest.data.orders.nodes[ 0 ] : null ;
363 record.oldestProcessedAt = node && typeof node.processedAt === 'string' ? node.processedAt : null ;
364 record.backdatedOrder = Boolean (record.oldestProcessedAt && Date. parse (record.oldestProcessedAt) < Date. parse (record.shopCreatedAt));
365 // A young store with no back-dated visible order: only the merchant can say whether orders
366 // were imported with dates Shopify keys the window on.
367 record.rowRequired = record.backdatedOrder;
368 }
369 }
370 await writeJsonAtomic (path. join (ctx.rawDir, ORDER_WINDOW_FILE ), record);
371 const result = { ... record, file: ORDER_WINDOW_FILE };
372 if (record.young && ! record.backdatedOrder && ! record.errors. length ) {
373 result.questions = [{
374 key: IMPORTED_ORDERS_KEY ,
375 title: 'Imported Shopify orders' ,
376 prompt: 'Were orders imported into this Shopify store from another system?' ,
377 response: { mode: 'choice' },
378 choices: [
379 { id: 'no' , label: 'No' },
380 { id: 'yes' , label: 'Yes' },
381 { id: 'unknown' , label: 'I don \' t know' },
382 ],
383 artifactRefs: [{ path: `${ RAW_DIR }/${ ORDER_WINDOW_FILE }` , mediaType: 'application/json' }],
384 }];
385 }
386 return result;
387 }
388
389 function orderWindowRows ( record , answers ) {
390 const asked = Array. isArray (record.questions) && record.questions. some (( question ) => question.key === IMPORTED_ORDERS_KEY );
391 if (asked) {
392 const answer = answers[ IMPORTED_ORDERS_KEY ];
393 const choice = answer && answer.answer ? answer.answer.choice : null ;
394 if (choice === 'no' ) return [];
395 // `yes`, `unknown`, or no recorded answer: fail closed.
396 return [ orderWindowRow ({ artifact: answer ? answer.evidence : `${ RAW_DIR }/${ ORDER_WINDOW_FILE }` , pointer: windowEvidencePointer (record, choice || 'unanswered' ) })];
397 }
398 if (record.rowRequired === false ) return [];
399 return [ orderWindowRow ({ artifact: `${ RAW_DIR }/${ ORDER_WINDOW_FILE }` , pointer: windowEvidencePointer (record, null ) })];
400 }
401
402 const ORDER_WINDOW_STEP = Object. freeze ({ id: 'order-window' , run: orderWindowStep, rows: orderWindowRows });
403
404 // Not a coverage row source: a regional value is a field of the store, and an unknown one is
405 // recorded in the regional file itself.
406 const REGIONAL_STEP = Object. freeze ({ id: 'regional' , run: regionalStep });
407
408 const GIFT_CARD_CENSUS_STEP = Object. freeze ({
409 id: 'gift-card-census' ,
410 run : ( ctx ) => giftCards. giftCardCensusStep (ctx, bulk),
411 rows : ( record ) => record.rows || [],
412 });
413
414 // ---------------------------------------------------------------------------------------------
415 // Step 5 -- questions to the merchant, raised by earlier steps as `record.questions[]`
416 // ---------------------------------------------------------------------------------------------
417
418 // Deterministic per discovery run and question, so a crash between creating the action and
419 // recording it never asks twice.
420 function questionRequestId ( startedAt , key ) {
421 const hex = crypto. createHash ( 'sha256' ). update ( `shopify-discovery \0 ${ startedAt } \0 ${ key }` ). digest ( 'hex' );
422 return `uar_${ hex . slice ( 0 , 8 ) }-${ hex . slice ( 8 , 12 ) }-${ hex . slice ( 12 , 16 ) }-${ hex . slice ( 16 , 20 ) }-${ hex . slice ( 20 , 32 ) }` ;
423 }
424
425 async function ensureQuestion ( ctx , question ) {
426 const requestId = questionRequestId (ctx.state.startedAt, question.key);
427 try {
428 return await readUserAction (ctx.projectDir, requestId);
429 } catch (error) {
430 if (error.code !== 'ENOENT' ) throw error;
431 }
432 return createUserAction (ctx.projectDir, {
433 requestId,
434 kind: 'question' ,
435 stage: 'discovery' ,
436 title: question.title,
437 prompt: question.prompt,
438 response: { mode: question.response && question.response.mode ? question.response.mode : 'choice' },
439 choices: question.choices || [],
440 artifactRefs: question.artifactRefs || [],
441 });
442 }
443
444 async function questionsStep ( ctx ) {
445 const questions = [];
446 for ( const [ stepId , entry ] of Object. entries (ctx.state.steps)) {
447 for ( const question of (entry.record && entry.record.questions) || []) questions. push ({ stepId, ... question });
448 }
449 const answers = {};
450 const pending = [];
451 for ( const question of questions) {
452 const action = await ensureQuestion (ctx, question);
453 if (action.status === 'answered' ) {
454 answers[question.key] = { stepId: question.stepId, requestId: action.requestId, answer: action.answer, evidence: `orchestration/user-actions/${ action . requestId }.json` };
455 } else {
456 pending. push (action.requestId);
457 }
458 }
459 if (pending. length ) return { awaiting: pending };
460 return { answers };
461 }
462
463 // ---------------------------------------------------------------------------------------------
464 // Step 6 -- the coverage file and the raw-capture index
465 // ---------------------------------------------------------------------------------------------
466
467 function countCell ( entry ) {
468 if (entry.count === null ) return '—' ;
469 return entry.countExact ? String (entry.count) : `≥ ${ entry . count } (not exact)` ;
470 }
471
472 function renderIndex ( capture , rows , generatedAt ) {
473 const lines = [
474 '# Shopify discovery capture' ,
475 '' ,
476 `Generated ${ generatedAt }. Raw evidence only: \` source-profile.md \` and \` source-schema.json \` are synthesized from it.` ,
477 '' ,
478 '| Entity | Count | Samples | File |' ,
479 '| --- | --- | --- | --- |' ,
480 ... capture.entities. map (( entry ) => `| ${ entry . entity } | ${ countCell ( entry ) } | ${ entry . sampleCount } | \` ${ entry . file } \` |` ),
481 '' ,
482 `Coverage rows: ${ rows . length } ( \` source-coverage.json \` ).` ,
483 ];
484 for ( const row of rows) lines. push ( `- \` ${ row . coverageId } \` — ${ row . reason }` );
485 return `${ lines . join ( ' \n ' ) } \n ` ;
486 }
487
488 async function coverageStep ( ctx ) {
489 const capture = ctx.state.steps.capture && ctx.state.steps.capture.record;
490 if ( ! capture) throw new ShopifyDiscoveryStop ( 'shopify_discovery_state_invalid' , 'the capture step is not recorded; re-run discovery' );
491 const answers = (ctx.state.steps.questions && ctx.state.steps.questions.record && ctx.state.steps.questions.record.answers) || {};
492 const stepRows = [];
493 for ( const step of ctx.steps) {
494 const entry = ctx.state.steps[step.id];
495 if ( ! entry || ! entry.record || step.id === 'capture' || step.id === 'coverage' ) continue ;
496 const rows = typeof step.rows === 'function' ? step. rows (entry.record, answers) : entry.record.rows;
497 if (Array. isArray (rows)) stepRows. push ( ... rows);
498 }
499 const generatedAt = ctx. now ();
500 const rows = buildShopifyCoverageRows ({ captureFailures: capture.failed, stepRows, rawDir: RAW_DIR });
501 const file = buildCoverageFile ({ platform: 'shopify' , adapter: 'shopify' , rows, generatedAt });
502 await writeJsonAtomic (path. join (ctx.rawDir, 'source-coverage.json' ), file);
503 await fs. writeFile (path. join (ctx.rawDir, 'README.md' ), renderIndex (capture, file.rows, generatedAt), 'utf8' );
504 return { rows: file.rows. map (( row ) => row.coverageId) };
505 }
506
507 // ---------------------------------------------------------------------------------------------
508 // The sequence
509 // ---------------------------------------------------------------------------------------------
510
511 const DEFAULT_STEPS = Object. freeze ([
512 { id: 'capture' , run: captureStep },
513 WITHHELD_DETAILS_STEP ,
514 GIFT_CARD_CENSUS_STEP ,
515 ORDER_WINDOW_STEP ,
516 REGIONAL_STEP ,
517 { id: 'questions' , run: questionsStep },
518 { id: 'coverage' , run: coverageStep },
519 ]);
520
521 // Forget what the named entities captured, so the next run reads exactly those again. The capture
522 // and coverage records are re-derived; every other recorded step is kept.
523 async function resetEntities ( projectDir , rawDir , entities , now , store ) {
524 if ( ! entities. length ) return ;
525 const known = new Set ( ALL_READS . map (( read ) => read.entity));
526 for ( const entity of entities) {
527 if ( ! known. has (entity)) throw new ShopifyDiscoveryStop ( 'shopify_entity_unknown' , `unknown Shopify entity ${ entity }; known: ${ [ ... known ]. join ( ', ' ) }` );
528 await fs. rm (path. join (rawDir, entityFile (entity)), { force: true });
529 }
530 const state = await readSteps (rawDir, now, store);
531 delete state.steps.capture;
532 delete state.steps.coverage;
533 delete state.steps.regional;
534 if ( await protectedFields. resetProbeEntities (projectDir, entities)) delete state.steps[ 'withheld-details' ];
535 await writeSteps (rawDir, state);
536 }
537
538 async function runDiscovery ({
539 projectDir ,
540 store ,
541 execute ,
542 steps = DEFAULT_STEPS ,
543 reads = ALL_READS ,
544 sampleLimit = SAMPLE_LIMIT ,
545 reread = [],
546 restart = false ,
547 now = () => new Date (). toISOString (),
548 progress = () => {},
549 sessionCheck = async () => ({ ok: true }),
550 } = {}) {
551 if ( ! projectDir) throw new Error ( 'projectDir is required' );
552 if ( typeof execute !== 'function' ) throw new Error ( 'execute is required' );
553 const boundStore = normalizeStore (store);
554 const rawDir = path. join (projectDir, RAW_DIR );
555 if (restart) await restartDiscovery (projectDir, rawDir, boundStore);
556 // Bind before anything is read or reset: a different store's data is refused here, and the
557 // store is recorded in the steps file before the first read of a new run.
558 await assertBoundToStore (rawDir, boundStore);
559 await fs. mkdir (path. join (rawDir, ENTITIES_DIR ), { recursive: true });
560 await resetEntities (projectDir, rawDir, reread, now, boundStore);
561 const state = await readSteps (rawDir, now, boundStore);
562 if ( ! state.steps || typeof state.steps !== 'object' ) state.steps = {};
563 await writeSteps (rawDir, state);
564
565 const ran = [];
566 for ( const step of steps) {
567 if (state.steps[step.id] && state.steps[step.id].status === 'done' ) continue ;
568 progress ( `Shopify discovery step ${ step . id }` , { step: step.id });
569 const ctx = { projectDir, rawDir, store: boundStore, execute, reads, sampleLimit, now, progress, state, steps, sessionCheck };
570 let record;
571 try {
572 record = await step. run (ctx);
573 } catch (error) {
574 // The connection lapsed: hold at a re-approval request. Recorded steps and
575 // captured entities are kept, so the resumed run reads only what is not yet captured.
576 if (error instanceof ShopifyCliError && error.code === 'source_auth_expired' ) {
577 const action = await recordAuthExpired (projectDir, { stage: 'discovery' , store: boundStore });
578 throw new ShopifyDiscoveryStop ( 'source_auth_expired' , `the Shopify connection for ${ boundStore } expired during discovery step ${ step . id }. Re-approval request ${ action . requestId } is pending: run the Shopify session check (with --connect if asked), then answer it and run discovery again. Finished reads are not repeated.` , { step: step.id, requestId: action.requestId });
579 }
580 throw error;
581 }
582 ran. push (step.id);
583 if (record && Array. isArray (record.awaiting)) {
584 return { status: 'awaiting_user_action' , requestIds: record.awaiting, stepsRun: ran };
585 }
586 state.steps[step.id] = { status: 'done' , completedAt: now (), record: record || {} };
587 await writeSteps (rawDir, state);
588 }
589 const coverage = state.steps.coverage && state.steps.coverage.record;
590 return { status: 'complete' , stepsRun: ran, rows: coverage ? coverage.rows : [] };
591 }
592
593 // ---------------------------------------------------------------------------------------------
594 // CLI
595 // ---------------------------------------------------------------------------------------------
596
597 function parseArgs ( argv ) {
598 const args = { project: null , store: null , reread: [], restart: false , confirmWithheldExport: null , help: false };
599 for ( let i = 0 ; i < argv. length ; i += 1 ) {
600 const arg = argv[i];
601 if (arg === '--help' || arg === '-h' ) args.help = true ;
602 else if (arg === '--project' ) { args.project = argv[i + 1 ]; i += 1 ; }
603 else if (arg === '--store' ) { args.store = argv[i + 1 ]; i += 1 ; }
604 else if (arg === '--reread' ) { args.reread. push (argv[i + 1 ]); i += 1 ; }
605 else if (arg === '--restart' ) args.restart = true ;
606 else if (arg === '--confirm-withheld-export' ) { args.confirmWithheldExport = argv[i + 1 ]; i += 1 ; }
607 else throw new Error ( `unknown argument ${ arg }` );
608 }
609 return args;
610 }
611
612 const USAGE = 'Usage: shopify-discovery.js --project <migrations-root>/<project> (--store <shop>.myshopify.com [--reread <entity> ...] [--restart] | --confirm-withheld-export owner-exported) [--progress-log <path>] \n ' ;
613
614 async function main () {
615 const parsed = parseProgressArgs (process.argv. slice ( 2 ));
616 const logger = createProgressLogger ({ script: 'skills/wix-replatform/resources/rp-source-shopify/scripts/shopify-discovery.js' , ... parsed.progress });
617 let args;
618 try {
619 args = parseArgs (parsed.args);
620 } catch (error) {
621 process.stderr. write ( `${ error . message } \n ${ USAGE }` );
622 return 1 ;
623 }
624 if (args.help) { process.stdout. write ( USAGE ); return 0 ; }
625 if ( ! args.project || ( ! args.store && ! args.confirmWithheldExport) || (args.store && args.confirmWithheldExport)) { process.stderr. write ( USAGE ); return 1 ; }
626 logger. start ( 'Shopify discovery started' , { phase: 'discovery' });
627 try {
628 if (args.confirmWithheldExport) {
629 try {
630 const confirmation = await protectedFields. confirmWithheldExport (path. resolve (args.project), args.confirmWithheldExport);
631 process.stdout. write ( `${ JSON . stringify ({ ok: true , status: 'confirmed' , file: protectedFields. WITHHELD_EXPORT_FILE , confirmation }) } \n ` );
632 logger. complete ( 'Shopify withheld-details export confirmed' , { phase: 'discovery' });
633 return 0 ;
634 } catch (error) {
635 throw new ShopifyDiscoveryStop ( 'shopify_withheld_export_confirmation_failed' , error.message);
636 }
637 }
638 const store = normalizeStore (args.store);
639 // Discovery starts only after a passing session check for this store, approved from a
640 // confirmed dedicated migration staff account.
641 await assertConnectionReady (path. resolve (args.project), store);
642 const execute = createStoreExecutor ({ store });
643 const result = await runDiscovery ({
644 projectDir: path. resolve (args.project),
645 store,
646 execute,
647 reread: args.reread,
648 restart: args.restart,
649 sessionCheck : async () => {
650 try {
651 await runSessionCheck ({ projectDir: path. resolve (args.project), store, askFirstConnection: false });
652 return { ok: true };
653 } catch (error) {
654 if (error instanceof ShopifySessionStop || error instanceof ShopifyCliError ) return { ok: false , error: { code: error.code, message: error.message } };
655 throw error;
656 }
657 },
658 progress : ( message , fields ) => logger. progress (message, { phase: 'discovery' , ... fields }),
659 });
660 process.stdout. write ( `${ JSON . stringify ({ ok: true , ... result }) } \n ` );
661 logger. complete ( `Shopify discovery ${ result . status }` , { phase: 'discovery' });
662 return result.status === 'complete' ? 0 : 3 ;
663 } catch (error) {
664 if (error instanceof ShopifyDiscoveryStop || error instanceof ShopifyCliError || error instanceof ShopifySessionStop ) {
665 process.stdout. write ( `${ JSON . stringify ({ ok: false , status: 'stopped' , code: error.code , message: error.message }) } \n ` );
666 logger. error (error.message, { phase: 'discovery' });
667 return 2 ;
668 }
669 throw error;
670 }
671 }
672
673 if (require.main === module ) {
674 main (). then (( code ) => { process.exitCode = code; }). catch (( error ) => {
675 process.stderr. write ( `${ error . stack || error . message } \n ` );
676 process.exitCode = 1 ;
677 });
678 }
679
680 module . exports = {
681 RAW_DIR,
682 STEPS_FILE,
683 DEFAULT_STEPS,
684 HANDOFF_FILES,
685 ShopifyDiscoveryStop,
686 captureEntity,
687 captureStep,
688 questionsStep,
689 coverageStep,
690 unavailableStep,
691 orderWindowStep,
692 orderWindowRows,
693 WITHHELD_DETAILS_STEP,
694 ORDER_WINDOW_STEP,
695 GIFT_CARD_CENSUS_STEP,
696 REGIONAL_STEP,
697 ORDER_WINDOW_FILE,
698 CONNECTION_FILES,
699 questionRequestId,
700 runDiscovery,
701 normalizeStore,
702 parseArgs,
703 };