Setting the file. One moment. Blog Post Build · Rp Target Wix · wix/skills · Skills Docs41.10
Multilingual Restore
function checkRegistry
— line 62
This file
- Number
- 41.128
- Position
- 128 of 174
- Type
- JavaScript
- Size
- 40 KB
- Lines
- 721
lib/blog-post-build.js
JavaScript·721 lines·40 KB
setTimeout
(resolve, ms));
8const NOT_FOUND_CODE = 'NOT_FOUND';
9// LIVE-VERIFIED (2026-10-31): Get Draft Post and Update Draft Post can return slug: null while the
10// draft's slug is in seoSlug (with slugs[] beside it), so either field names the draft's slug.
11// slug wins when both are set.
12function draftSlug(draft) {
13 return draft?.slug || draft?.seoSlug || null;
14}
15function isNotFound(error) {
16 if (error?.status !== 404) return false;
17 const code = error.code || error.applicationErrorCode || error.details?.code ||
18 error.body?.details?.applicationError?.code;
19 const message = error.body?.message || error.message || '';
20 return [NOT_FOUND_CODE, 'ENTITY_NOT_FOUND'].includes(code) ||
21 (!code && error.outcomeKnown === true && /^entity not found\b/i.test(String(message)));
22}
23async function readOrNull(fn, id) {
24 try { return await fn(id); }
25 catch (error) { if (isNotFound(error)) return null; throw error; }
26}
27function stableKey(post) { return String(post.sourceStableKey ?? post.id); }
28function sourceDate(post) { return Date.parse(post.firstPublishedDate || post.date || 0) || 0; }
29function sortPosts(posts) { return [...posts].sort((a, b) => sourceDate(a) - sourceDate(b) || stableKey(a).localeCompare(stableKey(b))); }
30function sampleKeys(posts) {
31 if (!posts.length) return new Set();
32 const ordered = sortPosts(posts);
33 const chosen = [ordered[0]];
34 const longest = [...ordered].sort((a, b) =>
35 encodeURIComponent(b.desiredPost?.seoSlug || b.slug || '').length - encodeURIComponent(a.desiredPost?.seoSlug || a.slug || '').length)[0];
36 if (longest && stableKey(longest) !== stableKey(chosen[0])) chosen.push(longest);
37 const rest = ordered.filter(post => !chosen.includes(post)).sort((a, b) =>
38 ledger.sha(stableKey(a)).localeCompare(ledger.sha(stableKey(b))));
39 return new Set([...chosen, ...rest].slice(0, Math.min(10, posts.length)).map(stableKey));
40}
41function budgetPlan(posts, transforms = {}) {
42 const ready = posts.filter(post => post.mediaReady !== false);
43 if (transforms.rewriteLinks && ready.some(post => typeof post.hasPostLinks !== 'boolean')) {
44 throw new Error('blog preflight needs hasPostLinks for every ready post when link rewriting is registered');
45 }
46 const N = ready.length;
47 const S0 = ready.filter(post => post.slug && encodeURIComponent(post.slug).length > 100).length;
48 const L = ready.filter(post => post.hasPostLinks === true).length;
49 return { N, S0, L, ceiling: N + N + L + 40, expected: N + S0 + L + 40 };
50}
51function makeFinding(name, blocking, identity, details = {}) {
52 return { check: name, blocking, findingKey: ledger.sha({ name, identity }), ...details };
53}
54function defaultChecks() {
55 return [{ name: 'path', blocking: false, kind: 'verify', run(published, ctx) {
56 const actual = publishedPath(published);
57 const expected = ctx.expectedPath;
58 return actual === expected ? [] : [makeFinding('path', false,
59 { sourceStableKey: ctx.sourceStableKey, expected }, { expected, actual })];
60 } }];
61}
62function checkRegistry(checks = []) {
63 const out = [...defaultChecks(), ...checks];
64 const names = new Set();
65 for (const check of out) {
66 if (!check.name || !ledger.SNAPSHOT_KINDS.has(check.kind) ||
67 typeof check.run !== 'function' || typeof check.blocking !== 'boolean') throw new Error('invalid blog check');
68 if (names.has(check.name)) throw new Error(`duplicate blog check ${check.name}`);
69 names.add(check.name);
70 }
71 return out;
72}
73function phase(projectDir, post, value, extra = {}) {
74 return ledger.appendRow(projectDir, { kind: 'phase', sourceStableKey: stableKey(post), phase: value, ...extra });
75}
76function current(projectDir, key) { return ledger.fold(ledger.readRows(projectDir)).posts.get(key); }
77function bodyFrom(post) {
78 const body = post.desiredPost;
79 if (!body) throw new Error(`blog post ${stableKey(post)} has no desiredPost`);
80 return ledger.validateDesiredPost(structuredClone(body));
81}
82function commitGeneration(projectDir, post, draftId, desiredPost, origin, event, projection, extra = {}) {
83 const saved = ledger.saveGeneration(projectDir, stableKey(post), desiredPost, origin,
84 { draftId, ...extra });
85 return ledger.appendRow(projectDir, { kind: 'desired-state', sourceStableKey: stableKey(post),
86 draftId, ...saved, event, projection });
87}
88function desiredFor(projectDir, key) {
89 const state = current(projectDir, key);
90 if (!state?.desired) throw new Error(`blog post ${key}: no committed desired state`);
91 return ledger.loadGeneration(projectDir, state.desired.snapshotSha);
92}
93function validateClient(client) {
94 for (const name of ['createDraft', 'updateDraft', 'publishDraft', 'getDraft', 'getPost',
95 'listDrafts', 'deletePost']) {
96 if (typeof client?.[name] !== 'function') throw new Error(`blog client needs ${name}`);
97 }
98}
99async function allDrafts(client) {
100 const drafts = [];
101 let cursor = null;
102 const seen = new Set();
103 for (let page = 0; page < 1000; page += 1) {
104 const result = await client.listDrafts({ cursor });
105 if (!Array.isArray(result?.drafts)) throw new Error('draft listing lacked drafts array');
106 drafts.push(...result.drafts);
107 if (!result.nextCursor) return drafts;
108 if (seen.has(result.nextCursor)) throw new Error('draft listing cursor repeated');
109 seen.add(result.nextCursor);
110 cursor = result.nextCursor;
111 }
112 throw new Error('draft listing page cap reached');
113}
114function candidateCreatedAfter(candidate, intent) {
115 const created = Date.parse(candidate.createdDate || candidate.createdAt);
116 const started = Date.parse(intent.ts);
117 return Number.isFinite(created) && created >= started - 5 * 60_000;
118}
119function ownedElsewhere(id, key, state, crosswalk) {
120 if (typeof crosswalk?.idOwners !== 'function') throw new Error('blog adoption requires crosswalk.idOwners');
121 for (const [otherKey, other] of state.posts) {
122 if (otherKey !== key && other.rows.some(row => row.draftId === id || row.postId === id)) return true;
123 }
124 return crosswalk.idOwners(id).some(owner => String(owner) !== key);
125}
126async function candidateDrafts(client, intent, post, state, crosswalk, expectedHashes, expectedTitle) {
127 const list = await allDrafts(client);
128 const pref = list.filter(candidate => {
129 if (candidate.title !== expectedTitle) return false;
130 // A list page can omit fields that a full draft read includes. Do not interpret
131 // a sparse listing as proof that a draft does not match an uncertain create.
132 return !candidate.createdDate && !candidate.createdAt || candidateCreatedAfter(candidate, intent);
133 });
134 const matches = [];
135 const problems = [];
136 for (const candidate of pref) {
137 const full = await client.getDraft(candidate.id);
138 if (ledger.fingerprint(full) !== intent.fingerprint) continue;
139 if (!candidateCreatedAfter(full, intent)) {
140 problems.push({ id: candidate.id, creationTime: 'missing-or-outside-window' });
141 continue;
142 }
143 const bodyMatches = ledger.bodySha(full) === intent.createBodySha;
144 const owned = ownedElsewhere(candidate.id, stableKey(post), state, crosswalk);
145 const collision = expectedHashes.filter(value => value === intent.createBodySha).length > 1;
146 if (bodyMatches && !owned && !collision) matches.push(full);
147 else problems.push({ id: candidate.id, bodyMatches, ownedElsewhere: owned, sourceCollision: collision });
148 }
149 return { matches, problems };
150}
151async function recoverCreating(projectDir, post, client, crosswalk, expectedHashes, options) {
152 const key = stableKey(post);
153 const state = ledger.fold(ledger.readRows(projectDir));
154 const intent = state.posts.get(key).phase;
155 const title = desiredFor(projectDir, key).desiredPost.title;
156 let found = await candidateDrafts(client, intent, post, state, crosswalk, expectedHashes, title);
157 if (!found.matches.length && !found.problems.length) {
158 await sleep(options.settleMs ?? 10_000);
159 found = await candidateDrafts(client, intent, post, state, crosswalk, expectedHashes, title);
160 }
161 if (found.problems.length || found.matches.length > 1) {
162 phase(projectDir, post, 'held', { reason: 'adoption-ambiguous', candidates: [...found.matches.map(x => x.id), ...found.problems.map(x => x.id)] });
163 throw new Error(`blog post ${key}: adoption-ambiguous`);
164 }
165 if (found.matches.length === 1) return { draft: found.matches[0], recreated: false, adopted: true };
166 // The intent pins the complete body. Source input may have changed since the crash.
167 const pinned = desiredFor(projectDir, key).desiredPost;
168 if (ledger.bodySha(pinned) !== intent.createBodySha) throw new Error(`blog post ${key}: create intent body changed`);
169 const draft = await client.createDraft(pinned);
170 return { draft, recreated: true };
171}
172async function captureCreate(projectDir, post, client, crosswalk, expectedHashes, options) {
173 const key = stableKey(post);
174 let state = current(projectDir, key);
175 let newIntent = false;
176 if (!state?.phase || state.phase.phase === 'media-pending' ||
177 state.phase.phase === 'held' && state.phase.reason === 'desired-post-invalid' && !latestId(state)) {
178 const body = bodyFrom(post);
179 const oldId = crosswalk?.get ? await crosswalk.get(key) : null;
180 if (oldId) {
181 const id = typeof oldId === 'string' ? oldId : oldId.targetId || oldId.postId || oldId.draftId;
182 if (!id) throw new Error(`blog post ${key}: crosswalk row has no target id`);
183 const owners = typeof crosswalk.idOwners === 'function' ? crosswalk.idOwners(id).map(String) : [];
184 if (owners.length !== 1 || owners[0] !== key) {
185 phase(projectDir, post, 'held', { draftId: id, reason: 'legacy-mismatch' });
186 throw new Error(`blog post ${key}: legacy crosswalk id has ambiguous owners`);
187 }
188 const draft = await readOrNull(value => client.getDraft(value), id);
189 if (draft) {
190 const full = draft.draftPost || draft;
191 if (ledger.bodySha(full) !== ledger.bodySha(body) || ledger.fingerprint(full) !== ledger.fingerprint(body) ||
192 ownedElsewhere(id, key, ledger.fold(ledger.readRows(projectDir)), crosswalk) ||
193 expectedHashes.filter(hash => hash === ledger.bodySha(body)).length > 1) {
194 phase(projectDir, post, 'held', { draftId: id, reason: 'legacy-mismatch' });
195 throw new Error(`blog post ${key}: legacy-mismatch`);
196 }
197 commitGeneration(projectDir, post, id, body, 'source', 'captured', undefined,
198 { ownerState: post.ownerState });
199 phase(projectDir, post, 'drafted', { draftId: id, slug: full.slug || null,
200 fingerprint: ledger.fingerprint(body), createBodySha: ledger.bodySha(body),
201 storedBodySha: ledger.bodySha(full), legacy: true });
202 return;
203 }
204 const published = await readOrNull(value => client.getPost(value), id);
205 if (!published) throw new Error(`blog post ${key}: crosswalk target missing on both surfaces`);
206 options.publishedCache?.set(key, published);
207 const saved = commitGeneration(projectDir, post, id, body, 'source', 'captured', undefined,
208 { ownerState: post.ownerState });
209 const path = expectedPath(published.slug || body.seoSlug, post);
210 phase(projectDir, post, 'snapshot-saved', { draftId: id,
211 expectedPath: path, snapshotSha: saved.snapshotSha, snapshotOrigin: 'source' });
212 phase(projectDir, post, 'adopted-published', { draftId: id, postId: id,
213 expectedPath: path, publishedPath: publishedPath(published) });
214 return;
215 }
216 commitGeneration(projectDir, post, null, body, 'run', 'captured', undefined,
217 { ownerState: post.ownerState });
218 phase(projectDir, post, 'creating', { fingerprint: ledger.fingerprint(body), createBodySha: ledger.bodySha(body) });
219 state = current(projectDir, key);
220 newIntent = true;
221 }
222 if (state.phase.phase !== 'creating') return;
223 const result = newIntent
224 ? { draft: await client.createDraft(desiredFor(projectDir, key).desiredPost), recreated: false }
225 : await recoverCreating(projectDir, post, client, crosswalk, expectedHashes, options);
226 const draft = result.draft?.draftPost || result.draft;
227 if (!draft?.id) throw new Error(`blog post ${key}: create response lacks draft id`);
228 if (result.adopted && ledger.bodySha(draft) !== state.phase.createBodySha) {
229 phase(projectDir, post, 'held', { draftId: draft.id, reason: 'create-projection-mismatch' });
230 throw new Error(`blog post ${key}: create response body projection differs`);
231 }
232 phase(projectDir, post, 'drafted', { draftId: draft.id, slug: draft.slug || null,
233 fingerprint: state.phase.fingerprint, createBodySha: state.phase.createBodySha,
234 storedBodySha: ledger.bodySha(draft), recreatedAfterIntent: result.recreated });
235}
236function latestId(postState) {
237 return [...postState.rows].reverse().find(row => row.draftId)?.draftId;
238}
239function latestSlug(postState) {
240 return [...postState.rows].reverse().find(row => Object.hasOwn(row, 'slug'))?.slug || null;
241}
242function expectedPath(slug, post) {
243 if (!slug) return null;
244 const prefix = String(post?.blogPathPrefix || '/post').replace(/\/$/, '');
245 return `${prefix}/${encodeURIComponent(slug)}`;
246}
247function publishedPath(post) {
248 const raw = post?.url?.path || post?.url?.relativePath || post?.path || post?.url;
249 if (typeof raw === 'string') {
250 try { return new URL(raw).pathname; } catch { return raw; }
251 }
252 return null;
253}
254async function updateWithIntent(projectDir, post, client, draftId, desiredPost, projection, origin, ownerState) {
255 commitGeneration(projectDir, post, draftId, desiredPost, origin, 'update-intent', Object.keys(projection),
256 { ownerState });
257 const response = await client.updateDraft(draftId, projection);
258 const partial = response?.draftPost || response || {};
259 const committed = { ...desiredPost };
260 if (draftSlug(partial)) committed.seoSlug = draftSlug(partial);
261 commitGeneration(projectDir, post, draftId, committed, origin, 'update-applied', Object.keys(projection),
262 { ownerState, finalSlug: draftSlug(partial) });
263 return { response: partial, desiredPost: committed };
264}
265async function finalizePost(projectDir, post, client, transforms = {}, options = {}) {
266 const key = stableKey(post);
267 let state = current(projectDir, key);
268 if (!state || ['media-pending', 'snapshot-saved', 'publishing', 'published',
269 'adopted-published', 'verified', 'verify-failed'].includes(state.phase?.phase)) return;
270 if (state.phase?.phase === 'links-final' && !state.pendingIntent) return;
271 if (state.phase?.phase === 'held' && ['adoption-ambiguous', 'legacy-mismatch',
272 'create-projection-mismatch'].includes(state.phase.reason)) return;
273 if (state.containmentOpen) return;
274 const draftId = latestId(state);
275 if (!draftId) return;
276 if ((state.phase.phase === 'contained' || state.phase.reason === 'leftover-after-publish') &&
277 typeof transforms.detectLeftovers !== 'function') {
278 if (state.phase.phase !== 'held') phase(projectDir, post, 'held', { draftId,
279 reason: 'leftover-after-publish' });
280 return;
281 }
282 let generation = desiredFor(projectDir, key);
283 let desired = generation.desiredPost;
284 let origin = generation.snapshotOrigin;
285 let ownerState = generation.ownerState;
286 let slug = latestSlug(state);
287 const heldResume = state.phase.phase === 'held' || state.phase.phase === 'contained' ||
288 state.pendingIntent && origin === 'held-draft';
289 if (heldResume) {
290 const liveDraft = await client.getDraft(draftId);
291 try { desired = ledger.fromDraftResponse(liveDraft); }
292 catch (error) { phase(projectDir, post, 'held', { draftId, reason: 'desired-post-invalid', detail: error.message }); return; }
293 origin = 'held-draft';
294 if (typeof transforms.captureOwnerState === 'function')
295 ownerState = await transforms.captureOwnerState(desired, post, ownerState);
296 commitGeneration(projectDir, post, draftId, desired, origin, 'captured', undefined,
297 { ownerState, finalSlug: draftSlug(liveDraft.draftPost || liveDraft) });
298 const readSlug = draftSlug(liveDraft.draftPost || liveDraft);
299 if (!readSlug) { if (state.phase.reason !== 'slug-missing-in-response') phase(projectDir, post, 'held', { draftId, reason: 'slug-missing-in-response' }); return; }
300 if (slug && readSlug !== slug) { if (state.phase.reason !== 'slug-changed-while-held') phase(projectDir, post, 'held', { draftId, reason: 'slug-changed-while-held', slug }); return; }
301 slug = readSlug;
302 }
303 if (!heldResume && (state.phase.phase === 'drafted' || state.phase.phase === 'held' || state.phase.phase === 'contained')) {
304 if (!slug || post.forceSlugUpdate) {
305 const sourceSlug = post.slug || desired.seoSlug;
306 if (!sourceSlug) { phase(projectDir, post, 'held', { draftId, reason: 'slug-missing-in-response' }); return; }
307 desired.seoSlug = sourceSlug;
308 const update = await updateWithIntent(projectDir, post, client, draftId, desired,
309 { seoSlug: sourceSlug, media: desired.media }, origin, ownerState);
310 desired = update.desiredPost;
311 slug = draftSlug(update.response);
312 if (!slug) { phase(projectDir, post, 'held', { draftId, reason: 'slug-missing-in-response' }); return; }
313 phase(projectDir, post, 'slug-set', { draftId, slug, expectedPath: expectedPath(slug, post) });
314 }
315 }
316 if (!slug) { phase(projectDir, post, 'held', { draftId, reason: 'slug-missing-in-response' }); return; }
317 if (options.slugOnly) return;
318 const index = options.slugIndex || new Map();
319 const rewritten = transforms.rewriteLinks ? await transforms.rewriteLinks(structuredClone(desired), post, index) : desired;
320 if (rewritten?.waitingFor?.length) {
321 phase(projectDir, post, 'held', { draftId, slug, reason: 'waiting-for-linked-post', waitingFor: rewritten.waitingFor }); return;
322 }
323 const next = rewritten?.desiredPost || rewritten;
324 if (ledger.canonical(next.richContent) !== ledger.canonical(desired.richContent)) {
325 desired = next;
326 const update = await updateWithIntent(projectDir, post, client, draftId, desired,
327 { richContent: desired.richContent, media: desired.media }, origin, ownerState);
328 desired = update.desiredPost;
329 const updatedSlug = draftSlug(update.response);
330 if (!updatedSlug) { phase(projectDir, post, 'held', { draftId, reason: 'slug-missing-in-response' }); return; }
331 if (updatedSlug !== slug) {
332 phase(projectDir, post, 'held', { draftId, slug, reason: 'slug-changed-while-held' });
333 return;
334 }
335 }
336 const leftovers = transforms.detectLeftovers ? await transforms.detectLeftovers(desired, post, index) : [];
337 ledger.appendRow(projectDir, { kind: 'leftover-set', sourceStableKey: key, findings: leftovers });
338 const blockingLeftover = leftovers.find(f => f.blocking);
339 if (blockingLeftover) {
340 const reason = blockingLeftover.check === 'source-address' ? 'source-address-outside-link' :
341 blockingLeftover.reason === 'source-address-outside-link' ? blockingLeftover.reason : 'leftover-after-rewrite';
342 if (state.phase.phase !== 'held' || state.phase.reason !== reason)
343 phase(projectDir, post, 'held', { draftId, slug, reason });
344 return;
345 }
346 phase(projectDir, post, 'links-final', { draftId, slug, expectedPath: expectedPath(slug, post) });
347}
348async function proveAbsence(client, id, waits = [1000, 2000, 4000, 8000, 15000]) {
349 for (const wait of waits) {
350 if (wait) await sleep(wait);
351 const published = await readOrNull(value => client.getPost(value), id);
352 if (published) return { published: true, post: published };
353 }
354 const draft = await readOrNull(value => client.getDraft(value), id);
355 if (!draft) throw new Error(`blog post ${id}: absence ambiguous; both post and draft missing`);
356 return { published: false, draft };
357}
358async function verifyPost(projectDir, post, client, checks, sampled, via = 'read', fetchLivePage = true,
359 alreadyRead = null) {
360 const key = stableKey(post);
361 const state = current(projectDir, key);
362 const id = latestId(state);
363 const published = alreadyRead || await client.getPost(id);
364 const expected = [...state.rows].reverse().find(row => row.expectedPath)?.expectedPath;
365 const ctx = { sourceStableKey: key, sourcePost: post, expectedPath: expected, sampled };
366 const findings = [];
367 const byKind = new Map();
368 for (const check of checks) {
369 const result = await check.run(published, ctx);
370 if (!Array.isArray(result)) throw new Error(`blog check ${check.name} did not return findings`);
371 if (!byKind.has(check.kind)) byKind.set(check.kind, []);
372 for (const finding of result) {
373 if (!finding?.findingKey) throw new Error(`blog check ${check.name} returned a finding without findingKey`);
374 const normalized = { ...finding, check: check.name, blocking: check.blocking };
375 findings.push(normalized);
376 byKind.get(check.kind).push(normalized);
377 }
378 }
379 if (sampled && fetchLivePage) {
380 if (typeof client.fetchPage !== 'function') throw new Error('live-page fetch required for the verification sample');
381 const page = await client.fetchPage(expected);
382 if (page.status !== 200 || String(page.body || '').includes('fit/w_0')) {
383 findings.push(makeFinding('live-page', false, { key, expected }, { status: page.status }));
384 }
385 const covers = page.coverUrls || [];
386 if (covers.length > 3) throw new Error('sample page reported more than three cover URLs');
387 for (const url of covers) {
388 if (typeof client.fetchImage !== 'function') throw new Error('cover-image fetch required for a sampled page with a cover');
389 const result = await client.fetchImage(url);
390 if (result.status !== 200) findings.push(makeFinding('cover-url', false, { key, url }, { status: result.status }));
391 }
392 }
393 for (const [kind, ownFindings] of byKind) {
394 if (kind !== 'verify') ledger.appendRow(projectDir, { kind, sourceStableKey: key, findings: ownFindings });
395 }
396 if (!fetchLivePage) {
397 for (const old of state.openFindings?.verify || []) {
398 if (['live-page', 'cover-url'].includes(old.check)) findings.push(old);
399 }
400 }
401 ledger.appendRow(projectDir, { kind: 'verify', sourceStableKey: key, findings, sampled,
402 publishedPath: publishedPath(published) });
403 const blocking = findings.some(finding => finding.blocking);
404 if (blocking) return { blocking, findings, published };
405 if (findings.length) phase(projectDir, post, 'verify-failed', { draftId: id,
406 postId: id, publishedPath: publishedPath(published), reason: 'checks-failed' });
407 else phase(projectDir, post, 'verified', { draftId: id, postId: id,
408 publishedPath: publishedPath(published), via });
409 return { blocking, findings, published };
410}
411async function containPost(projectDir, post, client, crosswalk, expectedHashes = [], options = {}) {
412 const key = stableKey(post);
413 let state = current(projectDir, key);
414 const id = state.containmentOpen
415 ? [...state.rows].reverse().find(row => row.phase === 'containing')?.postId
416 : latestId(state);
417 if (!state.containmentOpen) {
418 const snap = [...state.rows].reverse().find(row => row.phase === 'snapshot-saved');
419 const evidenceFile = options.published
420 ? ledger.savePublishedEvidence(projectDir, key, options.published) : null;
421 phase(projectDir, post, 'containing', { draftId: id, postId: id,
422 snapshotSha: snap?.snapshotSha, evidenceFile,
423 exposedFrom: [...state.rows].reverse().find(row =>
424 ['publishing', 'adopted-published'].includes(row.phase))?.ts });
425 state = current(projectDir, key);
426 }
427 let last = state.phase.phase;
428 if (last === 'containing' || last === 'contain-failed' && state.phase.reason === 'delete-failed') {
429 try {
430 const before = await readOrNull(value => client.getPost(value), id);
431 if (before) await client.deletePost(id);
432 const missing = await readOrNull(value => client.getPost(value), id);
433 if (missing) throw new Error('published post still exists');
434 phase(projectDir, post, 'contain-deleted', { draftId: id, postId: id, containedAt: new Date().toISOString() });
435 } catch (error) {
436 phase(projectDir, post, 'contain-failed', { draftId: id, postId: id, reason: 'delete-failed', detail: error.message });
437 throw error;
438 }
439 }
440 state = current(projectDir, key); last = state.phase.phase;
441 const snap = [...state.rows].reverse().find(row => row.phase === 'snapshot-saved');
442 const sourceRecovery = [...state.rows].reverse().find(row => row.phase === 'contain-recreating' && row.snapshotSha);
443 let saved;
444 try { saved = ledger.loadGeneration(projectDir, sourceRecovery?.snapshotSha || snap?.snapshotSha, key); }
445 catch (error) {
446 const approval = options.confirmSourceRebuild;
447 if (!approval || !String(approval.confirmedBy || '').trim() || !String(approval.note || '').trim()) {
448 if (last !== 'contain-failed' || state.phase.reason !== 'snapshot-missing')
449 phase(projectDir, post, 'contain-failed', { draftId: id, postId: id, reason: 'snapshot-missing' });
450 throw new Error(`blog post ${key}: snapshot-missing requires a named source-rebuild confirmation`);
451 }
452 if (sourceRecovery) throw new Error(`blog post ${key}: confirmed source-rebuild snapshot is missing`);
453 const rebuilt = bodyFrom(post);
454 const row = commitGeneration(projectDir, post, id, rebuilt, 'source', 'captured', undefined,
455 { ownerState: post.ownerState });
456 phase(projectDir, post, 'contain-recreating', { draftId: id, postId: id,
457 fingerprint: ledger.fingerprint(rebuilt), createBodySha: ledger.bodySha(rebuilt),
458 snapshotSha: row.snapshotSha, sourceRebuildBy: approval.confirmedBy,
459 sourceRebuildNote: approval.note });
460 state = current(projectDir, key); last = state.phase.phase;
461 saved = ledger.loadGeneration(projectDir, row.snapshotSha, key);
462 }
463 if (last === 'contain-deleted' || last === 'contain-failed' && state.phase.reason !== 'delete-failed') {
464 let draft = await readOrNull(value => client.getDraft(value), id);
465 if (!draft) {
466 const previousIntent = [...state.rows].reverse().find(row => row.phase === 'contain-recreating');
467 if (!previousIntent || last === 'contain-deleted') {
468 phase(projectDir, post, 'contain-recreating', { draftId: id,
469 fingerprint: ledger.fingerprint(saved.desiredPost), createBodySha: ledger.bodySha(saved.desiredPost) });
470 state = current(projectDir, key);
471 }
472 last = 'contain-recreating';
473 }
474 if (draft) {
475 const newId = (draft.draftPost || draft).id;
476 phase(projectDir, post, 'contain-drafted', { draftId: newId, postId: id,
477 storedBodySha: ledger.bodySha(draft), survivedDelete: true });
478 commitGeneration(projectDir, post, newId, saved.desiredPost, saved.snapshotOrigin, 'captured', undefined,
479 { ownerState: saved.ownerState, finalSlug: saved.finalSlug });
480 }
481 }
482 state = current(projectDir, key); last = state.phase.phase;
483 if (last === 'contain-recreating' || last === 'contain-failed' &&
484 ['recreate-failed', 'recreate-ambiguous'].includes(state.phase.reason)) {
485 const intent = [...state.rows].reverse().find(row => row.phase === 'contain-recreating');
486 let found = await candidateDrafts(client, intent, post, ledger.fold(ledger.readRows(projectDir)),
487 crosswalk, expectedHashes.length ? expectedHashes : [intent.createBodySha], saved.desiredPost.title);
488 if (!found.matches.length && !found.problems.length) {
489 await sleep(options.settleMs ?? 10_000);
490 found = await candidateDrafts(client, intent, post, ledger.fold(ledger.readRows(projectDir)),
491 crosswalk, expectedHashes.length ? expectedHashes : [intent.createBodySha], saved.desiredPost.title);
492 }
493 if (found.problems.length || found.matches.length > 1) {
494 phase(projectDir, post, 'contain-failed', { draftId: id, postId: id, reason: 'recreate-ambiguous',
495 candidates: [...found.matches.map(x => x.id), ...found.problems.map(x => x.id)] });
496 throw new Error(`blog post ${key}: recreate-ambiguous`);
497 }
498 let draft;
499 const recreatedAfterIntent = found.matches.length === 0;
500 try { draft = found.matches[0] || await client.createDraft(saved.desiredPost); }
501 catch (error) { phase(projectDir, post, 'contain-failed', { draftId: id, postId: id, reason: 'recreate-failed' }); throw error; }
502 const newId = (draft.draftPost || draft).id;
503 if (!newId || !recreatedAfterIntent && ledger.bodySha(draft) !== intent.createBodySha) {
504 phase(projectDir, post, 'contain-failed', { draftId: id, postId: id, reason: 'recreate-ambiguous' });
505 throw new Error(`blog post ${key}: recreated draft body mismatch`);
506 }
507 phase(projectDir, post, 'contain-drafted', { draftId: newId, postId: id,
508 storedBodySha: ledger.bodySha(draft), recreatedAfterIntent });
509 commitGeneration(projectDir, post, newId, saved.desiredPost, saved.snapshotOrigin, 'captured', undefined,
510 { ownerState: saved.ownerState, finalSlug: saved.finalSlug });
511 }
512 state = current(projectDir, key); last = state.phase.phase;
513 if (last === 'contain-drafted') {
514 if (crosswalk && typeof crosswalk.set !== 'function') throw new Error('containment requires crosswalk.set');
515 if (crosswalk?.set) await crosswalk.set(key, latestId(state));
516 phase(projectDir, post, 'contain-linked', { draftId: latestId(state), postId: id });
517 }
518 state = current(projectDir, key);
519 if (state.phase.phase === 'contain-linked') {
520 const operation = [...state.rows].reverse().find(row => row.phase === 'containing');
521 const deletion = [...state.rows].reverse().find(row => row.phase === 'contain-deleted');
522 phase(projectDir, post, 'contained', { draftId: latestId(state), postId: id,
523 reason: 'leftover-after-publish', exposedFrom: operation?.exposedFrom,
524 containedAt: deletion?.containedAt, snapshotSha: operation?.snapshotSha });
525 }
526}
527async function publishPost(projectDir, post, client, checks, sampled, options) {
528 const key = stableKey(post);
529 let state = current(projectDir, key);
530 if (!state || state.containmentOpen) return;
531 const id = latestId(state);
532 if (state.phase.phase === 'links-final') {
533 const currentBody = desiredFor(projectDir, key);
534 const saved = commitGeneration(projectDir, post, id, currentBody.desiredPost,
535 currentBody.snapshotOrigin, 'captured', undefined,
536 { ownerState: currentBody.ownerState, finalSlug: state.phase.slug });
537 ledger.loadGeneration(projectDir, saved.snapshotSha);
538 phase(projectDir, post, 'snapshot-saved', { draftId: id,
539 slug: state.phase.slug, expectedPath: state.phase.expectedPath, snapshotSha: saved.snapshotSha });
540 }
541 state = current(projectDir, key);
542 if (state.phase.phase === 'snapshot-saved') {
543 ledger.loadGeneration(projectDir, state.phase.snapshotSha);
544 phase(projectDir, post, 'publishing', { draftId: id, expectedPath: state.phase.expectedPath });
545 await client.publishDraft(id);
546 phase(projectDir, post, 'published', { draftId: id, postId: id,
547 expectedPath: state.phase.expectedPath });
548 } else if (state.phase.phase === 'publishing') {
549 const absence = await proveAbsence(client, id, options.absenceWaits);
550 if (!absence.published) await client.publishDraft(id);
551 phase(projectDir, post, 'published', { draftId: id, postId: id,
552 expectedPath: state.phase.expectedPath });
553 }
554 state = current(projectDir, key);
555 if (!['published', 'adopted-published'].includes(state.phase.phase)) return;
556 const result = await verifyPost(projectDir, post, client, checks, sampled, 'read', true,
557 options.publishedCache?.get(key));
558 if (result.blocking) {
559 await containPost(projectDir, post, client, options.crosswalk, options.expectedHashes,
560 { ...options, published: result.published });
561 throw new Error(`blog post ${key}: blocking verification failed; contained`);
562 }
563 return result;
564}
565async function runBlogBuild({ projectDir, posts, client, crosswalk, checks = [], transforms = {}, options = {} }) {
566 validateClient(client);
567 const originalClient = client;
568 const measured = [];
569 let activeKey = null;
570 client = Object.create(originalClient);
571 for (const name of ['createDraft', 'updateDraft', 'publishDraft', 'getDraft', 'getPost',
572 'listDrafts', 'deletePost', 'fetchPage', 'fetchImage']) {
573 if (typeof originalClient[name] !== 'function') continue;
574 client[name] = async (...args) => {
575 const started = Date.now();
576 try { return await originalClient[name](...args); }
577 finally { measured.push({ name, key: activeKey, ms: Date.now() - started }); }
578 };
579 }
580 if (!Array.isArray(posts)) throw new Error('blog posts must be an array');
581 const ordered = sortPosts(posts);
582 const budget = budgetPlan(ordered, transforms);
583 const log = options.log || console.log;
584 log(`Blog preflight estimate (before writes): N=${budget.N}, S0=${budget.S0}, L=${budget.L}; ` +
585 `added-call ceiling ${budget.ceiling}, expected ${budget.expected} (plus existing creates/publishes; 40 is a fixed maximum).`);
586 const registry = checkRegistry(checks);
587 return ledger.withLock(projectDir, async () => {
588 let finalReport;
589 try {
590 ledger.setRunId(projectDir, options.runId);
591 ledger.resetSnapshotMetrics(projectDir);
592 const publishedCache = new Map();
593 const hashes = [];
594 const invalidKeys = new Set();
595 for (const post of ordered.filter(item => item.desiredPost)) {
596 const state = current(projectDir, stableKey(post));
597 try {
598 hashes.push(ledger.bodySha(state?.desired ? desiredFor(projectDir, stableKey(post)).desiredPost : bodyFrom(post)));
599 } catch (error) {
600 // A referenced generation is durable recovery state. Corruption cannot be
601 // reclassified as a source validation hold and must stop before Wix writes.
602 if (state?.desired && !state.containmentOpen) throw error;
603 if (state?.containmentOpen ||
604 ['published', 'adopted-published', 'verified', 'verify-failed'].includes(state?.phase?.phase)) {
605 const original = state.rows.find(row => row.createBodySha)?.createBodySha;
606 if (original) hashes.push(original);
607 continue;
608 }
609 invalidKeys.add(stableKey(post));
610 if (state?.phase?.phase !== 'held' || state.phase.reason !== 'desired-post-invalid')
611 phase(projectDir, post, 'held', { draftId: state && latestId(state),
612 reason: 'desired-post-invalid', detail: error.message });
613 }
614 }
615 await duplicates.finishPendingEvidence(projectDir, client, crosswalk);
616 for (const post of ordered) {
617 const key = stableKey(post);
618 activeKey = key;
619 if (invalidKeys.has(key)) continue;
620 const state = current(projectDir, key);
621 if (state?.containmentOpen) {
622 await containPost(projectDir, post, client, crosswalk, hashes, options);
623 throw new Error(`blog post ${key}: containment completed; run stopped`);
624 }
625 if (['verified', 'verify-failed', 'published', 'adopted-published'].includes(state?.phase?.phase)) continue;
626 if (post.mediaReady === false) {
627 const reason = post.mediaPendingReason || 'media-not-ready';
628 if (!state?.phase || state.phase.phase !== 'media-pending' || state.phase.reason !== reason)
629 phase(projectDir, post, 'media-pending', { reason });
630 continue;
631 }
632 if (!state?.phase || state.phase.phase === 'media-pending' || state.phase.phase === 'creating' ||
633 state.phase.phase === 'held' && state.phase.reason === 'desired-post-invalid' && !latestId(state)) {
634 await captureCreate(projectDir, post, client, crosswalk, hashes,
635 { ...options, publishedCache });
636 }
637 }
638 const exactSlugUpdates = ordered.filter(post => {
639 const state = current(projectDir, stableKey(post));
640 return state?.phase?.phase === 'drafted' && (!latestSlug(state) || post.forceSlugUpdate);
641 }).length;
642 log(`Blog preflight estimate (after drafts, before updates): N=${budget.N}, S=${exactSlugUpdates}, ` +
643 `L=${budget.L}; added-call ceiling ${budget.N + exactSlugUpdates + budget.L + 40}.`);
644 const slugIndex = new Map();
645 for (const post of ordered) {
646 activeKey = stableKey(post);
647 const state = current(projectDir, stableKey(post));
648 if (state?.phase?.phase === 'drafted' && !latestSlug(state)) {
649 await finalizePost(projectDir, post, client, transforms, { slugOnly: true });
650 }
651 }
652 for (const post of ordered) {
653 activeKey = stableKey(post);
654 const state = current(projectDir, stableKey(post));
655 const slug = state && latestSlug(state);
656 if (slug) slugIndex.set(stableKey(post), slug);
657 }
658 for (const post of ordered) {
659 activeKey = stableKey(post);
660 await finalizePost(projectDir, post, client, transforms, { slugIndex });
661 const state = current(projectDir, stableKey(post));
662 const slug = state && latestSlug(state);
663 if (slug) slugIndex.set(stableKey(post), slug);
664 }
665 const sample = sampleKeys(ordered.filter(post => {
666 const state = current(projectDir, stableKey(post));
667 return state?.phase && !state.containmentOpen &&
668 !['media-pending', 'held', 'contain-failed', 'contained'].includes(state.phase.phase);
669 }));
670 if (sample.size && (typeof client.fetchPage !== 'function' || typeof client.fetchImage !== 'function')) {
671 throw new Error('blog sample requires fetchPage and fetchImage before the first publish');
672 }
673 let canaryDone = false;
674 for (const post of ordered) {
675 activeKey = stableKey(post);
676 const state = current(projectDir, stableKey(post));
677 if (state?.phase && ['links-final', 'snapshot-saved', 'publishing', 'published', 'adopted-published'].includes(state.phase.phase)) {
678 if (state.rows.some(row => ['drafted', 'contain-drafted'].includes(row.phase) && row.recreatedAfterIntent)) {
679 await duplicates.detectExtraDrafts(projectDir, client, crosswalk, stableKey(post));
680 }
681 const result = await publishPost(projectDir, post, client, registry, sample.has(stableKey(post)),
682 { ...options, crosswalk, expectedHashes: hashes, publishedCache });
683 if (!canaryDone) {
684 if (result?.findings?.length) throw new Error(`blog canary ${stableKey(post)} failed verification`);
685 canaryDone = true;
686 const added = measured.filter(item => item.key === stableKey(post) &&
687 ['updateDraft', 'getPost', 'fetchPage', 'fetchImage'].includes(item.name));
688 const snapshots = ledger.snapshotMetrics(projectDir, stableKey(post));
689 log(`Blog canary local snapshots (measured): ${snapshots.generations} generations, ` +
690 `${snapshots.bytes} serialized bytes, ${snapshots.ms.toFixed(1)} ms.`);
691 if (added.length) {
692 const meanMs = added.reduce((sum, item) => sum + item.ms, 0) / added.length;
693 log(`Blog canary timing (measured ${added.length} added calls): mean ${meanMs.toFixed(1)} ms; ` +
694 `estimated clean added-call ceiling ${((budget.N + exactSlugUpdates + budget.L + 40) * meanMs / 1000).toFixed(1)} s. ` +
695 'The estimate excludes unchanged creates/publishes and local snapshot I/O.');
696 }
697 }
698 }
699 }
700 await duplicates.detectExtraDrafts(projectDir, client, crosswalk);
701 } finally {
702 try { finalReport = (await reports.refreshBlogHandoff(projectDir)).report; }
703 finally { ledger.setRunId(projectDir, null); }
704 }
705 return finalReport;
706 });
707}
708async function reverify({ projectDir, post, client, crosswalk, checks = [], sampled, options = {} }) {
709 validateClient(client);
710 return ledger.withLock(projectDir, async () => {
711 const state = current(projectDir, stableKey(post));
712 if (state?.phase?.phase !== 'verify-failed') throw new Error('post is not verify-failed');
713 const result = await verifyPost(projectDir, post, client, checkRegistry(checks),
714 sampled === undefined ? state.snapshots.verify?.sampled === true : sampled, 'reverify', false);
715 if (result.blocking) { await containPost(projectDir, post, client, crosswalk, [ledger.bodySha(bodyFrom(post))], options); }
716 return (await reports.refreshBlogHandoff(projectDir)).report;
717 });
718}
719
720module.exports = { runBlogBuild, reverify, sampleKeys, sortPosts, checkRegistry,
721 makeFinding, allDrafts, proveAbsence, isNotFound, expectedPath };