Setting the file. One moment. Link Verification · Wix Replatform · wix/skills · Skills DocsBundled file Link Waivers
lib/link-verification.js
JavaScript·274 lines·19 KB
const
CALIBRATION_MS
=
2
*
60
*
1000
;
8const MAX_CALIBRATION_CHECKS = 20;
9const MAX_EXCEPTION_TOKENS = 100000;
10const READ_TIMEOUT_MS = 10000;
11
12async function boundedRead(callback, row, options = {}) {
13 const controller = new AbortController();
14 let timeout;
15 try {
16 return await Promise.race([
17 callback(row, { ...options, signal: controller.signal }),
18 new Promise((_, reject) => { timeout = setTimeout(() => { controller.abort(); reject(new Error('destination read timed out')); }, READ_TIMEOUT_MS); }),
19 ]);
20 } finally { clearTimeout(timeout); }
21}
22
23function estimateCost(audit, { checkMs, checkSource = 'measured', readBackMs = checkMs, writeMs = 1500, writeSource = 'assumed-default', sessionCount = 0, retryRatio = 0, concurrency = 1, calibrationMs = 0, calibratedCount = 0, exceptionTokens = 0 } = {}) {
24 if (!Number.isFinite(checkMs) || checkMs < 0 || !Number.isFinite(calibrationMs)) throw new Error('measured check and calibration time required');
25 const count = audit.pendingDestinations.length;
26 const sampleCount = (audit.samples || []).length;
27 const writableIds = new Set((audit.writerIntegrations || []).filter((row) => row.status === 'available').flatMap((row) => row.occurrenceIds));
28 const writes = new Set((audit.occurrences || []).filter((row) => writableIds.has(row.id)).map((row) => `${row.recordType || 'unknown'}:${row.targetRecordId || row.id}`)).size;
29 const renderedLinkChecks = (audit.pendingDestinations || []).reduce((sum, row) => sum + row.occurrenceIds.filter((id) => writableIds.has(id)).length, 0);
30 const predictedMs = calibrationMs + Math.ceil((Math.max(0, count - calibratedCount) + sampleCount) / concurrency) * checkMs * (1 + retryRatio) + writes * (writeMs + readBackMs) + renderedLinkChecks * readBackMs + sessionCount * 3000;
31 return {
32 kind: 'estimate', predictedMs, exceptionTokens, withinBudget: predictedMs <= (audit.budgetDecision?.maxLiveMs || MAX_LIVE_MS) && exceptionTokens <= (audit.budgetDecision?.maxExceptionTokens || MAX_EXCEPTION_TOKENS),
33 inputs: { distinctDestinations: count, calibratedCount, familySamples: sampleCount, writes, renderedLinkChecks, concurrency, retryRatio, checkMs, checkSource, readBackMs, writeMs, writeSource, sessionCount, sessionMs: 3000, calibrationMs },
34 uncertainty: 'Destination retries, session behavior, content write latency and operator exception review may differ from inputs.',
35 };
36}
37
38async function calibrate(audit, check, { now = () => Date.now(), prior = null } = {}) {
39 if (typeof check !== 'function') throw new Error('read-only check required');
40 const start = now();
41 const results = { ...(prior && prior.results || {}) };
42 for (const row of audit.pendingDestinations.slice(0, MAX_CALIBRATION_CHECKS)) {
43 if (results[row.id] && results[row.id].result && results[row.id].result.reason !== 'read-unavailable') continue;
44 if (!results[row.id] && Object.keys(results).length >= MAX_CALIBRATION_CHECKS) break;
45 if ((prior && prior.elapsedMs || 0) + now() - start >= CALIBRATION_MS - READ_TIMEOUT_MS) break;
46 const before = now();
47 let result;
48 try { result = await boundedRead(check, row, { readOnly: true }); }
49 catch (error) { result = { reason: 'read-unavailable', errorCode: error.code || 'read-error' }; }
50 results[row.id] = { result, elapsedMs: now() - before };
51 }
52 const elapsedMs = now() - start;
53 const values = Object.values(results).map((entry) => entry.elapsedMs);
54 return { results, elapsedMs: (prior && prior.elapsedMs || 0) + elapsedMs, checkMs: values.length ? Math.max(1, values.reduce((sum, value) => sum + value, 0) / values.length) : 2000, checkSource: values.length ? 'measured' : 'assumed-default' };
55}
56
57async function verifyLinkAudit(audit, { deploymentId, destinationState, activeWriters, checkDestination, checkSample, checkRenderedLink, checkFragment, recordAdapter, journal, repair, normalizeUnresolved, readBackMs, writeMs, writeSource, sessionCount = 0, exceptionTokens = 0, now = () => Date.now(), onCheckpoint = async () => {} }) {
58 validateLinkAudit(audit);
59 if (!deploymentId || typeof checkDestination !== 'function' || typeof checkSample !== 'function') throw new Error('deployment and read-only check callbacks required');
60 if (destinationState !== 'migration-not-live') throw new Error('post-frontend repairs require a confirmed non-live migration destination');
61 if (audit.deploymentId && audit.deploymentId !== deploymentId) throw new Error('deployment changed; replan and invalidate prior verification first');
62 if (audit.stage === 'budget-hold') throw new Error('budget hold requires a recorded human decision and re-plan before verification resumes');
63 const liveLimitMs = audit.budgetDecision?.maxLiveMs || MAX_LIVE_MS;
64 // Re-evaluate the active contracts at the actual write boundary. A plan from an earlier
65 // build or an adjacent writer revision is not permission to mutate content.
66 for (const integration of audit.writerIntegrations || []) {
67 const recordType = integration.field.split(':', 1)[0];
68 const guard = writerGuard(integration.field, activeWriters, recordType);
69 integration.status = guard.ok ? 'available' : 'pending';
70 integration.reason = guard.ok ? null : guard.reason;
71 }
72 const started = now();
73 const priorElapsedMs = audit.liveElapsedMs || 0;
74 const spentMs = () => priorElapsedMs + now() - started;
75 const checkpoint = async () => { audit.liveElapsedMs = spentMs(); await onCheckpoint(audit); };
76 audit.deploymentId = deploymentId;
77 const calibration = await calibrate(audit, checkDestination, { now, prior: audit.calibration });
78 audit.calibration = calibration;
79 audit.estimate = estimateCost(audit, { checkMs: calibration.checkMs, checkSource: calibration.checkSource, readBackMs, writeMs, writeSource, sessionCount, calibrationMs: spentMs(), calibratedCount: Object.keys(calibration.results).length, exceptionTokens });
80 audit.liveStartedAt = new Date().toISOString();
81 if (!audit.estimate.withinBudget || spentMs() > liveLimitMs) {
82 audit.stage = 'budget-hold';
83 await checkpoint();
84 return audit;
85 }
86 const familyResults = new Map();
87 audit.sampleResults ||= {};
88 for (const sample of audit.samples || []) {
89 const sampleId = JSON.stringify([sample.pageFamily, sample.targetRecordId, deploymentId, audit.routeRevision]);
90 if (audit.sampleResults[sampleId]?.passed) {
91 familyResults.set(sample.pageFamily, (familyResults.get(sample.pageFamily) !== false) && audit.sampleResults[sampleId].passed);
92 continue;
93 }
94 if (spentMs() >= liveLimitMs) { audit.stage = 'budget-hold'; await checkpoint(); return audit; }
95 let result;
96 try { result = await boundedRead(checkSample, sample, { readOnly: true }); }
97 catch { result = { reason: 'sample-unavailable' }; }
98 const passed = result.identityMatched === true && result.generatedUrlMatched === true;
99 audit.sampleResults[sampleId] = { passed, checkedAt: new Date().toISOString() };
100 familyResults.set(sample.pageFamily, (familyResults.get(sample.pageFamily) !== false) && passed);
101 await checkpoint();
102 }
103 const queue = [...audit.pendingDestinations];
104 audit.pendingDestinations = [];
105 const verifiedRows = new Map();
106 for (let index = 0; index < queue.length; index += 1) {
107 const destination = queue[index];
108 if (spentMs() >= liveLimitMs) { audit.pendingDestinations.push(...queue.slice(index)); break; }
109 if (familyResults.get(destination.pageFamily) !== true) {
110 destination.reason = 'family-sample-unverified'; audit.pendingDestinations.push(destination); continue;
111 }
112 const cached = calibration.results[destination.id];
113 let check;
114 if (cached && cached.result && cached.result.reason !== 'read-unavailable') check = cached.result;
115 else try { check = await boundedRead(checkDestination, destination, { readOnly: true }); }
116 catch { check = { reason: 'read-unavailable' }; }
117 if (!check || check.identityMatched !== true || check.generatedUrlMatched !== true || check.rendered !== true || check.deploymentId !== deploymentId || check.path !== destination.expectedPath) {
118 destination.reason = check && check.reason || 'served-route-unverified'; audit.pendingDestinations.push(destination); continue;
119 }
120 const verified = verifiedDestination({ ...check, targetRecordId: destination.targetRecordId, pageFamily: destination.pageFamily });
121 if (recordAdapter) {
122 for (const id of destination.occurrenceIds) verifiedRows.set(id, { destination, verified });
123 audit.pendingDestinations.push(destination);
124 const completedPrefix = audit.pendingDestinations;
125 audit.pendingDestinations = [...completedPrefix, ...queue.slice(index + 1)];
126 await checkpoint();
127 audit.pendingDestinations = completedPrefix;
128 continue;
129 }
130 let held = false;
131 for (const id of destination.occurrenceIds) {
132 const occurrence = audit.occurrences.find((row) => row.id === id);
133 if (occurrence.classification !== 'internal-navigation') continue;
134 const field = `${occurrence.recordType || 'unknown'}:${occurrence.field}`;
135 const integration = (audit.writerIntegrations || []).find((row) => row.field === field);
136 if (!integration || integration.status !== 'available' || typeof repair !== 'function') { held = true; continue; }
137 const original = new URL(occurrence.originalPath, 'https://link-audit.invalid');
138 const repairedPath = `${verified.path}${original.search}${original.hash}`;
139 const result = await repair(occurrence, { ...verified, repairedPath });
140 if (!result || result.status !== 'read-back-verified' || result.rendered !== true) { held = true; continue; }
141 audit.outcomes.push({ occurrenceId: id, status: 'verified', targetRecordId: destination.targetRecordId, expectedPath: destination.expectedPath, deploymentId, contentRevision: result.contentRevision || null, checkedAt: verified.checkedAt });
142 }
143 if (held) audit.pendingDestinations.push({ ...destination, occurrenceIds: destination.occurrenceIds.filter((id) => !audit.outcomes.some((row) => row.occurrenceId === id)), reason: 'writer-or-link-verification-pending' });
144 // Include the unprocessed queue in the durable checkpoint, without duplicating any
145 // occurrence that has already reached an outcome.
146 const completedPrefix = audit.pendingDestinations;
147 audit.pendingDestinations = [...completedPrefix, ...queue.slice(index + 1)];
148 await checkpoint();
149 audit.pendingDestinations = completedPrefix;
150 }
151 if (recordAdapter) {
152 if (!journal || typeof recordAdapter.read !== 'function' || typeof recordAdapter.write !== 'function' || typeof recordAdapter.patchField !== 'function' || typeof checkRenderedLink !== 'function') {
153 throw new Error('guarded record adapter, journal and rendered-link check required');
154 }
155 const groups = new Map();
156 for (const [id, row] of verifiedRows) {
157 const occurrence = audit.occurrences.find((item) => item.id === id);
158 if (!occurrence || !occurrence.targetRecordId || !occurrence.recordType) continue;
159 const key = `${occurrence.recordType}\0${occurrence.targetRecordId}`;
160 if (!groups.has(key)) groups.set(key, { recordId: occurrence.targetRecordId, recordType: occurrence.recordType, rows: [] });
161 groups.get(key).rows.push({ occurrence, ...row });
162 }
163 for (const group of groups.values()) {
164 if (spentMs() >= liveLimitMs) break;
165 const byField = new Map();
166 const destinations = {};
167 for (const row of group.rows) {
168 const fieldName = row.occurrence.field;
169 const fieldKey = `${group.recordType}:${fieldName}`;
170 if (!audit.writerIntegrations.some((item) => item.field === fieldKey && item.status === 'available')) continue;
171 if (!row.occurrence.targetContentRevision) {
172 const pending = audit.pendingDestinations.find((item) => item.occurrenceIds.includes(row.occurrence.id));
173 if (pending) pending.reason = 'missing-content-revision';
174 continue;
175 }
176 if (audit.pendingDestinations.some((item) => item.occurrenceIds.some((id) => {
177 const occurrence = audit.occurrences.find((candidate) => candidate.id === id);
178 return occurrence && occurrence.targetRecordId === group.recordId && occurrence.recordType === group.recordType && occurrence.field === fieldName && !verifiedRows.has(id);
179 }))) continue;
180 if (!byField.has(fieldName)) byField.set(fieldName, { name: fieldName, occurrenceIds: [], patches: [], expectedRevision: row.occurrence.targetContentRevision });
181 if (byField.get(fieldName).expectedRevision !== row.occurrence.targetContentRevision) throw new Error('inconsistent planned content revisions');
182 const original = new URL(row.occurrence.originalPath, 'https://link-audit.invalid');
183 const repairedPath = `${row.verified.path}${original.search}${original.hash}`;
184 byField.get(fieldName).occurrenceIds.push(row.occurrence.id);
185 byField.get(fieldName).patches.push({ occurrence: row.occurrence, destinationId: row.destination.id, path: repairedPath });
186 destinations[row.destination.id] = row.verified;
187 }
188 if (!byField.size) continue;
189 const fields = [...byField.values()];
190 const intentKey = JSON.stringify([audit.runId, audit.sourceSnapshot, audit.routeRevision, deploymentId, group.recordType, group.recordId, fields.flatMap((field) => field.occurrenceIds).sort()]);
191 let result;
192 try {
193 const priorIntentKeys = [...new Set(group.rows.map((row) => audit.priorWriteIntents?.[row.occurrence.id]).filter(Boolean))];
194 result = await reconcileRecord({ recordId: group.recordId, recordType: group.recordType, fields, destinations, activeWriters, destinationState, adapter: recordAdapter, journal, intentKey, priorIntentKeys });
195 } catch (error) {
196 for (const row of group.rows) {
197 const pending = audit.pendingDestinations.find((item) => item.occurrenceIds.includes(row.occurrence.id));
198 if (pending) pending.reason = error.message === 'uncertain write conflicts with changed content' ? 'concurrent-edit' : 'writer-or-link-verification-pending';
199 }
200 await checkpoint();
201 continue;
202 }
203 for (const blocked of result.blocked || []) {
204 for (const pending of audit.pendingDestinations) {
205 if (pending.occurrenceIds.some((id) => blocked.occurrenceIds.includes(id))) pending.reason = blocked.reason;
206 }
207 }
208 // A successful backend write remains reusable even if this deployment has not
209 // rendered it yet. The journal after-image is checked again on the next plan.
210 if (result.written.length) {
211 audit.priorWriteIntents ||= {};
212 for (const row of group.rows) if (result.written.includes(row.occurrence.field)) {
213 audit.priorWriteIntents[row.occurrence.id] = result.priorIntentKey || intentKey;
214 }
215 await checkpoint();
216 }
217 for (const row of group.rows) {
218 if (spentMs() >= liveLimitMs) break;
219 if (!result.written.includes(row.occurrence.field)) continue;
220 const original = new URL(row.occurrence.originalPath, 'https://link-audit.invalid');
221 const repairedPath = `${row.verified.path}${original.search}${original.hash}`;
222 let rendered;
223 try { rendered = await boundedRead(checkRenderedLink, row.occurrence, { readOnly: true, repairedPath, deploymentId }); }
224 catch { rendered = null; }
225 if (!rendered || rendered.rendered !== true || rendered.path !== repairedPath) continue;
226 audit.outcomes.push({ occurrenceId: row.occurrence.id, status: 'verified', targetRecordId: row.destination.targetRecordId, containingRecordId: group.recordId, expectedPath: row.destination.expectedPath, deploymentId, contentRevision: result.contentRevision, writeIntentKey: result.priorIntentKey || intentKey, checkedAt: row.verified.checkedAt });
227 for (const pending of audit.pendingDestinations) pending.occurrenceIds = pending.occurrenceIds.filter((id) => id !== row.occurrence.id);
228 }
229 audit.pendingDestinations = audit.pendingDestinations.filter((row) => row.occurrenceIds.length);
230 await checkpoint();
231 }
232 }
233 // Same-page fragments are checked as anchors and never passed to a destination writer.
234 for (const unresolved of [...audit.unresolved]) {
235 if (unresolved.detail !== 'fragment-anchor-unverified' || typeof checkFragment !== 'function') continue;
236 const occurrence = audit.occurrences.find((row) => row.id === unresolved.occurrenceIds[0]);
237 let result;
238 try { result = await boundedRead(checkFragment, occurrence, { readOnly: true, deploymentId }); } catch { result = null; }
239 if (!result || result.anchorExists !== true || result.deploymentId !== deploymentId) continue;
240 audit.unresolved = audit.unresolved.filter((row) => row !== unresolved);
241 audit.outcomes.push({ occurrenceId: occurrence.id, status: 'verified-fragment', deploymentId, checkedAt: new Date().toISOString() });
242 await checkpoint();
243 }
244 for (const unresolved of audit.unresolved) {
245 if (spentMs() >= liveLimitMs) break;
246 if (!unresolved.originalPath || !unresolved.originalPath.startsWith('/') || unresolved.originalPath.startsWith('//')) continue;
247 for (const id of unresolved.occurrenceIds) {
248 const occurrence = audit.occurrences.find((row) => row.id === id);
249 if (occurrence.classification !== 'internal-navigation') continue;
250 const field = `${occurrence.recordType || 'unknown'}:${occurrence.field}`;
251 const integration = (audit.writerIntegrations || []).find((row) => row.field === field);
252 if (!integration || integration.status !== 'available' || typeof normalizeUnresolved !== 'function') continue;
253 const result = await normalizeUnresolved(occurrence, unresolved.originalPath);
254 if (result && result.status === 'read-back-verified') {
255 unresolved.normalizationStatus = 'read-back-verified';
256 unresolved.normalizedPath = unresolved.originalPath;
257 await checkpoint();
258 }
259 }
260 }
261 audit.verification = { deploymentId, checkedAt: new Date().toISOString(), elapsedMs: spentMs(), exceptionTokens };
262 for (const integration of audit.writerIntegrations || []) {
263 if (integration.status === 'available' && integration.occurrenceIds.every((id) => audit.outcomes.some((row) => row.occurrenceId === id && row.status === 'verified') || audit.unresolved.some((row) => row.normalizationStatus === 'read-back-verified' && row.occurrenceIds.includes(id)))) {
264 integration.status = 'complete';
265 }
266 }
267 audit.stage = audit.pendingDestinations.length || audit.unresolved.length ? 'incomplete' : 'verified';
268 if (spentMs() >= liveLimitMs) audit.stage = 'budget-hold';
269 validateLinkAudit(audit);
270 await checkpoint();
271 return audit;
272}
273
274module.exports = { MAX_LIVE_MS, MAX_EXCEPTION_TOKENS, estimateCost, calibrate, verifyLinkAudit };