Setting the file. One moment.
Shopify Nested Reads · Rp Source Shopify · wix/skills · Skills Docs
ContentsBack to the top of the page const take
— line 119
This file
Number 38.12
Position 12 of 51
Type JavaScript
Size 14 KB
Lines 306 lib/ shopify-nested-reads.js
JavaScript · 306 lines · 14 KB
// `reassemble` hook that, before the dataset is marked done, reads those connections with
14 // ordinary read-only queries (queries/read-*.graphql) and writes them into the same rows, so the
15 // final file has the shape the bulk query used to promise. Nothing here sends a mutation, and a
16 // read that fails stops the dataset. The reads are serial and paced (READ_GAP_MS between calls),
17 // so a large store takes hours (see `estimateReads`); they are therefore checkpointed: every
18 // finished chunk of rows is kept with a marker beside it, and a rerun over the same downloaded
19 // rows continues after the last finished chunk instead of reading everything again.
20
21 const crypto = require ( 'node:crypto' );
22 const fs = require ( 'node:fs' );
23 const fsp = require ( 'node:fs/promises' );
24 const path = require ( 'node:path' );
25 const readline = require ( 'node:readline' );
26 const { finished } = require ( 'node:stream/promises' );
27
28 const QUERIES_DIR = path. join (__dirname, '..' , 'queries' );
29 const CHUNK_ROWS = 50 ;
30 const READ_GAP_MS = 250 ;
31 const MAX_PAGES = 10000 ;
32
33 // What a full read costs, so it can be shown to whoever approves it: one call per `batch` ids plus
34 // READ_GAP_MS of pause after each, before network time and before extra pages.
35 function estimateReads ({ refunds = 0 , fulfillments = 0 } = {}) {
36 const calls = Math. ceil (refunds / READS .refund.batch) + Math. ceil (fulfillments / READS .fulfillment.batch);
37 return { calls, pauseSeconds: Math. round ((calls * READ_GAP_MS ) / 1000 ) };
38 }
39
40 const READS = Object. freeze ({
41 refund: { file: 'read-refund-items.graphql' , first: 10 , batch: 4 },
42 fulfillment: { file: 'read-fulfillment-items.graphql' , first: 25 , batch: 10 },
43 zone: { file: 'read-delivery-zones.graphql' , first: 25 , batch: 1 },
44 variantComponents: { file: 'read-variant-components.graphql' , first: 25 , batch: 10 },
45 });
46
47 const documents = new Map ();
48 function document ( kind ) {
49 if ( ! documents. has (kind)) documents. set (kind, fs. readFileSync (path. join ( QUERIES_DIR , READS [kind].file), 'utf8' ));
50 return documents. get (kind);
51 }
52
53 class NestedReadFailure extends Error {
54 constructor ( message , evidence = {}) {
55 super (message);
56 this .name = 'NestedReadFailure' ;
57 this .evidence = evidence;
58 }
59 }
60
61 function costExceeded ( errors ) {
62 return (errors || []). some (( error ) => error && error.extensions && error.extensions.code === 'MAX_COST_EXCEEDED' );
63 }
64
65 function failure ( what , errors ) {
66 const codes = [ ...new Set ((errors || []). map (( error ) => (error && error.extensions && error.extensions.code) || 'GRAPHQL_ERROR' ))];
67 const messages = (errors || []). map (( error ) => error && error.message). filter (Boolean). join ( '; ' ). slice ( 0 , 300 );
68 return new NestedReadFailure ( `the ${ what } read failed (${ codes . join ( ', ' ) })${ messages ? `: ${ messages }` : ''}` , { codes });
69 }
70
71 async function* rowsOf ( file ) {
72 const input = fs. createReadStream (file, { encoding: 'utf8' });
73 try {
74 for await ( const line of readline. createInterface ({ input, crlfDelay: Infinity })) if (line. length ) yield JSON . parse (line);
75 } finally {
76 input. destroy ();
77 }
78 }
79
80 async function digestOf ( file ) {
81 const hash = crypto. createHash ( 'sha256' );
82 for await ( const chunk of fs. createReadStream (file)) hash. update (chunk);
83 return hash. digest ( 'hex' );
84 }
85
86 function readMarker ( file ) {
87 try {
88 return JSON . parse (fs. readFileSync (file, 'utf8' ));
89 } catch {
90 return null ;
91 }
92 }
93
94 async function writeMarker ( file , value ) {
95 const temporary = `${ file }.next` ;
96 await fsp. writeFile (temporary, JSON . stringify (value));
97 await fsp. rename (temporary, file);
98 }
99
100 // One call with the cost fallback: a refused cost halves the requested page size (never below 1).
101 async function costAware ( execute , kind , variables , state ) {
102 for (;;) {
103 const result = await execute ( document (kind), { ... variables, first: state.first });
104 if (result.errors && result.errors. length ) {
105 if ( costExceeded (result.errors) && state.first > 1 ) { state.first = Math. max ( 1 , Math. floor (state.first / 2 )); continue ; }
106 throw failure (kind, result.errors);
107 }
108 return result.data;
109 }
110 }
111
112 // `nodes(ids:)` reads for Refund / Fulfillment ids. `connections` names each connection and the
113 // variable carrying its cursor. A connection that reports another page is read to the end for
114 // that one id, so no record is cut at the page size.
115 async function readNodeConnections ( ids , { kind , connections , execute , sleep }) {
116 const state = { first: READS [kind].first };
117 const out = new Map ();
118 const wanted = [ ...new Set (ids)];
119 const take = async ( batch ) => {
120 let data;
121 try {
122 data = await costAware (execute, kind, { ids: batch }, state);
123 } catch (error) {
124 if (error instanceof NestedReadFailure && batch. length > 1 && error.evidence.codes. includes ( 'MAX_COST_EXCEEDED' )) {
125 const half = Math. ceil (batch. length / 2 );
126 await take (batch. slice ( 0 , half));
127 await take (batch. slice (half));
128 return ;
129 }
130 throw error;
131 }
132 const nodes = Array. isArray (data && data.nodes) ? data.nodes : [];
133 for ( const [ index , id ] of batch. entries ()) {
134 const node = nodes[index];
135 if ( ! node || node.id !== id) throw new NestedReadFailure ( `a ${ kind } the bulk read listed could not be read by id` , { codes: [ 'NODE_MISSING' ] });
136 const record = {};
137 for ( const { field , after } of connections) {
138 const connection = node[field];
139 if ( ! connection || ! Array. isArray (connection.nodes)) throw new NestedReadFailure ( `the ${ kind } read returned no ${ field }` , { codes: [ 'CONNECTION_MISSING' ] });
140 const items = [ ... connection.nodes];
141 let info = connection.pageInfo || {};
142 for ( let page = 0 ; info.hasNextPage && info.endCursor; page += 1 ) {
143 if (page >= MAX_PAGES ) throw new NestedReadFailure ( `the ${ kind } ${ field } did not end after ${ MAX_PAGES } pages` , { codes: [ 'PAGES_EXCEEDED' ] });
144 await sleep ( READ_GAP_MS );
145 const more = await costAware (execute, kind, { ids: [id], [after]: info.endCursor }, state);
146 const again = more && Array. isArray (more.nodes) ? more.nodes[ 0 ] : null ;
147 if ( ! again || ! again[field] || ! Array. isArray (again[field].nodes)) throw new NestedReadFailure ( `the ${ kind } read returned no ${ field } page` , { codes: [ 'CONNECTION_MISSING' ] });
148 items. push ( ... again[field].nodes);
149 info = again[field].pageInfo || {};
150 }
151 record[field] = items;
152 }
153 out. set (id, record);
154 }
155 };
156 for ( let i = 0 ; i < wanted. length ; i += READS [kind].batch) {
157 if (i > 0 ) await sleep ( READ_GAP_MS );
158 await take (wanted. slice (i, i + READS [kind].batch));
159 }
160 return out;
161 }
162
163 const NODE_DATASETS = Object. freeze ({
164 'orders-refunds' : { list: 'refunds' , kind: 'refund' , connections: [{ field: 'refundLineItems' , after: 'itemsAfter' }, { field: 'transactions' , after: 'transactionsAfter' }] },
165 'orders-fulfillments' : { list: 'fulfillments' , kind: 'fulfillment' , connections: [{ field: 'fulfillmentLineItems' , after: 'itemsAfter' }] },
166 products: { list: 'variants' , kind: 'variantComponents' , when : ( variant ) => variant.requiresComponents === true , connections: [{ field: 'productVariantComponents' , after: 'componentsAfter' }] },
167 });
168
169 const wanted = ( spec , element ) => element && typeof element.id === 'string' && ( ! spec.when || spec. when (element));
170
171 async function enrichRows ( spec , rows , context ) {
172 const elements = rows. flatMap (( row ) => (Array. isArray (row[spec.list]) ? row[spec.list] : [])). filter (( element ) => wanted (spec, element));
173 if ( ! elements. length ) return ;
174 const read = await readNodeConnections (elements. map (( element ) => element.id), { kind: spec.kind, connections: spec.connections, execute: context.execute, sleep: context.sleep });
175 for ( const element of elements) Object. assign (element, read. get (element.id));
176 }
177
178 // One zone at a time (`locationGroupZones(first: 1)`), its rates paged by cursor, so the requested
179 // cost stays small however many zones or rates a profile has.
180 async function readGroupZones ( profileId , groupId , { execute , sleep }) {
181 const state = { first: READS .zone.first };
182 const zones = [];
183 let zoneAfter = null ;
184 for ( let page = 0 ; ; page += 1 ) {
185 if (page >= MAX_PAGES ) throw new NestedReadFailure ( `the delivery zones did not end after ${ MAX_PAGES } pages` , { codes: [ 'PAGES_EXCEEDED' ] });
186 let methodsAfter = null ;
187 let zone = null ;
188 let zoneInfo = {};
189 for ( let methodPage = 0 ; ; methodPage += 1 ) {
190 if (page > 0 || methodPage > 0 ) await sleep ( READ_GAP_MS );
191 const data = await costAware (execute, 'zone' , { id: profileId, group: groupId, zoneAfter, methodsAfter }, state);
192 const profile = data && data.deliveryProfile;
193 const group = profile && Array. isArray (profile.profileLocationGroups) ? profile.profileLocationGroups[ 0 ] : null ;
194 const connection = group && group.locationGroupZones;
195 if ( ! connection || ! Array. isArray (connection.nodes)) throw new NestedReadFailure ( 'the delivery profile or its location group could not be read by id' , { codes: [ 'NODE_MISSING' ] });
196 zoneInfo = connection.pageInfo || {};
197 const node = connection.nodes[ 0 ];
198 if ( ! node) return zones;
199 const methods = node.methodDefinitions;
200 if ( ! methods || ! Array. isArray (methods.nodes)) throw new NestedReadFailure ( 'the delivery zone read returned no methodDefinitions' , { codes: [ 'CONNECTION_MISSING' ] });
201 if ( ! zone) zone = { ... node, methodDefinitions: [] };
202 zone.methodDefinitions. push ( ... methods.nodes);
203 const info = methods.pageInfo || {};
204 if ( ! info.hasNextPage || ! info.endCursor) break ;
205 methodsAfter = info.endCursor;
206 }
207 zones. push (zone);
208 if ( ! zoneInfo.hasNextPage || ! zoneInfo.endCursor) return zones;
209 zoneAfter = zoneInfo.endCursor;
210 }
211 }
212
213 async function enrichProfile ( row , context ) {
214 for ( const group of Array. isArray (row.profileLocationGroups) ? row.profileLocationGroups : []) {
215 const groupId = group && group.locationGroup && group.locationGroup.id;
216 if ( typeof groupId !== 'string' ) continue ;
217 group.locationGroupZones = await readGroupZones (row.id, groupId, context);
218 }
219 }
220
221 function needsRead ( name , row ) {
222 if (name === 'delivery-profiles' ) return (Array. isArray (row.profileLocationGroups) ? row.profileLocationGroups : []). some (( group ) => group && group.locationGroup && group.locationGroup.id);
223 const spec = NODE_DATASETS [name];
224 return (Array. isArray (row[spec.list]) ? row[spec.list] : []). some (( element ) => wanted (spec, element));
225 }
226
227 function createNestedReadHook ( name ) {
228 return async function nestedReadHook ( context ) {
229 // Nothing to read (no refund, fulfillment or profile): the folded file is already final.
230 let any = false ;
231 for await ( const row of rowsOf (context.recordsFile)) {
232 if ( needsRead (name, row)) { any = true ; break ; }
233 }
234 if ( ! any) return undefined ;
235 // Neither name ends in .tmp or .partial, which every start deletes as a leftover.
236 const dir = path. dirname (context.recordsFile);
237 const output = path. join (dir, `${ name }.nested-progress.ndjson` );
238 const markerFile = path. join (dir, `${ name }.nested-progress.json` );
239 const sourceDigest = await digestOf (context.recordsFile);
240 // Resume only over the same downloaded rows: the marker names them by digest, so a re-read
241 // dataset (a different file) starts again, and so does a missing or short progress file.
242 const marker = readMarker (markerFile);
243 let skip = 0 ;
244 let written = 0 ;
245 if (marker && marker.version === 1 && marker.sourceDigest === sourceDigest && Number. isInteger (marker.rows) && Number. isInteger (marker.bytes)
246 && fs. existsSync (output) && fs. statSync (output).size >= marker.bytes) {
247 skip = marker.rows;
248 written = marker.bytes;
249 await fsp. truncate (output, written);
250 } else {
251 await fsp. rm (output, { force: true });
252 await fsp. rm (markerFile, { force: true });
253 }
254 const out = fs. createWriteStream (output, { encoding: 'utf8' , flags: skip ? 'a' : 'w' });
255 let buffer = [];
256 let rowsDone = skip;
257 const write = ( text ) => new Promise (( resolve , reject ) => out. write (text, ( error ) => (error ? reject (error) : resolve ())));
258 const flush = async () => {
259 if ( ! buffer. length ) return ;
260 if (name === 'delivery-profiles' ) for ( const row of buffer) await enrichProfile (row, context);
261 else await enrichRows ( NODE_DATASETS [name], buffer, context);
262 const text = buffer. map (( row ) => `${ JSON . stringify ( row ) } \n ` ). join ( '' );
263 await write (text);
264 written += Buffer. byteLength (text);
265 rowsDone += buffer. length ;
266 buffer = [];
267 await writeMarker (markerFile, { version: 1 , sourceDigest, rows: rowsDone, bytes: written });
268 };
269 try {
270 let index = 0 ;
271 for await ( const row of rowsOf (context.recordsFile)) {
272 if (index ++ < skip) continue ;
273 buffer. push (row);
274 if (buffer. length >= CHUNK_ROWS ) await flush ();
275 }
276 await flush ();
277 out. end ();
278 await finished (out);
279 } catch (error) {
280 // The finished chunks and their marker stay: a rerun over the same rows continues from them.
281 out. destroy ();
282 if (error instanceof NestedReadFailure ) {
283 return { ok: false , errorCode: 'shopify_nested_read_failed' , message: error.message, evidence: { ... error.evidence, rowsRead: rowsDone } };
284 }
285 throw error;
286 }
287 await fsp. rm (markerFile, { force: true });
288 return { recordsFile: output };
289 };
290 }
291
292 const NESTED_READ_QUERIES = Object. freeze ([ 'orders-refunds' , 'orders-fulfillments' , 'delivery-profiles' , 'products' ]);
293
294 function nestedReadHook ( name ) {
295 return NESTED_READ_QUERIES . includes (name) ? createNestedReadHook (name) : null ;
296 }
297
298 module . exports = {
299 NESTED_READ_QUERIES,
300 READS,
301 nestedReadHook,
302 estimateReads,
303 readNodeConnections,
304 readGroupZones,
305 NestedReadFailure,
306 };