Setting the file. One moment.
Import Task Runner · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page — line 117
This file
Number 45.88
Position 88 of 182
Type JavaScript
Size 18 KB
Lines 248 lib/ import-task-runner.js
JavaScript · 248 lines · 18 KB
14 // parent did not land (missing, failed, held, on a cycle) still imports, without the parent
15 // link, recorded as a finding and a cutover item. It is never held or refused for it.
16 // - An earlier attempt whose outcome is unknown (`started` with no terminal row, or
17 // `succeeded_unverified`) is looked up first and adopted when exactly one match is found.
18 // Otherwise it is created; when the lookup could not rule the earlier write out, the possible
19 // duplicate is recorded as a finding and a cutover item. It is never parked.
20 // - Every attempted record leaves a terminal journal row. A refusal before any byte left is a
21 // known non-write; an error after the request may have left is unresolved, never a failure, so
22 // a resume reads it back instead of creating a duplicate.
23
24 const crypto = require ( 'node:crypto' );
25 const fs = require ( 'node:fs' );
26 const path = require ( 'node:path' );
27 const readiness = require ( './dependency-readiness' );
28 const capability = require ( './execution-capability' );
29 const local = require ( './local-state' );
30 const { builderHold } = require ( './read-back-contract' );
31 const { readEntityMappings } = require ( './website-handoff' );
32 const { depthLevels } = require ( '../resources/rp-target-wix/lib/same-entity-hierarchy' );
33
34 const UNKNOWN_OUTCOME = new Set ([ 'started' , 'succeeded_unverified' ]);
35
36 function at ( record , dotted ) { return String (dotted). split ( '.' ). reduce (( value , key ) => value?.[key], record); }
37 function setAt ( body , dotted , value ) {
38 const keys = String (dotted). split ( '.' ), last = keys. pop ();
39 let cursor = body;
40 for ( const key of keys) cursor = cursor[key] && typeof cursor[key] === 'object' ? cursor[key] : (cursor[key] = {});
41 cursor[last] = value;
42 }
43 const noParent = value => value === undefined || value === null || value === '' || value === 0 || value === '0' ;
44
45 // Target stores whose records can be found again by what was sent. Each returns
46 // { targetId } when exactly one match exists, null only when none can exist, undefined when it
47 // cannot tell.
48 const RECONCILERS = {
49 'stores/category' : async ( recordClient , payload ) => {
50 const writers = require ( '../resources/rp-target-wix/lib/wix-writers' );
51 const want = payload?.category || payload || {};
52 const parent = want.parentCategory?.id || null ;
53 const named = ( await writers. queryAllStoresCategories (recordClient)). filter ( c => c.name === want.name);
54 const hits = named. filter ( c => (c.parentCategory?.id || null ) === parent);
55 if (hits. length === 1 ) return { targetId: hits[ 0 ].id };
56 // Absence is proven only when no category has this name: a same-name category under another
57 // parent may be this record written earlier without its link.
58 return named. length ? undefined : null ;
59 },
60 };
61
62 // The one same-task parent field of this task's entity, and the source path the accepted mapping
63 // feeds it from. No mapped source path means the records are written flat (no parent is sent).
64 // Two mapped source fields cannot be ordered: the records are written flat and each record with a
65 // parent value is recorded as unlinked (`ambiguous` lists the candidates).
66 function hierarchyOf ( task , binding , plan ) {
67 const fields = readiness. sameTaskReferenceFields (task.entityRefs). filter ( field => field.ref === binding.ref);
68 if ( ! fields. length ) return null ;
69 const row = readEntityMappings (plan || {}). find ( row => row.sourceEntity === binding.sourceEntity && (row.targetRef || binding.ref) === binding.ref);
70 const mapped = [];
71 for ( const field of fields) for ( const decision of row?.fieldMappings || []) for ( const output of decision.outputs || []) {
72 if (output.targetRef !== field.ref || ! [output.targetPath, [output.writeInput, output.targetPath]. filter (Boolean). join ( '.' )]. includes (field.template)) continue ;
73 const sourcePath = (output.sourcePaths || decision.sourcePaths || [])[ 0 ];
74 if (sourcePath) mapped. push ({ ... field, sourcePath });
75 }
76 const unique = [ ...new Map (mapped. map ( field => [ `${ field . template } \u0000 ${ field . sourcePath }` , field])). values ()];
77 if (unique. length > 1 ) return { ambiguous: unique };
78 return unique[ 0 ] || null ;
79 }
80
81 function readExtract ( projectDir , extractPath ) {
82 return fs. readFileSync (path. join (projectDir, extractPath), 'utf8' ). split ( / \r ? \n / ). filter (Boolean). map ( line => JSON . parse (line));
83 }
84
85 function failure ( key , operation , { errorCode , outcome , message , httpStatus = null , targetEntityId = null }) {
86 return { sourceId: key, targetEntityId, operation, httpStatus, errorCode, scopeGroupName: null , outcome, message };
87 }
88 // After a request may have left: a 4xx other than 408/429 was rejected and not applied; a 429 was
89 // not applied; anything else (5xx, timeout, reset) may have landed and is unresolved.
90 function sentOutcome ( error ) {
91 const status = error && (error.status || error.statusCode || error.response?.status);
92 if (Number. isInteger (status) && status >= 400 && status < 500 && status !== 408 ) return { status: status === 429 ? 'failed_retryable' : 'failed_terminal' , httpStatus: status };
93 return { status: 'succeeded_unverified' , httpStatus: Number. isInteger (status) ? status : null };
94 }
95
96 async function runRecordTask ({
97 taskBinding , wix , sourceEntity , records , sourceSystem , build , send , reconcile ,
98 operation , batchSize = 50 , stopRequested = () => false , selectionMode = 'full' , recordFilter = () => true ,
99 bindRecord , isInterruption = error => require ( './wix-auth' ). isRunInterruption (error),
100 } = {}) {
101 if ( typeof build !== 'function' || typeof send !== 'function' ) throw new Error ( 'runRecordTask requires build and send' );
102 if ( typeof sourceSystem !== 'string' || ! sourceSystem) throw new Error ( 'runRecordTask requires sourceSystem (the crosswalk source system)' );
103 if ( ! wix && typeof bindRecord !== 'function' ) throw new Error ( 'runRecordTask requires wix (the task client) or bindRecord' );
104 const ctx = capability. requireKind (taskBinding, 'import-task' );
105 const { projectDir , runId , siteId , task , taskId } = ctx, dryRun = ctx.dryRun === true ;
106 const bindings = (task.sourceBindings || []). filter ( binding => binding.role !== 'source-archive' && ( ! sourceEntity || binding.sourceEntity === sourceEntity));
107 if (bindings. length !== 1 ) throw new Error ( `runRecordTask: task ${ taskId } needs exactly one source binding${ sourceEntity ? ` for ${ sourceEntity }` : ''} (found ${ bindings . length }); pass sourceEntity` );
108 const [ binding ] = bindings, ref = binding.ref, keyField = binding.keyField || 'id' ;
109 const op = operation || ( typeof task.writeOperations?.[ref] === 'string' ? task.writeOperations[ref] : 'create' );
110 const keyOf = record => `${ binding . keyPrefix }${ at ( record , keyField ) }` ;
111 const bind = bindRecord || (( grant , key ) => require ( '../resources/rp-target-wix/lib/wix-writers' ). bindRecordClient (wix, { readinessGrant: grant, sourceStableKey: key }));
112 const findExisting = reconcile || RECONCILERS [ref];
113 const journalOptions = dryRun ? { sample: true } : {};
114 const row = ( key , status , extra = {}) => ({ schemaVersion: 1 , attemptId: crypto. randomUUID (), sourceStableKey: key, writeSpecId: taskId, operation: op, targetEntityType: ref, status, runId, phase: 'import' , ts: new Date (). toISOString (), siteId, ... extra });
115 const journal = value => local. appendAttempt (projectDir, value, journalOptions);
116 const counts = { attempted: 0 , imported: 0 , alreadyPresent: 0 , deferred: 0 , failed: 0 , unresolved: 0 };
117 const defer = async ( key , reason , extra = {}) => { counts.deferred += 1 ; await journal ( row (key, 'deferred' , { deferral: { reason, ... extra } })); };
118 const landed = async ( key , record , result , attemptId , findings ) => {
119 if ( ! dryRun) await local. appendCrosswalkRow (projectDir, { schemaVersion: 1 , sourceSystem, sourceEntityType: binding.sourceEntity, sourceId: String ( at (record, keyField)), sourceStableKey: key,
120 targetSystem: 'wix' , targetEntityType: ref, targetId: String (result.targetId), status: 'imported' , updatedAt: new Date (). toISOString (),
121 ... (result.revision !== undefined ? { targetRevision: String (result.revision) } : {}), ... (result.crosswalkExtra || {}) });
122 counts.imported += 1 ;
123 await journal ( row (key, 'imported' , { attemptId, targetId: String (result.targetId), findings }));
124 };
125
126 // A recorded loss on a landed record becomes a cutover item (completion report
127 // `dependency-reference-gap`), so it is never only a line in the journal.
128 const recordFindingGap = async ( key , finding , targetId ) => {
129 if (finding.code === 'same-task-parent-unlinked' ) await readiness. recordTaskGap ({ taskBinding, sourceStableKey: key, code: finding.reason,
130 action: `These ${ ref } records were imported without their parent link (${ finding . reason }). Once each parent exists on the site, set it as the record's parent (${ tree ?. template || 'the parent field'}), then confirm the hierarchy in Business Manager.` ,
131 detail: { targetId, ... (finding.parentSourceKey ? { parentSourceKey: finding.parentSourceKey } : {}) } });
132 if (finding.code === 'unknown-outcome-recreated' ) await readiness. recordTaskGap ({ taskBinding, sourceStableKey: key, code: 'possible-duplicate-after-unknown-outcome' ,
133 action: `An earlier create of each of these ${ ref } records had no recorded outcome and could not be ruled out, so it was created again. Look for another ${ ref } matching each source record on the site; if one exists, keep the listed targetId and delete the other.` ,
134 detail: { targetId, priorAttemptId: finding.priorAttemptId, lookup: finding.lookup } });
135 };
136 const all = records || readExtract (projectDir, binding.extractPath);
137 const mapped = hierarchyOf (task, binding, ctx.plan), tree = mapped && ! mapped.ambiguous ? mapped : null ;
138 const { levels , unplaceable } = tree ? depthLevels (all, { idOf : record => at (record, keyField), parentIdOf : record => at (record, tree.sourcePath) }) : { levels: [all], unplaceable: [] };
139 // Why a record is written without its parent link, decided before it is built.
140 const unlinked = new Map ();
141 for ( const record of unplaceable) unlinked. set ( keyOf (record), 'same-task-parent-cycle' );
142 if (mapped?.ambiguous) for ( const record of all) if (mapped.ambiguous. some ( field => ! noParent ( at (record, field.sourcePath)))) unlinked. set ( keyOf (record), 'same-task-parent-field-ambiguous' );
143 if (unplaceable. length ) levels. push (unplaceable);
144 const parentKeyOf = record => (tree && ! noParent ( at (record, tree.sourcePath)) ? `${ binding . keyPrefix }${ at ( record , tree . sourcePath ) }` : null );
145 const unlinkedFinding = ( key , record ) => unlinked. has (key) ? [{ code: 'same-task-parent-unlinked' , reason: unlinked. get (key), ... ( parentKeyOf (record) ? { parentSourceKey: parentKeyOf (record) } : {}) }] : [];
146
147 const latest = local. foldAttemptsBySourceKey (( await local. loadAttemptJournal (projectDir, journalOptions). catch ( error => { if (error.code === 'ENOENT' || error.code === 'JOURNAL_MISSING' ) return { rows: [] }; throw error; })).rows. filter ( r => r.writeSpecId === taskId));
148 let stopped = false ;
149 for ( const level of levels) {
150 for ( let offset = 0 ; offset < level. length && ! stopped; offset += batchSize) {
151 if ( stopRequested ()) { stopped = true ; break ; }
152 const crosswalk = dryRun ? { bySource: {} } : await local. loadCrosswalk (projectDir);
153 const batch = [];
154 for ( const record of level. slice (offset, offset + batchSize)) {
155 const key = keyOf (record);
156 if ( ! recordFilter (key)) continue ;
157 const existing = crosswalk.bySource[key];
158 if (existing && existing.status === 'imported' && ( ! existing.siteId || existing.siteId === siteId)) { counts.alreadyPresent += 1 ; await journal ( row (key, 'skipped_already_imported' , { targetId: existing.targetId })); continue ; }
159 batch. push (record);
160 }
161 if ( ! batch. length ) continue ;
162 // One evaluation per batch: the previous level's rows have just landed.
163 const evaluation = await readiness. evaluateTask ({ taskBinding });
164 if ( ! evaluation.ok) {
165 const reason = `dependency-not-ready:${ [ ...new Set ( evaluation . blocked . map ( b => b . ref ))]. sort (). join ( ',' ) }` . slice ( 0 , 200 );
166 for ( const record of batch) await defer ( keyOf (record), reason);
167 continue ;
168 }
169 const byKey = new Map (batch. map ( record => [ keyOf (record), record]));
170 const gateRecords = records => readiness. gateBatch ({ evaluation, records: records. map ( record => ({ sourceStableKey: keyOf (record), transform : async resolvers => {
171 const body = await build (record, resolvers);
172 const parentKey = parentKeyOf (record);
173 if ( builderHold (body) || ! parentKey || unlinked. has ( keyOf (record))) return body;
174 const parentId = resolvers. resolveSameTaskReference (ref, parentKey, tree.template);
175 if (parentId !== undefined ) setAt (body, tree.template, parentId);
176 return body;
177 } })) });
178 const first = await gateRecords (batch);
179 const sends = first.send. map ( item => ({ item, grant: first.grant }));
180 // A child whose parent has not landed (missing from the source, failed, held) is written
181 // without the link in the same batch. Its parent's level already ran, so waiting gains nothing.
182 const orphans = first.defer. filter ( row => row.deferral?.dependencyState === 'same-task-parent-not-written' );
183 let deferred = first.defer. length - orphans. length ;
184 if (orphans. length ) {
185 for ( const row of orphans) unlinked. set (row.sourceStableKey, 'same-task-parent-not-written' );
186 const second = await gateRecords (orphans. map ( row => byKey. get (row.sourceStableKey)));
187 sends. push ( ... second.send. map ( item => ({ item, grant: second.grant })));
188 deferred += second.defer. length ;
189 }
190 counts.deferred += deferred; // the gate journals its own deferrals
191 for ( const { item , grant } of sends) {
192 if ( stopRequested ()) { stopped = true ; break ; }
193 const key = item.sourceStableKey, record = byKey. get (key);
194 // Bind before any started row: a refused binding sends nothing and leaves no orphan row.
195 let recordClient;
196 try { recordClient = bind (grant, key); }
197 catch (error) { if (error?.code !== 'DEPENDENCY_READINESS_REQUIRED' ) throw error; await defer (key, 'readiness-grant-refused' ); continue ; }
198 const findings = unlinkedFinding (key, record);
199 const prior = latest. get (key);
200 if (prior && UNKNOWN_OUTCOME . has (prior.status) && ! dryRun) {
201 // Look it up; adopt exactly one match. Anything else creates: the record is never parked.
202 let found;
203 try { found = findExisting ? await findExisting (recordClient, item.payload, record) : undefined ; } catch (error) { if ( isInterruption (error)) throw error; found = undefined ; }
204 if (found && found.targetId) {
205 await landed (key, record, found, prior.attemptId, [ ... findings, { code: 'adopted-after-read-back' }]);
206 for ( const finding of findings) await recordFindingGap (key, finding, String (found.targetId));
207 continue ;
208 }
209 if (found === null ) {
210 // The read proved the earlier request never landed: close it, then create.
211 await journal ( row (key, 'failed_retryable' , { attemptId: prior.attemptId, requestSent: false , failure: failure (key, op, { errorCode: 'not-found-on-read-back' , outcome: 'failed_retryable' , message: 'read-back found no target; the earlier request did not land' }) }));
212 } else {
213 findings. push ({ code: 'unknown-outcome-recreated' , priorAttemptId: prior.attemptId, lookup: findExisting ? 'inconclusive' : 'no-reader' });
214 }
215 }
216 const started = await journal ( row (key, 'started' ));
217 counts.attempted += 1 ;
218 if (dryRun) { await defer (key, 'dry-run-would-send' ); continue ; }
219 try {
220 const result = await send (recordClient, item.payload, record);
221 if ( ! result || typeof result.targetId !== 'string' || ! result.targetId) {
222 counts.unresolved += 1 ;
223 await journal ( row (key, 'succeeded_unverified' , { attemptId: started.attemptId, failure: failure (key, op, { errorCode: 'no-target-id' , outcome: 'unresolved' , message: 'write returned no target id' }) }));
224 continue ;
225 }
226 await landed (key, record, result, started.attemptId, [ ... findings, ... (result.findings || [])]);
227 for ( const finding of findings) await recordFindingGap (key, finding, String (result.targetId));
228 } catch (error) {
229 if ( isInterruption (error)) throw error;
230 if (error && error.requestSent === false ) {
231 counts.failed += 1 ;
232 await journal ( row (key, 'failed_retryable' , { attemptId: started.attemptId, requestSent: false , failure: failure (key, op, { errorCode: error.code || 'refused-before-send' , outcome: 'failed_retryable' , message: 'refused before sending' }) }));
233 continue ;
234 }
235 const outcome = sentOutcome (error);
236 if (outcome.status === 'succeeded_unverified' ) counts.unresolved += 1 ; else counts.failed += 1 ;
237 await journal ( row (key, outcome.status, { attemptId: started.attemptId, failure: failure (key, op, { errorCode: error?.code ? String (error.code) : null , httpStatus: outcome.httpStatus, outcome: outcome.status === 'succeeded_unverified' ? 'unresolved' : outcome.status, message: 'write failed' }) }));
238 }
239 }
240 }
241 if (stopped) break ;
242 }
243 if ( ! stopped) await readiness. finishTaskPass ({ taskBinding, selection: { mode: selectionMode } });
244 const status = stopped ? 'stopped' : counts.failed || counts.unresolved || counts.deferred ? 'incomplete' : 'complete' ;
245 return { taskId, sourceEntity: binding.sourceEntity, status, counts };
246 }
247
248 module . exports = { runRecordTask, RECONCILERS, hierarchyOf };