Setting the file. One moment.
Shopify Extract · Rp Source Shopify · wix/skills · Skills Docs
ContentsBack to the top of the page
13 KB scripts/ shopify-extract.js
JavaScript · 287 lines · 13 KB
15 // --retry-failed Import Operator recovery: send every stopping dataset (each `failed` entry, each
16 // unattributed `denied` entry, and the datasets whose exact count check stopped
17 // the extract) back to `pending` and read it again. Nothing else is reset.
18 // --catch-up a source catch-up rerun: archive the `complete` manifest as
19 // extract-manifest.<T_prev>.json and read the whole store again with a new
20 // snapshot time. A manifest that is not complete is resumed instead.
21 //
22 // Output: data/source-extract/<entity>.ndjson, data/source-extract/extract-manifest.json.
23 // Exit codes: 0 complete; 3 waiting on a question (shopify_bulk_overdue: answer it, run again);
24 // 2 stopped (the printed code says why; finished datasets are kept); 1 usage error.
25
26 const fs = require ( 'node:fs/promises' );
27 const path = require ( 'node:path' );
28 const { createProgressLogger , parseProgressArgs } = require ( '../../../lib/progress-log.js' );
29 const { readEnvFile } = require ( '../../../lib/config-env.js' );
30 const { SHOPIFY_SOURCE_ENV } = require ( '../../../lib/source-access.js' );
31 const { createStoreExecutor , ShopifyCliError } = require ( '../lib/shopify-cli.js' );
32 const session = require ( '../lib/shopify-session.js' );
33 const bulk = require ( '../lib/shopify-bulk.js' );
34 const protectedFields = require ( '../lib/shopify-protected-fields.js' );
35 const giftCards = require ( '../lib/shopify-gift-cards.js' );
36
37 const USAGE = 'Usage: shopify-extract.js --project <migrations-root>/<project> [--store <shop>.myshopify.com] [--retry-failed] [--catch-up] [--progress-log <path>] \n ' ;
38
39 function parseArgs ( argv ) {
40 const args = { project: null , store: null , retryFailed: false , catchUp: false , help: false };
41 for ( let i = 0 ; i < argv. length ; i += 1 ) {
42 const arg = argv[i];
43 if (arg === '--help' || arg === '-h' ) args.help = true ;
44 else if (arg === '--project' ) { args.project = argv[i + 1 ]; i += 1 ; }
45 else if (arg === '--store' ) { args.store = argv[i + 1 ]; i += 1 ; }
46 else if (arg === '--retry-failed' ) args.retryFailed = true ;
47 else if (arg === '--catch-up' ) args.catchUp = true ;
48 else throw new Error ( `unknown argument ${ arg }` );
49 }
50 return args;
51 }
52
53 async function configuredDomain ( projectDir ) {
54 let values;
55 try {
56 values = await readEnvFile (path. join (projectDir, SHOPIFY_SOURCE_ENV ));
57 } catch (error) {
58 if (error.code === 'ENOENT' ) throw new session. ShopifySessionStop ( 'shopify_shop_domain_missing' , `${ SHOPIFY_SOURCE_ENV } is missing; record SHOPIFY_SHOP_DOMAIN=<shop>.myshopify.com there or pass --store.` );
59 throw error;
60 }
61 if ( ! values. SHOPIFY_SHOP_DOMAIN ) throw new session. ShopifySessionStop ( 'shopify_shop_domain_missing' , `SHOPIFY_SHOP_DOMAIN is missing from ${ SHOPIFY_SOURCE_ENV }.` );
62 return values. SHOPIFY_SHOP_DOMAIN ;
63 }
64
65 async function readJson ( file ) {
66 try {
67 return JSON . parse ( await fs. readFile (file, 'utf8' ));
68 } catch (error) {
69 if (error.code === 'ENOENT' ) return null ;
70 throw error;
71 }
72 }
73
74 // The previous complete manifest is kept as extract-manifest.<T_prev>.json (':' replaced by
75 // '-' so the name is valid on every platform), and the catch-up starts a fresh manifest.
76 async function archiveCompleteManifest ( manifestPath , knownPrevious = null ) {
77 const previous = knownPrevious || await readJson (manifestPath);
78 if ( ! previous || previous.status !== 'complete' ) return null ;
79 const stamp = String (previous.snapshotTime || 'unknown' ). replace ( /:/ g , '-' );
80 const archived = path. join (path. dirname (manifestPath), `extract-manifest.${ stamp }.json` );
81 try {
82 await fs. access (archived);
83 throw new session. ShopifySessionStop ( 'shopify_extract_archive_exists' , `${ path . basename ( archived ) } already exists; the previous extract was already archived. Nothing was changed.` );
84 } catch (error) {
85 if (error.code !== 'ENOENT' ) throw error;
86 }
87 await fs. rename (manifestPath, archived);
88 return archived;
89 }
90
91 // A crash can land after the complete manifest was renamed but before the new catch-up manifest
92 // was created. Recover its sidecar ownership from the newest archive so the replacement run still
93 // removes outputs that no longer exist; no archived record is ever modified.
94 async function latestArchivedManifest ( manifestPath ) {
95 const dir = path. dirname (manifestPath);
96 const prefix = `${ path . basename ( manifestPath , '.json' ) }.` ;
97 let names;
98 try {
99 names = await fs. readdir (dir);
100 } catch (error) {
101 if (error.code === 'ENOENT' ) return null ;
102 throw error;
103 }
104 const candidates = [];
105 for ( const name of names. filter (( candidate ) => candidate. startsWith (prefix) && candidate. endsWith ( '.json' ))) {
106 const file = path. join (dir, name);
107 const manifest = await readJson (file);
108 if ( ! manifest || manifest.status !== 'complete' ) continue ;
109 const time = Date. parse (manifest.snapshotTime || manifest.completedAt || '' );
110 candidates. push ({ file, manifest, time: Number. isFinite (time) ? time : - Infinity });
111 }
112 candidates. sort (( a , b ) => b.time - a.time || b.file. localeCompare (a.file));
113 return candidates[ 0 ] || null ;
114 }
115
116 // The denial path's session check: passes only when the connection is live and holds
117 // every required scope. A failure is data to the runner, not an exception.
118 function defaultSessionCheck ({ projectDir , store }) {
119 return async () => {
120 try {
121 await session. runSessionCheck ({ projectDir, store, askFirstConnection: false });
122 return { ok: true };
123 } catch (error) {
124 if (error instanceof session . ShopifySessionStop || error instanceof ShopifyCliError ) return { ok: false , error: { code: error.code, message: error.message } };
125 throw error;
126 }
127 };
128 }
129
130 async function runExtract ({
131 projectDir ,
132 store ,
133 execute ,
134 retryFailed = false ,
135 catchUp = false ,
136 now = () => Date. now (),
137 sessionCheck = defaultSessionCheck ({ projectDir, store }),
138 ... runnerOptions
139 } = {}) {
140 if ( ! projectDir) throw new Error ( 'projectDir is required' );
141 if ( typeof execute !== 'function' ) throw new Error ( 'execute is required' );
142 const normalizedStore = session. normalizeDomain (store);
143 const outputDir = path. join (projectDir, bulk. RAW_EXTRACT_DIR );
144 const manifestPath = path. join (outputDir, bulk. EXTRACT_MANIFEST_FILE );
145 const stagingDir = path. join (outputDir, 'staging' );
146 let existing = await readJson (manifestPath);
147 let archived = null ;
148 let previousSidecars = [];
149 let sameStore = ! existing;
150 if (existing && existing.store) {
151 try {
152 sameStore = session. normalizeDomain (existing.store) === normalizedStore;
153 } catch {
154 sameStore = false ;
155 }
156 }
157 // Store binding is checked before the only destructive catch-up action. A mismatch stays in
158 // place for the runner to report, so neither the manifest nor any final file is changed.
159 if (catchUp && sameStore && existing && existing.status === 'complete' ) {
160 previousSidecars = Array. isArray (existing.sidecars) ? existing.sidecars : [];
161 archived = await archiveCompleteManifest (manifestPath, existing);
162 existing = null ;
163 } else if (catchUp && ! existing) {
164 const recovered = await latestArchivedManifest (manifestPath);
165 if (recovered) {
166 let recoveredStore = null ;
167 try {
168 recoveredStore = session. normalizeDomain (recovered.manifest.store);
169 } catch {
170 recoveredStore = null ;
171 }
172 if (recoveredStore !== normalizedStore) {
173 return {
174 status: 'stopped' ,
175 code: 'shopify_extract_store_mismatch' ,
176 message: `${ path . basename ( recovered . file ) } was read from ${ recovered . manifest . store || 'an unrecorded store'}, not ${ normalizedStore }. Nothing was read or changed.` ,
177 details: { recorded: recovered.manifest.store || null , store: normalizedStore },
178 };
179 }
180 archived = recovered.file;
181 previousSidecars = Array. isArray (recovered.manifest.sidecars) ? recovered.manifest.sidecars : [];
182 }
183 }
184 giftCards. registerGiftCardHandlers (bulk);
185 const snapshotTime = existing && existing.snapshotTime ? existing.snapshotTime : new Date ( now ()). toISOString ();
186 const probeState = await protectedFields. readProbeState (projectDir);
187 const protectedOptions = protectedFields. createBulkOptions ({ projectDir, sessionCheck, probeState });
188 const project = protectedOptions.project;
189 // Texts are only used for a new manifest; a resumed one keeps what each query was last sent.
190 const queries = existing ? null : bulk. QUERY_NAMES . map (( name ) => ({ name, entity: bulk. entityOf (name), text: bulk. generateQueryText (name, { snapshotTime, project }) }));
191 // A resumed manifest may predate a catalog query: those are added as pending datasets.
192 const addQueries = existing ? bulk. QUERY_NAMES . filter (( name ) => ! (existing.queries && existing.queries[name])). map (( name ) => ({ name, entity: bulk. entityOf (name), text: bulk. generateQueryText (name, { snapshotTime, project }) })) : null ;
193 const suppliedHandlers = runnerOptions.handlers || {};
194 const handlers = {
195 ... protectedOptions.handlers,
196 ... suppliedHandlers,
197 failed: { ... (protectedOptions.handlers.failed || {}), ... (suppliedHandlers.failed || {}), byQuery: { ... ((protectedOptions.handlers.failed || {}).byQuery || {}), ... ((suppliedHandlers.failed || {}).byQuery || {}) } },
198 reassemble: { ... (protectedOptions.handlers.reassemble || {}), ... (suppliedHandlers.reassemble || {}) },
199 };
200 const { handlers : _ignoredHandlers , deniedReset : suppliedDeniedReset , ... remainingRunnerOptions } = runnerOptions;
201 const runner = bulk. createBulkRunner ({
202 projectDir,
203 store: normalizedStore,
204 caller: 'extract' ,
205 manifestPath,
206 stagingDir,
207 outputDir,
208 queries,
209 addQueries,
210 currentText: existing ? ( name ) => (bulk. QUERY_NAMES . includes (name) ? bulk. generateQueryText (name, { snapshotTime, project }) : null ) : null ,
211 snapshotTime,
212 execute,
213 retryFailed,
214 previousSidecars,
215 sessionCheck,
216 handlers,
217 deniedReset: suppliedDeniedReset || protectedOptions.deniedReset,
218 now,
219 ... remainingRunnerOptions,
220 });
221 const result = await runner. run ();
222 return { ... result, ... (archived ? { archived: path. relative (projectDir, archived) } : {}) };
223 }
224
225 function print ( value ) {
226 process.stdout. write ( `${ JSON . stringify ( value ) } \n ` );
227 }
228
229 async function main ( argv = process.argv. slice ( 2 )) {
230 const parsed = parseProgressArgs (argv);
231 const logger = createProgressLogger ({ script: 'skills/wix-replatform/resources/rp-source-shopify/scripts/shopify-extract.js' , ... parsed.progress });
232 let args;
233 try {
234 args = parseArgs (parsed.args);
235 } catch (error) {
236 process.stderr. write ( `${ error . message } \n ${ USAGE }` );
237 return 1 ;
238 }
239 if (args.help) { process.stdout. write ( USAGE ); return 0 ; }
240 if ( ! args.project) { process.stderr. write ( USAGE ); return 1 ; }
241 const projectDir = path. resolve (args.project);
242 logger. start ( 'Shopify extract started' , { phase: 'execution' });
243 try {
244 const store = session. normalizeDomain (args.store || await configuredDomain (projectDir));
245 // The extract starts only after a passing session check for this store, approved from a
246 // confirmed dedicated migration staff account.
247 await session. assertConnectionReady (projectDir, store);
248 const execute = createStoreExecutor ({ store });
249 const result = await runExtract ({
250 projectDir,
251 store,
252 execute,
253 retryFailed: args.retryFailed,
254 catchUp: args.catchUp,
255 progress : ( message , fields ) => logger. progress (message, { phase: 'execution' , ... fields }),
256 });
257 const ok = result.status === 'complete' ;
258 print ({
259 ok,
260 status: result.status,
261 ... (result.code ? { code: result.code, message: result.message } : {}),
262 ... (result.details && result.details.requestIds ? { requestIds: result.details.requestIds } : {}),
263 ... (result.archived ? { archived: result.archived } : {}),
264 ... (result.skipped ? { skipped: true } : {}),
265 manifest: `${ bulk . RAW_EXTRACT_DIR }/${ bulk . EXTRACT_MANIFEST_FILE }` ,
266 });
267 logger. complete ( `Shopify extract ${ result . status }` , { phase: 'execution' });
268 if (ok) return 0 ;
269 return result.status === 'awaiting_user_action' ? 3 : 2 ;
270 } catch (error) {
271 if (error instanceof session . ShopifySessionStop || error instanceof ShopifyCliError ) {
272 print ({ ok: false , status: 'stopped' , code: error.code, message: error.message });
273 logger. error (error.message, { phase: 'execution' });
274 return 2 ;
275 }
276 throw error;
277 }
278 }
279
280 if (require.main === module ) {
281 main (). then (( code ) => { process.exitCode = code; }). catch (( error ) => {
282 process.stderr. write ( `${ error . stack || error . message } \n ` );
283 process.exitCode = 1 ;
284 });
285 }
286
287 module . exports = { main, parseArgs, runExtract, archiveCompleteManifest, defaultSessionCheck };