Setting the file. One moment.
Extract Ledger · Wix Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page — line 117
This file
Number 45.82
Position 82 of 182
Type JavaScript
Size 22 KB
Lines 332 lib/ extract-ledger.js
JavaScript · 332 lines · 22 KB
14
// digest, and lost fields are recomputed from the shapes files rather than read from the row. The
15 // read-back inventory pins the chain head. The ledger is kept twice: under state/ and beside the
16 // extracts it describes (data/source-extract/.extract-ledger/), so deleting either copy is
17 // detected and repaired from the other.
18 //
19 // Never block: a changed, field-losing or unverifiable extract is re-extracted from the source
20 // (`src/extract/run-extract.js --entity <entity>`), the new version is recorded, and what happened
21 // -- old and new version, fields still lost -- is written to execution/extract-integrity.json for
22 // the run report. Only an extract that cannot be re-read from the source is refused, for that
23 // entity alone, with its next step.
24
25 const fs = require ( 'node:fs' );
26 const path = require ( 'node:path' );
27 const crypto = require ( 'node:crypto' );
28 const { spawn } = require ( 'node:child_process' );
29 const { readExtract } = require ( './read-back-extract' );
30
31 const LEDGER = 'state/source-extract-ledger.ndjson' ;
32 const SHAPES_DIR = 'state/source-extract-ledger' ;
33 const MIRROR = 'data/source-extract/.extract-ledger/ledger.ndjson' ;
34 const MIRROR_SHAPES_DIR = 'data/source-extract/.extract-ledger/shapes' ;
35 const REPORT = 'execution/extract-integrity.json' ;
36 const EXTRACTOR = 'src/extract/run-extract.js' ;
37 const SCHEMA_VERSION = 2 ;
38 // Source fields that change whenever the source changes a record. A record whose marker differs
39 // between two versions was edited at the source, so a field it no longer has is not a strip.
40 const CHANGE_MARKERS = [ 'date_modified_gmt' , 'date_modified' , 'updated_at' , 'updatedAt' , 'modified_gmt' , 'modified' ];
41
42 const sha = value => crypto. createHash ( 'sha256' ). update (value). digest ( 'hex' );
43 function canonical ( value ) {
44 if (Array. isArray (value)) return '[' + value. map (canonical). join ( ',' ) + ']' ;
45 if (value && typeof value === 'object' ) return '{' + Object. keys (value). sort (). map ( key => JSON . stringify (key) + ':' + canonical (value[key])). join ( ',' ) + '}' ;
46 return JSON . stringify (value);
47 }
48 function fail ( code ) { return Object. assign ( new Error (code), { code }); }
49 function inside ( projectDir , file ) {
50 const root = path. resolve (projectDir), absolute = path. resolve (root, typeof file === 'string' ? file : '' );
51 if ( typeof file !== 'string' || ! file || ! absolute. startsWith (root + path.sep)) throw fail ( 'extract_path_outside_project' );
52 return absolute;
53 }
54 const relative = ( projectDir , file ) => path. relative (path. resolve (projectDir), inside (projectDir, file)). split (path.sep). join ( '/' );
55 const valueAt = ( record , dotted ) => dotted. split ( '.' ). reduce (( node , key ) => (node && typeof node === 'object' ? node[key] : undefined ), record);
56
57 // Field names a record carries: object keys as dotted paths, arrays as `[]`, and for an array of
58 // `{ key, value }` items (WordPress meta) each item's key as `[key="…"]`, so a dropped meta key
59 // is a lost field even though `meta_data[].value` remains.
60 function shapePaths ( record , out = new Set (), base = '' ) {
61 if (Array. isArray (record)) {
62 for ( const item of record) {
63 if (item && typeof item === 'object' && ! Array. isArray (item) && typeof item.key === 'string' ) out. add ( `${ base }[key=${ JSON . stringify ( item . key ) }]` );
64 shapePaths (item, out, `${ base }[]` );
65 }
66 } else if (record && typeof record === 'object' ) {
67 for ( const [ key , value ] of Object. entries (record)) {
68 const next = base ? `${ base }.${ key }` : key;
69 out. add (next);
70 shapePaths (value, out, next);
71 }
72 }
73 return out;
74 }
75
76 async function fileDigest ( file ) {
77 const hash = crypto. createHash ( 'sha256' );
78 for await ( const chunk of fs. createReadStream (file)) hash. update (chunk);
79 return hash. digest ( 'hex' );
80 }
81
82 // One streamed pass: per record its field-name set (deduplicated by shape) and change marker.
83 // Parse failures surface as typed codes only, never source text.
84 async function measure ( projectDir , extractPath , { keyField = 'id' } = {}) {
85 const file = inside (projectDir, extractPath), shapes = [], shapeIndex = new Map (), records = {};
86 let rows = 0 ;
87 try {
88 for await ( const record of readExtract (file)) {
89 rows += 1 ;
90 const key = valueAt (record, keyField);
91 if (key === undefined || key === null || typeof key === 'object' || String (key) === '' ) throw fail ( 'extract_record_key_missing' );
92 if (Object. hasOwn (records, String (key))) throw fail ( 'extract_record_key_duplicate' );
93 const paths = [ ... shapePaths (record)]. sort (), id = canonical (paths);
94 if ( ! shapeIndex. has (id)) { shapeIndex. set (id, shapes. length ); shapes. push (paths); }
95 const marker = CHANGE_MARKERS . map ( name => record[name]). find ( value => typeof value === 'string' || typeof value === 'number' );
96 records[ String (key)] = marker === undefined ? [shapeIndex. get (id)] : [shapeIndex. get (id), String (marker)];
97 }
98 } catch (error) {
99 throw fail ( typeof error.code === 'string' && / ^ extract_/ . test (error.code) ? error.code : 'extract_unreadable' );
100 }
101 return { sha256: await fileDigest (file), rows, keyField, shapes, records };
102 }
103
104 function rowHash ( row ) { const { rowHash : _omit , ... rest } = row; return sha ( canonical (rest)); }
105 function readChain ( file ) {
106 if ( ! fs. existsSync (file)) return { rows: [], present: false , broken: false };
107 const rows = [];
108 let prev = 'genesis' , broken = false ;
109 for ( const line of fs. readFileSync (file, 'utf8' ). split ( ' \n ' ). filter (Boolean)) {
110 let row;
111 try { row = JSON . parse (line); } catch { broken = true ; break ; }
112 if (row.schemaVersion !== SCHEMA_VERSION || row.prev !== prev || row.rowHash !== rowHash (row) || row.seq !== rows. length + 1 ) { broken = true ; break ; }
113 rows. push (row); prev = row.rowHash;
114 }
115 return { rows, present: true , broken };
116 }
117 const isPrefix = ( short , long ) => short. every (( row , i ) => long[i]?.rowHash === row.rowHash);
118 // The longer intact copy is authoritative. A copy that is missing, broken, behind or divergent is
119 // reported (`issues`) and rewritten from it, so one deleted or edited copy never hides history.
120 function readLedger ( projectDir ) {
121 const copies = [ LEDGER , MIRROR ]. map ( rel => ({ rel, ... readChain (path. join (projectDir, rel)) }));
122 const [ main , mirror ] = copies;
123 const rows = main.rows. length >= mirror.rows. length ? main.rows : mirror.rows;
124 const issues = [];
125 for ( const copy of copies) {
126 if (copy.broken) issues. push ( `source_extract_ledger_broken:${ copy . rel }` );
127 else if ( ! copy.present && rows. length ) issues. push ( `source_extract_ledger_missing:${ copy . rel }` );
128 else if ( ! isPrefix (copy.rows, rows)) issues. push ( `source_extract_ledger_diverged:${ copy . rel }` );
129 }
130 return { rows, issues, errors: [] };
131 }
132 function writeLedger ( projectDir , rows ) {
133 for ( const rel of [ LEDGER , MIRROR ]) {
134 fs. mkdirSync (path. dirname (path. join (projectDir, rel)), { recursive: true });
135 fs. writeFileSync (path. join (projectDir, rel), rows. map ( row => JSON . stringify (row) + ' \n ' ). join ( '' ));
136 }
137 }
138 function entries ( projectDir , extractPath ) {
139 const wanted = extractPath === undefined ? null : relative (projectDir, extractPath);
140 return readLedger (projectDir).rows. filter ( row => wanted === null || row.extractPath === wanted);
141 }
142 const head = projectDir => readLedger (projectDir).rows. at ( - 1 )?.rowHash || null ;
143
144 const shapeSnapshotName = ( entity , extractPath , keyField , digest ) =>
145 `${ sha ( canonical ({ entity , extractPath , keyField })) }-${ digest }.json` ;
146 const mirrorShapes = row => `${ MIRROR_SHAPES_DIR }/${ path . basename ( row . shapesPath ) }` ;
147 function readShapes ( projectDir , row ) {
148 let changed = false ;
149 for ( const rel of [row.shapesPath, mirrorShapes (row)]) {
150 let text;
151 try { text = fs. readFileSync ( inside (projectDir, rel), 'utf8' ); } catch { continue ; }
152 if ( sha (text) === row.shapesSha256) return JSON . parse (text);
153 changed = true ;
154 }
155 throw fail (changed ? 'source_extract_shapes_changed' : 'source_extract_shapes_missing' );
156 }
157 // Fields a record carried in an earlier version and no longer carries, for every record present in
158 // both whose change marker did not move. Every earlier version counts, so a stripped version
159 // followed by any later re-extract stays lost. A record the source deleted or edited is not a loss.
160 function lostSince ( earlier , after ) {
161 const lost = new Map ();
162 for ( const [ key , [ shape , marker ]] of Object. entries (after.records)) {
163 const now = new Set (after.shapes[shape]), gone = new Set ();
164 for ( const before of earlier) {
165 const prior = before.records[key];
166 if ( ! prior || prior[ 1 ] !== marker) continue ;
167 for ( const p of before.shapes[prior[ 0 ]]) if ( ! now. has (p)) gone. add (p);
168 }
169 for ( const p of gone) lost. set (p, (lost. get (p) || 0 ) + 1 );
170 }
171 return [ ... lost. keys ()]. sort (). map ( p => ({ path: p, records: lost. get (p) }));
172 }
173
174 // Record the extract's current version, unless it is already the latest one. Both ledger copies
175 // are brought to the authoritative chain first.
176 async function recordExtract ( projectDir , { entity , extractPath , keyField = 'id' , recordedBy = 'toolkit' , replacesHead = null , now = new Date () }) {
177 if ( typeof entity !== 'string' || ! entity) throw fail ( 'extract_entity_required' );
178 const ledger = readLedger (projectDir);
179 const extract = relative (projectDir, extractPath), prior = ledger.rows. filter ( row => row.extractPath === extract);
180 const measured = await measure (projectDir, extract, { keyField });
181 const latest = prior. at ( - 1 );
182 if (ledger.issues. length ) writeLedger (projectDir, ledger.rows);
183 // A re-read from the source is always its own row, even when it matches: it settles the loss check.
184 if (latest?.sha256 === measured.sha256 && ! replacesHead && (recordedBy !== 're-extract' || latest.recordedBy === 're-extract' )) return latest;
185 const shapes = { sha256: measured.sha256, keyField, shapes: measured.shapes, records: measured.records }, shapesText = canonical (shapes);
186 const snapshotName = shapeSnapshotName (entity, extract, keyField, measured.sha256);
187 const shapesPath = `${ SHAPES_DIR }/${ snapshotName }` ;
188 for ( const rel of [shapesPath, `${ MIRROR_SHAPES_DIR }/${ snapshotName }` ]) {
189 fs. mkdirSync (path. dirname (path. join (projectDir, rel)), { recursive: true });
190 fs. writeFileSync (path. join (projectDir, rel), shapesText);
191 }
192 let earlier = [];
193 try { earlier = prior. map ( row => readShapes (projectDir, row)); } catch { /* unverifiable history: reported by extractErrors */ }
194 const row = { schemaVersion: SCHEMA_VERSION , seq: ledger.rows. length + 1 , prev: ledger.rows. at ( - 1 )?.rowHash || 'genesis' , entity, extractPath: extract, sha256: measured.sha256, rows: measured.rows, keyField,
195 shapesPath, shapesSha256: sha (shapesText), lostPaths: lostSince (earlier, shapes), version: prior. length + 1 , recordedBy, ... (replacesHead ? { replacesHead } : {}), recordedAt: now. toISOString () };
196 row.rowHash = rowHash (row);
197 writeLedger (projectDir, [ ... ledger.rows, row]);
198 return row;
199 }
200
201 // Errors for the extract as it is now. `ledgerHead` is the chain head an inventory pinned; the
202 // ledger must still contain it. Lost fields are recomputed from the pinned shapes files.
203 async function extractErrors ( projectDir , extractPath , { sha256 = null , ledgerHead = null } = {}) {
204 let extract;
205 try { extract = relative (projectDir, extractPath); } catch (error) { return [error.code]; }
206 const ledger = readLedger (projectDir);
207 if (ledger.issues. length ) return ledger.issues. map ( code => `${ code . split ( ':' )[ 0 ] }:${ extract }` );
208 // A re-extract that replaced a lost head satisfies it: the extract was re-read from the source.
209 if (ledgerHead && ! ledger.rows. some ( row => row.rowHash === ledgerHead || row.replacesHead === ledgerHead)) return [ `source_extract_ledger_head_missing:${ extract }` ];
210 const prior = ledger.rows. filter ( row => row.extractPath === extract), latest = prior. at ( - 1 );
211 if ( ! latest) return [ `source_extract_unrecorded:${ extract }` ];
212 let current;
213 try { current = await fileDigest ( inside (projectDir, extract)); } catch { return [ `source_extract_unreadable:${ extract }` ]; }
214 const errors = [];
215 if (current !== latest.sha256) errors. push ( `source_extract_changed_since_recorded:${ extract }` );
216 else if (sha256 && sha256 !== current) errors. push ( `source_extract_changed_since_inventory:${ extract }` );
217 try {
218 const lost = lostSince (prior. slice ( 0 , - 1 ). map ( row => readShapes (projectDir, row)), readShapes (projectDir, latest));
219 // Loss that remains after a re-read from the source is reported (extract-integrity.json), not retried.
220 if (lost. length && latest.recordedBy !== 're-extract' ) errors. push ( `source_extract_paths_lost:${ extract }:${ lost . length }` );
221 } catch (error) { errors. push ( `${ error . code || 'source_extract_shapes_missing'}:${ extract }` ); }
222 return errors;
223 }
224
225 // --- never block: repair, report, continue ----------------------------------------------------
226 function appendReport ( projectDir , event ) {
227 const file = path. join (projectDir, REPORT );
228 let report = { schemaVersion: 1 , events: [] };
229 try { report = JSON . parse (fs. readFileSync (file, 'utf8' )); } catch { /* first event */ }
230 report.events. push (event);
231 fs. mkdirSync (path. dirname (file), { recursive: true });
232 fs. writeFileSync (file + '.tmp' , JSON . stringify (report, null , 2 ) + ' \n ' );
233 fs. renameSync (file + '.tmp' , file);
234 }
235 // A hung source read cannot stall the task forever. Read per call so an operator can raise it.
236 const TIMEOUT_MS = 30 * 60 * 1000 ;
237 function runExtractor ( projectDir , entity , { timeoutMs = Number (process.env. RP_REEXTRACT_TIMEOUT_MS ) || TIMEOUT_MS } = {}) {
238 const script = path. join (projectDir, EXTRACTOR );
239 if ( ! fs. existsSync (script)) return Promise . resolve ( 'extractor_missing' );
240 return new Promise ( resolve => {
241 // Output is not kept: it may carry source values.
242 const child = spawn (process.execPath, [script, '--entity' , entity], { cwd: projectDir, stdio: 'ignore' });
243 const timer = setTimeout (() => { child. kill ( 'SIGKILL' ); resolve ( 'extractor_timeout' ); }, timeoutMs);
244 child. on ( 'error' , () => { clearTimeout (timer); resolve ( 'extractor_failed' ); });
245 child. on ( 'exit' , code => { clearTimeout (timer); resolve (code === 0 ? null : 'extractor_failed' ); });
246 });
247 }
248 // The suspect extract and its manifest (and any partial `.tmp`) are moved aside before the re-read,
249 // so an extractor that reuses a final file whose manifest matches cannot hand the same bytes back.
250 // On a failed re-read they are put back: the run keeps what it had, and only this entity stops.
251 function siblingManifest ( extract ) {
252 const dir = path.posix. dirname (extract), base = path.posix. basename (extract). replace ( / \. (ndjson | json) $ / , '' );
253 return `${ dir }/${ base }.manifest.json` ;
254 }
255 function moveAside ( projectDir , extract , manifestPath , tag ) {
256 const moved = [];
257 for ( const rel of [ ...new Set ([extract, `${ extract }.tmp` , manifestPath || siblingManifest (extract)])]) {
258 const file = inside (projectDir, rel);
259 if ( ! fs. existsSync (file)) continue ;
260 const aside = `${ file }.suspect-${ tag }` ;
261 fs. renameSync (file, aside);
262 moved. push ({ file, aside });
263 }
264 return moved;
265 }
266 function putBack ( moved ) {
267 for ( const { file , aside } of moved) { try { fs. rmSync (file, { force: true }); fs. renameSync (aside, file); } catch { /* left aside, named in the report */ } }
268 }
269 const version = row => (row ? { version: row.version, sha256: row.sha256, rows: row.rows } : null );
270 const RESTORE_ONLY = new Set ([ 'source_extract_ledger_missing' ]);
271
272 // Make the extract trustworthy without blocking: record a first-seen extract, restore a missing
273 // ledger copy, and re-extract from the source when the extract changed, lost fields or its
274 // history cannot be verified. Fields still lost after re-extraction are reported and the run
275 // continues. Throws only `source_extract_unreadable` (re-extraction failed), for this entity.
276 async function ensureExtract ( projectDir , { entity , extractPath , manifestPath = null , keyField = 'id' , ledgerHead = null , sha256 = null , recordedBy = 'toolkit' , now = () => new Date () }) {
277 const extract = relative (projectDir, extractPath);
278 const before = entries (projectDir, extract). at ( - 1 ) || null ;
279 let problems = await extractErrors (projectDir, extract, { ledgerHead, sha256 });
280 if (problems. length === 1 && problems[ 0 ]. startsWith ( 'source_extract_unrecorded:' )) {
281 await recordExtract (projectDir, { entity, extractPath: extract, keyField, recordedBy, now: now () });
282 problems = await extractErrors (projectDir, extract, { ledgerHead, sha256 });
283 }
284 if (problems. length && problems. every ( code => RESTORE_ONLY . has (code. split ( ':' )[ 0 ]))) {
285 writeLedger (projectDir, readLedger (projectDir).rows);
286 appendReport (projectDir, { at: now (). toISOString (), entity, extractPath: extract, trigger: problems. map ( code => code. split ( ':' )[ 0 ]), action: 'ledger-restored' , before: version (before), after: version ( entries (projectDir, extract). at ( - 1 )), lostPaths: [] });
287 problems = await extractErrors (projectDir, extract, { ledgerHead, sha256 });
288 }
289 // A changed extract is a new version; only lost fields or unverifiable history need the source.
290 if (problems. length === 1 && problems[ 0 ]. startsWith ( 'source_extract_changed_since_recorded:' )) {
291 await recordExtract (projectDir, { entity, extractPath: extract, keyField, recordedBy, now: now () });
292 problems = await extractErrors (projectDir, extract, { ledgerHead, sha256 });
293 }
294 if ( ! problems. length ) return { row: entries (projectDir, extract). at ( - 1 ), repaired: false };
295 const trigger = [ ...new Set (problems. map ( code => code. split ( ':' )[ 0 ]))];
296 const suspect = entries (projectDir, extract). at ( - 1 ) || before;
297 const moved = moveAside (projectDir, extract, manifestPath, (suspect?.sha256 || 'unrecorded' ). slice ( 0 , 12 ));
298 let failure = await runExtractor (projectDir, entity);
299 if ( ! failure && ! fs. existsSync ( inside (projectDir, extract))) failure = 'extractor_no_output' ;
300 if (failure) putBack (moved);
301 else for ( const { aside } of moved) fs. rmSync (aside, { force: true });
302 if (failure) {
303 appendReport (projectDir, { at: now (). toISOString (), entity, extractPath: extract, trigger, action: 'refused' , reason: failure, before: version (suspect), after: null , lostPaths: [], next: NEXT .source_extract_unreadable });
304 throw Object. assign ( fail ( 'source_extract_unreadable' ), { reason: failure });
305 }
306 const row = await recordExtract (projectDir, { entity, extractPath: extract, keyField, recordedBy: 're-extract' , replacesHead: trigger. includes ( 'source_extract_ledger_head_missing' ) ? ledgerHead : null , now: now () });
307 appendReport (projectDir, { at: now (). toISOString (), entity, extractPath: extract, trigger, action: 're-extracted' , before: version (suspect), after: version (row),
308 lostPaths: row.lostPaths || [], ... (row.lostPaths?. length ? { next: NEXT .source_extract_fields_still_lost } : {}) });
309 return { row, repaired: true };
310 }
311 async function reportEvents ( projectDir ) {
312 try { return JSON . parse ( await fs.promises. readFile (path. join (projectDir, REPORT ), 'utf8' )).events || []; } catch { return []; }
313 }
314
315 const NEXT = {
316 source_extract_unreadable: 'Requires Import Operator. Re-extraction from the source failed for this entity only; the rest of the import continues. Check source access, make src/extract/run-extract.js accept --entity <entity>, then rerun the import for this entity.' ,
317 source_extract_fields_still_lost: 'Requires Import Operator. The extract was re-read from the source and some unchanged records still lack fields an earlier extract had: the extractor drops them. Fix src/extract/run-extract.js to keep every source field, re-extract this entity and rerun its import.' ,
318 source_extract_unrecorded: 'Record the extract with `node scripts/extract-ledger.js record` right after writing it (the read-back inventory records it too).' ,
319 source_extract_changed_since_recorded: 'The extract file changed after the toolkit recorded it. Re-extract from the source and record it; never edit an extract in place.' ,
320 source_extract_changed_since_inventory: 'Rerun the read-back inventory on the current extract.' ,
321 source_extract_paths_lost: 'Records the source did not change lost fields since the previous extract. Re-extract from the source without dropping fields; a field with no target is kept by the mapping (extended field or CMS), never stripped.' ,
322 source_extract_ledger_broken: 'The extract ledger was edited. Restore it from the run; it is append-only toolkit state.' ,
323 source_extract_ledger_corrupt: 'The extract ledger was edited. Restore it from the run; it is append-only toolkit state.' ,
324 source_extract_ledger_missing: 'One ledger copy was deleted; it is restored from the other.' ,
325 source_extract_ledger_diverged: 'A ledger copy was edited; the extract is re-read from the source.' ,
326 source_extract_ledger_head_missing: 'The extract ledger no longer contains the version the read-back inventory recorded. Restore the ledger, or re-extract from the source and rerun the inventory.' ,
327 source_extract_shapes_missing: 'A recorded extract version lost its field record. Restore state/source-extract-ledger/ from the run.' ,
328 source_extract_shapes_changed: 'A recorded extract field record was edited. Restore state/source-extract-ledger/ from the run.' ,
329 };
330 const nextStep = code => NEXT [ String (code). split ( ':' )[ 0 ]] || null ;
331
332 module . exports = { TIMEOUT_MS, LEDGER, SHAPES_DIR, MIRROR, REPORT, EXTRACTOR, CHANGE_MARKERS, shapePaths, measure, entries, head, recordExtract, extractErrors, ensureExtract, reportEvents, nextStep, fileDigest : ( projectDir , file ) => fileDigest ( inside (projectDir, file)) };