Setting the file. One moment.
Blog Post Build Ledger · Rp Target Wix · wix/skills · Skills Docs
ContentsBack to the top of the page 41.10
Multilingual Restore
function fromDraftResponse
— line 100
This file
Number 41.125
Position 125 of 174
Type JavaScript
Size 15 KB
Lines 306 lib/ blog-post-build-ledger.js
JavaScript · 306 lines · 15 KB
'blog-post-build.ndjson'
;
10 const LOCK = 'blog-post-build.lock' ;
11 const SNAPSHOT_DIR = 'blog-containment' ;
12 const PHASES = new Set ([
13 'media-pending' , 'creating' , 'drafted' , 'slug-set' , 'links-final' , 'held' ,
14 'snapshot-saved' , 'publishing' , 'published' , 'adopted-published' , 'verified' ,
15 'verify-failed' , 'containing' , 'contain-deleted' , 'contain-recreating' ,
16 'contain-drafted' , 'contain-linked' , 'contained' , 'contain-failed' ,
17 ]);
18 const CONTAINMENT = new Set ([
19 'containing' , 'contain-deleted' , 'contain-recreating' , 'contain-drafted' ,
20 'contain-linked' , 'contained' , 'contain-failed' ,
21 ]);
22 const DUPLICATE_EVENTS = new Set ([
23 'detected' , 'provenance' , 'ownership-confirmed' , 'delete-chosen' ,
24 'removed' , 'kept' , 'not-importer' , 'unresolved' ,
25 ]);
26 const SNAPSHOT_KINDS = new Set ([ 'link-set' , 'leftover-set' , 'dates' , 'verify' ]);
27 const DESIRED_EVENTS = new Set ([ 'captured' , 'update-intent' , 'update-applied' ]);
28 const CLOSED_DUPLICATES = new Set ([ 'removed' , 'kept' , 'not-importer' ]);
29 const ROW_CACHE = new Map ();
30 const SNAPSHOT_METRICS = new Map ();
31 const RUN_IDS = new Map ();
32
33 function stateDir ( projectDir ) {
34 if ( ! projectDir) throw new Error ( 'blog build needs projectDir' );
35 return path. join (projectDir, 'state' , 'crosswalk' );
36 }
37 function ledgerPath ( projectDir ) { return path. join ( stateDir (projectDir), LEDGER ); }
38 function sha ( value ) { return crypto. createHash ( 'sha256' ). update ( typeof value === 'string' ? value : canonical (value)). digest ( 'hex' ); }
39 function canonical ( value ) {
40 if (Array. isArray (value)) return `[${ value . map ( canonical ). join ( ',' ) }]` ;
41 if (value && typeof value === 'object' ) return `{${ Object . keys ( value ). filter ( k => value [ k ] !== undefined ). sort (). map ( k => `${ JSON . stringify ( k ) }:${ canonical ( value [ k ]) }` ). join ( ',' ) }}` ;
42 return JSON . stringify (value);
43 }
44 function normalizeRich ( value , isNode = false ) {
45 if (Array. isArray (value)) return value. map ( item => normalizeRich (item, false ));
46 if ( ! value || typeof value !== 'object' ) return value;
47 return Object. fromEntries (Object. entries (value)
48 . filter (([ key ]) => ! (isNode && key === 'id' ) &&
49 ! [ 'createdDate' , 'updatedDate' , 'editedDate' , 'metadata' , 'documentStyle' ]. includes (key))
50 . map (([ key , item ]) => [key, key === 'nodes' && Array. isArray (item)
51 ? item. map ( node => normalizeRich (node, true )) : normalizeRich (item, false )]));
52 }
53 function normalizeSeoData ( value ) {
54 if ( ! value || typeof value !== 'object' ) return value;
55 const copy = { ... value };
56 if (Array. isArray (copy.tags) && copy.tags. length === 0 ) delete copy.tags;
57 return Object. keys (copy). length ? copy : undefined ;
58 }
59 function normalizeMedia ( value ) {
60 if ( ! value || typeof value !== 'object' ) return value;
61 const copy = structuredClone (value);
62 for ( const image of [copy.wixMedia?.image, copy.heroImage]) {
63 if ( ! image || typeof image !== 'object' ) continue ;
64 for ( const field of [ 'url' , 'width' , 'height' , 'filename' ]) delete image[field];
65 }
66 return copy;
67 }
68 function bodyProjection ( post ) {
69 const src = post && (post.draftPost || post.post || post);
70 if ( ! src || typeof src !== 'object' ) throw new Error ( 'blog body must be an object' );
71 return {
72 title: src.title,
73 richContent: normalizeRich (src.richContent),
74 media: normalizeMedia (src.media),
75 heroImage: normalizeMedia ({ heroImage: src.heroImage }).heroImage,
76 memberId: src.memberId,
77 firstPublishedDate: src.firstPublishedDate,
78 excerpt: src.excerpt,
79 categoryIds: [ ... (src.categoryIds || [])]. sort (),
80 tagIds: [ ... (src.tagIds || [])]. sort (),
81 seoData: normalizeSeoData (src.seoData),
82 };
83 }
84 function bodySha ( post ) { return sha ( bodyProjection (post)); }
85 function fingerprint ( post ) {
86 const src = post && (post.draftPost || post.post || post);
87 return sha ({ title: src.title, firstPublishedDate: src.firstPublishedDate,
88 memberId: src.memberId, excerpt: src.excerpt,
89 coverId: src.media?.wixMedia?.image?.id || src.heroImage?.id || '' });
90 }
91 function validateDesiredPost ( post ) {
92 const missing = [];
93 for ( const field of [ 'title' , 'memberId' , 'richContent' ]) {
94 if (post?.[field] === undefined || post?.[field] === null || post[field] === '' ) missing. push (field);
95 }
96 if ( ! post?.richContent || ! Array. isArray (post.richContent.nodes)) missing. push ( 'richContent.nodes' );
97 if (missing. length ) throw new Error ( `desired-post-invalid: missing ${ missing . join ( ', ' ) }` );
98 return post;
99 }
100 function fromDraftResponse ( response ) {
101 const draft = response?.draftPost || response;
102 if ( ! draft || typeof draft !== 'object' ) throw new Error ( 'draft response is missing' );
103 const fields = [ 'title' , 'richContent' , 'media' , 'memberId' , 'firstPublishedDate' ,
104 'excerpt' , 'categoryIds' , 'tagIds' , 'seoData' , 'heroImage' ];
105 const post = Object. fromEntries (fields. filter ( key => Object. hasOwn (draft, key)). map ( key => [key, draft[key]]));
106 if (draft.slug) post.seoSlug = draft.slug;
107 return validateDesiredPost (post);
108 }
109 function readRows ( projectDir ) {
110 const file = ledgerPath (projectDir);
111 if ( ! fs. existsSync (file)) return [];
112 const stat = fs. statSync (file, { bigint: true });
113 const cached = ROW_CACHE . get (file);
114 if (cached && cached.size === stat.size && cached.mtimeNs === stat.mtimeNs) return cached.rows;
115 const content = fs. readFileSync (file, 'utf8' );
116 const lines = content. split ( ' \n ' );
117 const rows = [];
118 const complete = content. endsWith ( ' \n ' ) ? lines. length - 1 : lines. length ;
119 for ( let i = 0 ; i < complete; i += 1 ) {
120 if ( ! lines[i]) throw new Error ( `blog ledger line ${ i + 1 }: empty line` );
121 let row;
122 try { row = JSON . parse (lines[i]); }
123 catch (error) {
124 if (i === complete - 1 && ! content. endsWith ( ' \n ' )) break ;
125 throw new Error ( `blog ledger line ${ i + 1 }: invalid JSON: ${ error . message }` );
126 }
127 if (row.seq !== rows. length + 1 ) throw new Error ( `blog ledger line ${ i + 1 }: expected seq ${ rows . length + 1 }, got ${ row . seq }` );
128 validateRow (row);
129 rows. push (row);
130 }
131 ROW_CACHE . set (file, { size: stat.size, mtimeNs: stat.mtimeNs, rows });
132 return rows;
133 }
134 function validateRow ( row ) {
135 if ( ! row.sourceStableKey && row.kind !== 'spot-check' ) throw new Error ( `blog ledger seq ${ row . seq }: sourceStableKey missing` );
136 if (row.kind === 'phase' ) {
137 if ( ! PHASES . has (row.phase)) throw new Error ( `blog ledger seq ${ row . seq }: unknown phase` );
138 if ([ 'held' , 'contain-failed' ]. includes (row.phase) && ! row.reason) throw new Error ( `blog ledger seq ${ row . seq }: reason missing` );
139 if (row.phase === 'verified' && ! [ 'read' , 'reverify' , 'accepted' ]. includes (row.via)) throw new Error ( `blog ledger seq ${ row . seq }: verified via missing` );
140 } else if (row.kind === 'desired-state' ) {
141 if ( ! DESIRED_EVENTS . has (row.event) || ! row.snapshotSha || ! row.generation) throw new Error ( `blog ledger seq ${ row . seq }: invalid desired state` );
142 } else if (row.kind === 'duplicate' ) {
143 if ( ! DUPLICATE_EVENTS . has (row.event) || ! row.extraDraftId || ! row.recordedDraftId) throw new Error ( `blog ledger seq ${ row . seq }: invalid duplicate` );
144 } else if ( SNAPSHOT_KINDS . has (row.kind)) {
145 if ( ! Array. isArray (row.findings) || row.findings. some ( f => ! f.findingKey)) throw new Error ( `blog ledger seq ${ row . seq }: invalid findings` );
146 } else if (row.kind === 'accepted' ) {
147 if ( ! row.snapshotKind || ! row.findingKey || ! row.snapshotSeq || ! row.acceptedBy) throw new Error ( `blog ledger seq ${ row . seq }: invalid acceptance` );
148 } else if (row.kind === 'spot-check' ) {
149 if ( ! row.coverageVersion || ! Array. isArray (row.posts) || ! row.checkedBy || ! row.recordedBy) throw new Error ( `blog ledger seq ${ row . seq }: invalid spot-check` );
150 } else throw new Error ( `blog ledger seq ${ row . seq }: unknown kind ${ row . kind }` );
151 }
152 function appendRow ( projectDir , row ) {
153 const dir = stateDir (projectDir);
154 fs. mkdirSync (dir, { recursive: true });
155 const rows = readRows (projectDir);
156 const content = fs. existsSync ( ledgerPath (projectDir)) ? fs. readFileSync ( ledgerPath (projectDir), 'utf8' ) : '' ;
157 if (content && ! content. endsWith ( ' \n ' )) {
158 const tail = content. slice (content. lastIndexOf ( ' \n ' ) + 1 );
159 let valid = false ;
160 try { JSON . parse (tail); valid = true ; } catch { /* torn tail already ignored by readRows */ }
161 if (valid) fs. appendFileSync ( ledgerPath (projectDir), ' \n ' );
162 else fs. truncateSync ( ledgerPath (projectDir), Buffer. byteLength (content. slice ( 0 , content. lastIndexOf ( ' \n ' ) + 1 )));
163 }
164 const runId = row.runId || RUN_IDS . get (projectDir);
165 const next = { ... row, ... (runId ? { runId } : {}), seq: rows. length + 1 ,
166 ts: row.ts || new Date (). toISOString () };
167 validateRow (next);
168 const fd = fs. openSync ( ledgerPath (projectDir), 'a' );
169 try { fs. writeSync (fd, `${ JSON . stringify ( next ) } \n ` ); fs. fsyncSync (fd); }
170 finally { fs. closeSync (fd); }
171 const stat = fs. statSync ( ledgerPath (projectDir), { bigint: true });
172 ROW_CACHE . set ( ledgerPath (projectDir), { size: stat.size, mtimeNs: stat.mtimeNs,
173 rows: [ ... rows, next] });
174 return next;
175 }
176 function withLock ( projectDir , run ) {
177 const dir = stateDir (projectDir);
178 fs. mkdirSync (dir, { recursive: true });
179 const lock = path. join (dir, LOCK );
180 let fd;
181 try { fd = fs. openSync (lock, 'wx' ); }
182 catch (error) {
183 if (error.code === 'EEXIST' ) throw new Error ( `blog build lock exists at ${ lock }; Import Operator must inspect it` );
184 throw error;
185 }
186 fs. writeSync (fd, `${ process . pid } \n ` ); fs. fsyncSync (fd);
187 const release = () => { fs. closeSync (fd); fs. unlinkSync (lock); };
188 try {
189 const result = run ();
190 if (result && typeof result.then === 'function' ) return result. finally (release);
191 release ();
192 return result;
193 } catch (error) { release (); throw error; }
194 }
195 function setRunId ( projectDir , runId ) {
196 if (runId) RUN_IDS . set (projectDir, runId);
197 else RUN_IDS . delete (projectDir);
198 }
199 function saveGeneration ( projectDir , sourceStableKey , desiredPost , origin , extra = {}) {
200 const started = process.hrtime. bigint ();
201 validateDesiredPost (desiredPost);
202 if ( ! [ 'run' , 'held-draft' , 'source' ]. includes (origin)) throw new Error ( 'invalid blog snapshot origin' );
203 const payload = { schema: 3 , sourceStableKey, draftId: extra.draftId || null ,
204 fingerprint: fingerprint (desiredPost), desiredPost, ownerState: extra.ownerState || null ,
205 finalSlug: extra.finalSlug || null , snapshotOrigin: origin,
206 savedAt: new Date (). toISOString () };
207 const bytes = canonical (payload);
208 const digest = sha (bytes);
209 const dir = path. join ( stateDir (projectDir), SNAPSHOT_DIR );
210 fs. mkdirSync (dir, { recursive: true });
211 const safeKey = encodeURIComponent (sourceStableKey). replace ( /%/ g , '_' );
212 const file = path. join (dir, `${ safeKey }.${ digest }.snapshot.json` );
213 if ( ! fs. existsSync (file)) {
214 const temp = path. join (dir, `.${ digest }.${ process . pid }.tmp` );
215 const fd = fs. openSync (temp, 'wx' );
216 try { fs. writeSync (fd, bytes); fs. fsyncSync (fd); }
217 finally { fs. closeSync (fd); }
218 fs. renameSync (temp, file);
219 const dirFd = fs. openSync (dir, 'r' );
220 try { fs. fsyncSync (dirFd); } finally { fs. closeSync (dirFd); }
221 }
222 const elapsedMs = Number (process.hrtime. bigint () - started) / 1e6 ;
223 const metricsKey = `${ projectDir } \u0000 ${ sourceStableKey }` ;
224 const previous = SNAPSHOT_METRICS . get (metricsKey) || { bytes: 0 , ms: 0 , generations: 0 };
225 SNAPSHOT_METRICS . set (metricsKey, { bytes: previous.bytes + Buffer. byteLength (bytes),
226 ms: previous.ms + elapsedMs, generations: previous.generations + 1 });
227 return { generation: digest, snapshotSha: digest, sourceStableKey };
228 }
229 function snapshotMetrics ( projectDir , sourceStableKey ) {
230 return SNAPSHOT_METRICS . get ( `${ projectDir } \u0000 ${ sourceStableKey }` ) ||
231 { bytes : 0 , ms : 0 , generations : 0 };
232 }
233 function resetSnapshotMetrics ( projectDir ) {
234 for ( const key of SNAPSHOT_METRICS . keys ()) if (key. startsWith ( `${ projectDir } \u0000 ` )) SNAPSHOT_METRICS . delete (key);
235 }
236 function loadGeneration ( projectDir , digest , sourceStableKey ) {
237 const dir = path. join ( stateDir (projectDir), SNAPSHOT_DIR );
238 const suffix = `.${ digest }.snapshot.json` ;
239 const names = fs. readdirSync (dir). filter ( name => name. endsWith (suffix));
240 if (names. length !== 1 ) throw new Error ( `blog snapshot ${ digest }: expected one referenced generation, found ${ names . length }` );
241 const file = path. join (dir, names[ 0 ]);
242 const data = fs. readFileSync (file, 'utf8' );
243 if ( sha (data) !== digest) throw new Error ( `blog snapshot hash mismatch: ${ digest }` );
244 const result = JSON . parse (data);
245 if (sourceStableKey && result.sourceStableKey !== sourceStableKey) throw new Error ( `blog snapshot source mismatch: ${ digest }` );
246 return result;
247 }
248 function savePublishedEvidence ( projectDir , sourceStableKey , published ) {
249 const dir = path. join ( stateDir (projectDir), SNAPSHOT_DIR );
250 fs. mkdirSync (dir, { recursive: true });
251 const safeKey = encodeURIComponent (sourceStableKey). replace ( /%/ g , '_' );
252 const file = path. join (dir, `${ safeKey }.published.json` );
253 const temp = `${ file }.${ process . pid }.tmp` ;
254 const fd = fs. openSync (temp, 'w' );
255 try { fs. writeSync (fd, `${ JSON . stringify ( published , null , 2 ) } \n ` ); fs. fsyncSync (fd); }
256 finally { fs. closeSync (fd); }
257 fs. renameSync (temp, file);
258 const dirFd = fs. openSync (dir, 'r' );
259 try { fs. fsyncSync (dirFd); } finally { fs. closeSync (dirFd); }
260 return path. relative (projectDir, file);
261 }
262 function fold ( rows ) {
263 const posts = new Map ();
264 const duplicates = new Map ();
265 let spotCheck = null ;
266 for ( const row of rows) {
267 if (row.kind === 'spot-check' ) { spotCheck = row; continue ; }
268 const key = row.sourceStableKey;
269 if ( ! posts. has (key)) posts. set (key, { sourceStableKey: key, phase: null , containmentOpen: false ,
270 desired: null , pendingIntent: null , snapshots: {}, accepted: new Map (), rows: [] });
271 const post = posts. get (key);
272 post.rows. push (row);
273 if (row.kind === 'phase' ) {
274 post.phase = row;
275 if (row.phase === 'containing' ) post.containmentOpen = true ;
276 else if (row.phase === 'contained' ) post.containmentOpen = false ;
277 } else if (row.kind === 'desired-state' ) {
278 if (row.event === 'captured' || row.event === 'update-applied' ) { post.desired = row; post.pendingIntent = null ; }
279 else post.pendingIntent = row;
280 } else if (row.kind === 'duplicate' ) {
281 const pair = `${ key } \u0000 ${ row . extraDraftId }` ;
282 if ( ! duplicates. has (pair)) duplicates. set (pair, { sourceStableKey: key, extraDraftId: row.extraDraftId,
283 recordedDraftId: row.recordedDraftId, evidence: null , confirmation: null , choice: null });
284 const item = duplicates. get (pair);
285 item.last = row;
286 if ([ 'provenance' , 'unresolved' ]. includes (row.event)) { item.evidence = row.evidence; item.confirmation = null ; item.choice = null ; }
287 if (row.event === 'ownership-confirmed' ) item.confirmation = row;
288 if (row.event === 'delete-chosen' ) item.choice = row;
289 item.closed = CLOSED_DUPLICATES . has (row.event);
290 } else if ( SNAPSHOT_KINDS . has (row.kind)) post.snapshots[row.kind] = row;
291 else if (row.kind === 'accepted' ) post.accepted. set ( `${ row . snapshotKind } \u0000 ${ row . findingKey }` , row);
292 }
293 for ( const post of posts. values ()) {
294 post.openFindings = Object. fromEntries (Object. entries (post.snapshots). map (([ kind , snap ]) =>
295 [kind, snap.findings. filter ( f => ! post.accepted. has ( `${ kind } \u0000 ${ f . findingKey }` ))]));
296 }
297 return { posts, duplicates, spotCheck };
298 }
299
300 module . exports = {
301 LEDGER, PHASES, CONTAINMENT, SNAPSHOT_KINDS, stateDir, ledgerPath, sha, canonical,
302 bodyProjection, bodySha, fingerprint, validateDesiredPost, fromDraftResponse,
303 readRows, appendRow, withLock, saveGeneration, loadGeneration, savePublishedEvidence, fold,
304 snapshotMetrics, resetSnapshotMetrics,
305 setRunId,
306 };