Setting the file. One moment.
Blocked Data Requests · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page 133
async function readCurrentSnapshot
— line 133
This file
Number 47.15
Position 15 of 34
Type JavaScript
Size 19 KB
Lines 427 lib/ blocked-data-requests.js
JavaScript · 427 lines · 19 KB
=
new
Map
([
9 [ 'fulfilled' , 0 ],
10 [ 'warning' , 1 ],
11 [ 'deferred' , 2 ],
12 [ 'failed' , 3 ],
13 ]);
14
15 function sourceEntitySlug ( sourceEntityRef ) {
16 if ( ! sourceEntityRef || typeof sourceEntityRef !== 'string' ) throw new Error ( 'sourceEntityRef is required' );
17 return sourceEntityRef. replace ( / [ ^ A-Za-z0-9._-] + / g , '-' );
18 }
19
20 function checksum ( data ) {
21 return `sha256:${ crypto . createHash ( 'sha256' ). update ( JSON . stringify ( data )). digest ( 'hex' ) }` ;
22 }
23
24 function requestDirectory ( projectDir ) {
25 return path. join (projectDir, 'state' , 'blocked-data-requests' );
26 }
27
28 function requestPath ( projectDir , sourceEntityRef ) {
29 return path. join ( requestDirectory (projectDir), `${ sourceEntitySlug ( sourceEntityRef ) }.json` );
30 }
31
32 function snapshotPath ( projectDir , sourceEntityRef , version ) {
33 return path. join ( requestDirectory (projectDir), `${ sourceEntitySlug ( sourceEntityRef ) }.extraction.v${ version }.json` );
34 }
35
36 async function writeJsonAtomic ( filePath , value , { immutable = false } = {}) {
37 await fsp. mkdir (path. dirname (filePath), { recursive: true });
38 const tempPath = `${ filePath }.${ process . pid }.${ crypto . randomUUID () }.tmp` ;
39 let tempExists = false ;
40 try {
41 await fsp. writeFile (tempPath, `${ JSON . stringify ( value , null , 2 ) } \n ` , { encoding: 'utf8' , flag: 'wx' });
42 tempExists = true ;
43 if (immutable) {
44 try {
45 // link(2) is an atomic no-clobber publish: only one concurrent writer can create
46 // filePath, while the uniquely named same-directory temporary file keeps partial
47 // snapshot contents out of the version namespace.
48 await fsp. link (tempPath, filePath);
49 } catch (error) {
50 if (error.code === 'EEXIST' ) {
51 const collision = new Error ( `immutable snapshot already exists: ${ filePath }` );
52 collision.code = 'IMMUTABLE_SNAPSHOT_EXISTS' ;
53 throw collision;
54 }
55 throw error;
56 }
57 await fsp. unlink (tempPath);
58 tempExists = false ;
59 } else {
60 await fsp. rename (tempPath, filePath);
61 tempExists = false ;
62 }
63 } finally {
64 if (tempExists) {
65 try { await fsp. unlink (tempPath); }
66 catch (error) { if (error.code !== 'ENOENT' ) throw error; }
67 }
68 }
69 }
70
71 async function readJsonIfExists ( filePath ) {
72 try { return JSON . parse ( await fsp. readFile (filePath, 'utf8' )); }
73 catch (error) {
74 if (error.code === 'ENOENT' ) return null ;
75 throw error;
76 }
77 }
78
79 async function listSnapshotVersions ( projectDir , sourceEntityRef ) {
80 let names = [];
81 try { names = await fsp. readdir ( requestDirectory (projectDir)); }
82 catch (error) { if (error.code !== 'ENOENT' ) throw error; }
83 const prefix = `${ sourceEntitySlug ( sourceEntityRef ) }.extraction.v` ;
84 return names
85 . filter (( name ) => name. startsWith (prefix) && name. endsWith ( '.json' ))
86 . map (( name ) => Number (name. slice (prefix. length , - 5 )))
87 . filter (Number.isInteger)
88 . sort (( a , b ) => a - b);
89 }
90
91 function snapshotValidationErrors ( snapshot , { request , expectedVersion } = {}) {
92 if ( ! snapshot || typeof snapshot !== 'object' || Array. isArray (snapshot)) return [ 'snapshot must be a JSON object' ];
93 const errors = [];
94 if ( ! request || snapshot.requestId !== request.requestId) errors. push ( 'requestId does not match the blocked data request' );
95 if ( ! request || snapshot.sourceEntityRef !== request.sourceEntityRef) errors. push ( 'sourceEntityRef does not match the blocked data request' );
96 if ( ! Number. isInteger (snapshot.version) || snapshot.version < 1 ) errors. push ( 'version must be a positive integer' );
97 if (Number. isInteger (expectedVersion) && snapshot.version !== expectedVersion) errors. push ( 'version does not match the snapshot filename' );
98 if ( ! request || ! request.fulfillment || snapshot.handlerId !== request.fulfillment.handlerId) errors. push ( 'handlerId does not match the fulfillment request' );
99 if ( typeof snapshot.handlerVersion !== 'string' || snapshot.handlerVersion. length === 0 ) errors. push ( 'handlerVersion is required' );
100 const extractedTime = typeof snapshot.extractedAt === 'string' ? Date. parse (snapshot.extractedAt) : NaN ;
101 if ( ! Number. isFinite (extractedTime) || new Date (extractedTime). toISOString () !== snapshot.extractedAt) errors. push ( 'extractedAt must be a canonical ISO date-time' );
102 if ( ! Number. isInteger (snapshot.sourceCount) || snapshot.sourceCount < 0 ) errors. push ( 'sourceCount must be a non-negative integer' );
103 if ( ! Number. isInteger (snapshot.expectedTotal) || snapshot.expectedTotal < 0 ) errors. push ( 'expectedTotal must be a non-negative integer' );
104 if (snapshot.sourceCount !== snapshot.expectedTotal) errors. push ( 'sourceCount must equal expectedTotal' );
105 if (snapshot.reconciled !== true ) errors. push ( 'reconciled must be true' );
106 if ( ! snapshot.data || typeof snapshot.data !== 'object' || Array. isArray (snapshot.data)) {
107 errors. push ( 'data must be a JSON object' );
108 } else if (snapshot.checksum !== checksum (snapshot.data)) {
109 errors. push ( 'checksum does not match snapshot data' );
110 }
111 return errors;
112 }
113
114 function invalidSnapshotError ( filePath , errors , cause ) {
115 const error = new Error ( `invalid blocked data snapshot ${ filePath }: ${ errors . join ( '; ' ) }` );
116 error.code = 'INVALID_BLOCKED_DATA_SNAPSHOT' ;
117 error.snapshotPath = filePath;
118 if (cause) error.cause = cause;
119 return error;
120 }
121
122 async function readSnapshotVersion ( projectDir , request , version ) {
123 const filePath = snapshotPath (projectDir, request.sourceEntityRef, version);
124 let snapshot;
125 try { snapshot = await readJsonIfExists (filePath); }
126 catch (error) { throw invalidSnapshotError (filePath, [ 'snapshot is not valid JSON' ], error); }
127 if (snapshot === null ) return null ;
128 const errors = snapshotValidationErrors (snapshot, { request, expectedVersion: version });
129 if (errors. length > 0 ) throw invalidSnapshotError (filePath, errors);
130 return snapshot;
131 }
132
133 async function readCurrentSnapshot ( projectDir , request ) {
134 let version = Number. isInteger (request.currentSnapshotVersion) ? request.currentSnapshotVersion : null ;
135 if (version === null ) {
136 const versions = await listSnapshotVersions (projectDir, request.sourceEntityRef);
137 version = versions[versions. length - 1 ] || null ;
138 }
139 return version === null ? null : readSnapshotVersion (projectDir, request, version);
140 }
141
142 function dependenciesOf ( targetEntity ) {
143 return Array. isArray (targetEntity.blockedSourceDependencies) ? targetEntity.blockedSourceDependencies : [];
144 }
145
146 function resultReconciles ( result ) {
147 return Boolean (result && result.reconciled === true
148 && Number. isInteger (result.sourceCount) && result.sourceCount >= 0
149 && Number. isInteger (result.expectedTotal) && result.sourceCount === result.expectedTotal);
150 }
151
152 async function buildBlockedDataRequests ({ targetEntities = [], sourceEntitiesByRef = new Map (), existingRequests = [], readiness , askedInteractively = false , now = new Date () } = {}) {
153 const bySource = new Map ();
154 const previousBySource = new Map (existingRequests. map (( request ) => [request.sourceEntityRef, request]));
155 for ( const targetEntity of targetEntities) {
156 const targetRef = targetEntity.ref || `${ targetEntity . domain }/${ targetEntity . entity }` ;
157 for ( const dependency of dependenciesOf (targetEntity)) {
158 const previous = previousBySource. get (dependency.sourceEntityRef);
159 const existing = bySource. get (dependency.sourceEntityRef) || {
160 ... (previous ? JSON . parse ( JSON . stringify (previous)) : {}),
161 requestId: dependency.sourceEntityRef,
162 sourceEntityRef: dependency.sourceEntityRef,
163 dependentEntities: [],
164 fulfillment: null ,
165 fulfillmentOptions: [],
166 consequenceIfMissing: null ,
167 status: previous ? previous.status : 'offered' ,
168 askedInteractively: Boolean (askedInteractively || (previous && previous.askedInteractively)),
169 createdAt: previous && previous.createdAt ? previous.createdAt : new Date (now). toISOString (),
170 currentSnapshotVersion: previous ? previous.currentSnapshotVersion : null ,
171 stale: previous ? Boolean (previous.stale) : false ,
172 dependentOutcomes: [],
173 history: previous && Array. isArray (previous.history) ? previous.history : [{ status: 'offered' , at: new Date (now). toISOString () }],
174 };
175 const dependent = {
176 targetEntity: targetRef,
177 degradedField: dependency.degradedField,
178 pitfallCode: dependency.pitfallCode,
179 };
180 if ( ! existing.dependentEntities. some (( item ) => item.targetEntity === targetRef && item.degradedField === dependency.degradedField)) {
181 const previousOutcome = (previous && previous.dependentOutcomes || []). find (( item ) => (
182 item.targetEntity === dependent.targetEntity
183 && item.degradedField === dependent.degradedField
184 && item.pitfallCode === dependent.pitfallCode
185 ));
186 existing.dependentEntities. push (dependent);
187 existing.dependentOutcomes. push ({
188 ... (previousOutcome || {}),
189 ... dependent,
190 recordOutcome: previousOutcome ? previousOutcome.recordOutcome : null ,
191 });
192 }
193 const pitfall = (targetEntity.pitfalls || []). find (( item ) => item.code === dependency.pitfallCode);
194 if ( ! existing.consequenceIfMissing && pitfall) existing.consequenceIfMissing = pitfall.summary;
195 bySource. set (dependency.sourceEntityRef, existing);
196 }
197 }
198
199 const readinessCache = new Map ();
200 for ( const request of bySource. values ()) {
201 const sourceEntity = sourceEntitiesByRef instanceof Map
202 ? sourceEntitiesByRef. get (request.sourceEntityRef)
203 : sourceEntitiesByRef[request.sourceEntityRef];
204 const blockers = sourceEntity ? [
205 ... (sourceEntity.blocked || []),
206 ... (sourceEntity.pitfalls || []). flatMap (( pitfall ) => pitfall.blocked || []),
207 ] : [];
208 for ( const blocker of blockers) {
209 if ( ! blocker.fulfillment) continue ;
210 const candidate = { freshnessWindowHours: 24 , ... blocker.fulfillment };
211 const cacheKey = JSON . stringify (candidate);
212 let result = readinessCache. get (cacheKey);
213 if ( ! result) {
214 result = readiness ? await readiness (candidate) : { ready: false , reason: 'readiness-not-provided' };
215 readinessCache. set (cacheKey, result);
216 }
217 if (result.ready) {
218 request.fulfillmentOptions. push ({ ... candidate });
219 if ( ! request.fulfillment) request.fulfillment = candidate;
220 } else if ( ! request.fulfillment) {
221 request.fulfillmentReadiness = result;
222 }
223 }
224 }
225 return [ ... bySource. values ()]. sort (( a , b ) => a.sourceEntityRef. localeCompare (b.sourceEntityRef));
226 }
227
228 function renderBlockedDataRequests ( requests ) {
229 const lines = [ '# Blocked data requests' , '' ];
230 if ( ! requests. length ) return `${ lines . join ( ' \n ' ) }No target fields depend on blocked source data. \n ` ;
231 for ( const request of requests) {
232 lines. push ( `## ${ request . sourceEntityRef }` , '' );
233 lines. push (request.fulfillmentOptions. length
234 ? `Available fulfillment: ${ request . fulfillmentOptions . map (( item ) => item . kind ). join ( ', ' ) }.`
235 : 'No built and production-ready fulfillment option is available yet; the import will use the target entity’s existing default.' );
236 if (request.consequenceIfMissing) lines. push ( '' , `Consequence: ${ request . consequenceIfMissing }` );
237 lines. push ( '' , 'Affected fields:' );
238 for ( const dependent of request.dependentEntities) lines. push ( `- ${ dependent . targetEntity }.${ dependent . degradedField } (${ dependent . pitfallCode })` );
239 lines. push ( '' );
240 }
241 return `${ lines . join ( ' \n ' ). trim () } \n ` ;
242 }
243
244 async function writeBlockedDataRequestArtifacts ( projectDir , requests ) {
245 if ( ! projectDir) throw new Error ( 'projectDir is required' );
246 for ( const request of requests) await writeJsonAtomic ( requestPath (projectDir, request.sourceEntityRef), request);
247 const reviewPath = path. join (projectDir, 'mapping' , 'review' , 'blocked-data-requests.md' );
248 await fsp. mkdir (path. dirname (reviewPath), { recursive: true });
249 await fsp. writeFile (reviewPath, renderBlockedDataRequests (requests), 'utf8' );
250 return { requestPaths: requests. map (( request ) => requestPath (projectDir, request.sourceEntityRef)), reviewPath };
251 }
252
253 async function attemptFulfillment ( request , { handlerRegistry , namespaceProbe , handlerContext = {} } = {}) {
254 const fulfillment = request.fulfillment;
255 if ( ! fulfillment || ! request.fulfillmentOptions || request.fulfillmentOptions. length === 0 ) return { status: 'missing' };
256 const handler = handlerRegistry && handlerRegistry[fulfillment.handlerId];
257 if ( ! handler) return { status: 'invalid' , error: 'handler-not-registered' };
258
259 if (fulfillment.kind === 'csv-upload' ) {
260 const inputPath = path. resolve (handlerContext.projectDir || '.' , fulfillment.expectedInputPath);
261 if ( ! fs. existsSync (inputPath)) return { status: 'missing' };
262 try {
263 const result = await handler. parse ({ inputPath, ... handlerContext });
264 return resultReconciles (result) ? { status: 'fulfilled' , result, handler } : { status: 'invalid' , error: 'input-did-not-reconcile' };
265 } catch (error) { return { status: 'invalid' , error: error.message }; }
266 }
267
268 if (fulfillment.kind === 'bridge-plugin' ) {
269 if ( typeof namespaceProbe !== 'function' ) return { status: 'missing' };
270 let namespacePresent = false ;
271 try {
272 const probe = await namespaceProbe (fulfillment.expectedNamespace);
273 namespacePresent = Array. isArray (probe) ? probe. includes (fulfillment.expectedNamespace) : probe === true ;
274 } catch (error) { return { status: 'missing' , error: error.message }; }
275 if ( ! namespacePresent) return { status: 'missing' };
276 try {
277 const result = await handler. extract ({ ... handlerContext, extractionRoute: fulfillment.extractionRoute });
278 return resultReconciles (result) ? { status: 'fulfilled' , result, handler } : { status: 'invalid' , error: 'route-did-not-reconcile' };
279 } catch (error) { return { status: 'invalid' , error: error.message }; }
280 }
281 return { status: 'invalid' , error: `unsupported fulfillment kind ${ fulfillment . kind }` };
282 }
283
284 function snapshotData ( result ) {
285 const { sourceCount , expectedTotal , reconciled , ... data } = result;
286 return data;
287 }
288
289 async function persistSnapshot ( projectDir , request , result , handler , now , knownSnapshotVersion = 0 ) {
290 const data = snapshotData (result);
291 let ignoredThroughVersion = knownSnapshotVersion;
292 for ( let attempt = 0 ; attempt < 10 ; attempt += 1 ) {
293 const versions = await listSnapshotVersions (projectDir, request.sourceEntityRef);
294 const latestVersion = versions[versions. length - 1 ] || 0 ;
295 if (latestVersion > ignoredThroughVersion) {
296 try {
297 const winner = await readSnapshotVersion (projectDir, request, latestVersion);
298 if (winner) return winner;
299 } catch (error) {
300 if (error.code !== 'INVALID_BLOCKED_DATA_SNAPSHOT' ) throw error;
301 }
302 // Never reuse a missing or invalid collision winner. Move past it and publish a
303 // new immutable version on the next iteration instead.
304 ignoredThroughVersion = latestVersion;
305 continue ;
306 }
307
308 const version = latestVersion + 1 ;
309 const snapshot = {
310 requestId: request.requestId,
311 sourceEntityRef: request.sourceEntityRef,
312 version,
313 extractedAt: new Date (now). toISOString (),
314 handlerId: request.fulfillment.handlerId,
315 handlerVersion: handler.version || 'unknown' ,
316 sourceCount: result.sourceCount,
317 expectedTotal: result.expectedTotal,
318 reconciled: true ,
319 checksum: checksum (data),
320 data,
321 };
322 try {
323 await writeJsonAtomic ( snapshotPath (projectDir, request.sourceEntityRef, version), snapshot, { immutable: true });
324 return snapshot;
325 } catch (error) {
326 if (error.code !== 'IMMUTABLE_SNAPSHOT_EXISTS' ) throw error;
327 // Another resolver won this version. Loop, validate its complete snapshot, and
328 // coalesce onto it instead of failing or manufacturing redundant versions.
329 }
330 }
331 throw new Error ( `could not publish or reuse a concurrent snapshot for ${ request . sourceEntityRef }` );
332 }
333
334 function applySnapshot ( request , snapshot , stale ) {
335 request.status = 'fulfilled' ;
336 request.stale = Boolean (stale);
337 request.currentSnapshotVersion = snapshot.version;
338 request.snapshot = {
339 version: snapshot.version,
340 extractedAt: snapshot.extractedAt,
341 checksum: snapshot.checksum,
342 };
343 request.fulfilledData = snapshot.data;
344 }
345
346 async function resolveBlockedDataRequest ({ projectDir , request , handlerRegistry , namespaceProbe , handlerContext = {}, now = new Date (), refresh = false } = {}) {
347 if ( ! projectDir) throw new Error ( 'projectDir is required' );
348 const resolved = JSON . parse ( JSON . stringify (request));
349 delete resolved.lastError;
350 delete resolved.refreshError;
351 delete resolved.fulfillmentErrorCode;
352 delete resolved.fulfilledData;
353 delete resolved.snapshot;
354 const observedVersions = await listSnapshotVersions (projectDir, resolved.sourceEntityRef);
355 const knownSnapshotVersion = observedVersions[observedVersions. length - 1 ] || 0 ;
356 let snapshot = null ;
357 let snapshotError = null ;
358 try { snapshot = await readCurrentSnapshot (projectDir, resolved); }
359 catch (error) {
360 if (error.code !== 'INVALID_BLOCKED_DATA_SNAPSHOT' ) throw error;
361 snapshotError = error;
362 }
363 const configuredFreshnessHours = resolved.fulfillment ? Number (resolved.fulfillment.freshnessWindowHours) : NaN ;
364 const freshnessHours = Number. isFinite (configuredFreshnessHours) ? configuredFreshnessHours : 24 ;
365 const fresh = snapshot && ( new Date (now). getTime () - new Date (snapshot.extractedAt). getTime ()) < freshnessHours * 3600000 ;
366 if (snapshot && fresh && ! refresh) {
367 applySnapshot (resolved, snapshot, false );
368 } else {
369 const attempt = await attemptFulfillment (resolved, {
370 handlerRegistry,
371 namespaceProbe,
372 handlerContext: { projectDir, ... handlerContext },
373 });
374 if (attempt.error) resolved.fulfillmentErrorCode = attempt.error;
375 if (attempt.status === 'fulfilled' ) {
376 const written = await persistSnapshot (projectDir, resolved, attempt.result, attempt.handler, now, knownSnapshotVersion);
377 applySnapshot (resolved, written, false );
378 } else if (snapshot) {
379 applySnapshot (resolved, snapshot, true );
380 resolved.refreshError = attempt.error || attempt.status;
381 } else if (snapshotError) {
382 resolved.status = 'invalid' ;
383 resolved.stale = false ;
384 resolved.lastError = `${ snapshotError . message }; refresh failed: ${ attempt . error || attempt . status }` ;
385 } else if (attempt.status === 'invalid' ) {
386 resolved.status = 'invalid' ;
387 resolved.stale = false ;
388 resolved.lastError = attempt.error;
389 } else if (resolved.status === 'declined' && resolved.askedInteractively === true ) {
390 resolved.status = 'declined' ;
391 resolved.stale = false ;
392 } else {
393 resolved.status = 'missing' ;
394 resolved.stale = false ;
395 if (attempt.error) resolved.lastError = attempt.error;
396 }
397 }
398 resolved.history = [ ... (resolved.history || []), { status: resolved.status, stale: resolved.stale, at: new Date (now). toISOString () }];
399 const persisted = { ... resolved };
400 delete persisted.fulfilledData;
401 await writeJsonAtomic ( requestPath (projectDir, resolved.sourceEntityRef), persisted);
402 return resolved;
403 }
404
405 function aggregateOutcome ( dependentOutcomes = []) {
406 return dependentOutcomes. reduce (( worst , item ) => {
407 const candidate = item.recordOutcome;
408 if ( ! OUTCOME_RANK . has (candidate)) return worst;
409 return ! worst || OUTCOME_RANK . get (candidate) > OUTCOME_RANK . get (worst) ? candidate : worst;
410 }, null );
411 }
412
413 module . exports = {
414 OUTCOME_RANK,
415 aggregateOutcome,
416 buildBlockedDataRequests,
417 checksum,
418 readCurrentSnapshot,
419 renderBlockedDataRequests,
420 requestPath,
421 resolveBlockedDataRequest,
422 snapshotPath,
423 sourceEntitySlug,
424 snapshotValidationErrors,
425 writeJsonAtomic,
426 writeBlockedDataRequestArtifacts,
427 };