Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 9 additions & 143 deletions packages/spacecat-shared-tokowaka-client/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,13 @@ import {
} from './utils/s3-utils.js';
import {
omitKeys,
isEdgeDeployableSuggestionStatus,
isPatternSuggestion,
groupSuggestionsByUrlPath,
filterEligibleSuggestions,
saveSuggestions,
stripSuggestion,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): This PR removes all callers of filterBatchCoveredSuggestions (imported here until this change) but leaves the function itself in src/utils/suggestion-utils.js. That dead code drops line coverage below the 100% threshold enforced by .nycrc.json, which is why CI fails.

Why it matters: CI is red and the PR cannot merge.

How to fix: Remove filterBatchCoveredSuggestions from suggestion-utils.js and its buildUrlMatcher import from pattern-utils.js (also now unused in that file). If any external consumer needs filterBatchCoveredSuggestions, the async worker that now owns covered-marking should own the function.

cleanupCoveredSuggestions,
classifySuggestions,
filterBatchCoveredSuggestions,
} from './utils/suggestion-utils.js';
import { buildUrlMatcher } from './utils/pattern-utils.js';
import { getEffectiveBaseURL } from './utils/site-utils.js';
import { removePatternFromMetaconfig, addPatternsToMetaconfig } from './utils/metaconfig-utils.js';
import { fetchHtmlWithWarmup, calculateForwardedHost } from './utils/custom-html-utils.js';
Expand Down Expand Up @@ -909,16 +905,12 @@ class TokowakaClient {
* @param {Object} opportunity - Opportunity entity
* @param {Array} suggestions - Array of suggestion entities to rollback
* @param {Object} [options] - Optional parameters
* @param {Array} [options.allSuggestions=[]] - All suggestions for the opportunity,
* used to clean up suggestions covered by a domain-wide or path-level pattern rollback
* @param {string} [options.updatedBy] - Actor identity (e.g. profile email). When undefined,
* context-specific fallback strings are used: 'tokowaka-rollback' for direct suggestions,
* 'domain-wide-rollback' for per-URL suggestions covered by a domain-wide pattern, and
* 'path-rollback' for per-URL suggestions covered by a path pattern.
* 'tokowaka-rollback' is used.
* @returns {Promise<Object>} - Rollback result with succeeded/failed suggestions
*/
// eslint-disable-next-line max-len
async rollbackSuggestions(site, opportunity, suggestions, { allSuggestions = [], updatedBy } = {}) {
async rollbackSuggestions(site, opportunity, suggestions, { updatedBy } = {}) {
const opportunityType = opportunity.getType();
const baseURL = getEffectiveBaseURL(site);
const mapper = this.mapperRegistry.getMapper(opportunityType);
Expand All @@ -935,7 +927,7 @@ class TokowakaClient {
const patternSuggestions = suggestions.filter(isPatternSuggestion);
const perUrlSuggestions = suggestions.filter((s) => !isPatternSuggestion(s));
// eslint-disable-next-line max-len
this.log.info(`[edge-rollback] Site: ${baseURL}, total: ${suggestions.length}, pattern: ${patternSuggestions.length}, perUrl: ${perUrlSuggestions.length}, allSuggestions: ${allSuggestions.length}`);
this.log.info(`[edge-rollback] Site: ${baseURL}, total: ${suggestions.length}, pattern: ${patternSuggestions.length}, perUrl: ${perUrlSuggestions.length}`);

const {
eligible: eligibleSuggestions,
Expand Down Expand Up @@ -1058,27 +1050,6 @@ class TokowakaClient {
}));
}
}

// Clean up covered suggestions for each succeeded pattern suggestion.
for (const suggestion of succeededPatternSuggestions) {
const suggData = suggestion.getData();
const isDomainWide = suggData?.isDomainWide === true;
const coveredFallback = isDomainWide ? 'domain-wide-rollback' : 'path-rollback';
const coverageField = isDomainWide
? 'coveredByDomainWide' : 'coveredByPattern';
const covered = allSuggestions.filter(
(s) => s.getData()?.[coverageField] === suggestion.getId(),
);
const fieldsToStrip = isDomainWide
? ['coveredByDomainWide']
: ['edgeDeployed', 'tokowakaDeployed', 'coveredByPattern'];
if (covered.length > 0) {
// eslint-disable-next-line max-len
this.log.info(`[edge-rollback] Cleaning ${covered.length} covered suggestion(s) for pattern ${suggestion.getId()} (isDomainWide=${isDomainWide}, fallback=${coveredFallback})`);
}
// eslint-disable-next-line no-await-in-loop, max-len
await cleanupCoveredSuggestions(this.dataAccess, covered, coveredFallback, updatedBy, fieldsToStrip, this.log);
}
}
}

Expand Down Expand Up @@ -1494,16 +1465,13 @@ class TokowakaClient {

/**
* Deploys a single pattern suggestion by appending its patterns to the metaconfig allowList
* (append-and-deduplicate; CDN write is skipped when nothing changes), stamps the suggestion
* with edgeDeployed, and marks qualifying per-URL suggestions as covered.
* (append-and-deduplicate; CDN write is skipped when nothing changes), and stamps
* the suggestion with edgeDeployed.
* @param {Object} suggestion - The pattern suggestion entity
* @param {Array<string>} allowedRegexPatterns
* @param {Object} metaconfig - Metaconfig object (mutated in place)
* @param {string} baseURL
* @param {Set<string>} skippedInBatchIds - IDs already handled as same-batch skips
* @param {Array} allSuggestions - Full opportunity suggestion list
* @param {string} updatedBy
* @param {Array} coveredSuggestions - Accumulator (mutated)
* @returns {Promise<void>} Throws on metaconfig upload failure
* @private
*/
Expand All @@ -1512,14 +1480,8 @@ class TokowakaClient {
allowedRegexPatterns,
metaconfig,
baseURL,
skippedInBatchIds,
allSuggestions,
updatedBy,
coveredSuggestions,
) {
const data = suggestion.getData();
const coverageField = data?.isDomainWide ? 'coveredByDomainWide' : 'coveredByPattern';

const beforeAllowList = metaconfig.prerender?.allowList ?? [];
const changed = addPatternsToMetaconfig(metaconfig, allowedRegexPatterns);
const afterAllowList = changed ? metaconfig.prerender.allowList : beforeAllowList;
Expand All @@ -1540,103 +1502,39 @@ class TokowakaClient {
suggestion.setData({ ...suggestion.getData(), edgeDeployed: Date.now() });
suggestion.setUpdatedBy(updatedBy);
// suggestion.save() is deferred — caller batches saves via saveSuggestions.

// Mark per-URL suggestions covered by this pattern.
const matchers = allowedRegexPatterns.flatMap((p) => {
const m = buildUrlMatcher(p);
if (!m) {
// eslint-disable-next-line max-len
this.log.warn(`[edge-deploy] Pattern '${p}' for suggestion ${suggestion.getId()} is invalid, skipping`);
}
return m ? [m] : [];
});

if (matchers.length === 0) {
return;
}

const covered = allSuggestions.filter((s) => {
if (s.getId() === suggestion.getId()) {
return false;
}
if (skippedInBatchIds.has(s.getId())) {
return false;
}
if (!isEdgeDeployableSuggestionStatus(s.getStatus())) {
return false;
}
if (s.getData()?.edgeDeployed) {
return false;
}
// Path-level pattern suggestions (not domain-wide) are fully covered by a
// domain-wide deployment. Other pattern suggestions (including other DW ones) are not.
if (isPatternSuggestion(s)) {
return coverageField === 'coveredByDomainWide' && !s.getData()?.isDomainWide;
}
const url = s.getData()?.url;
return url && matchers.some((match) => match(url));
});

// eslint-disable-next-line max-len
this.log.info(`[edge-deploy] Pattern ${suggestion.getId()}: found ${covered.length} coverable per-URL suggestions (field=${coverageField})`);

if (covered.length > 0) {
covered.forEach((cs) => {
cs.setData({ ...cs.getData(), [coverageField]: suggestion.getId() });
cs.setUpdatedBy(updatedBy);
});
try {
await saveSuggestions(this.dataAccess, covered);
coveredSuggestions.push(...covered);
// eslint-disable-next-line max-len
this.log.info(`[edge-deploy] Marked ${covered.length} suggestions as ${coverageField}=${suggestion.getId()}`);
} catch (coverError) {
this.log.warn(
'[edge-deploy] Failed to mark covered suggestions for pattern suggestion '
+ `${suggestion.getId()}: ${coverError.message}`,
);
}
}
}

/**
* Deploys suggestions to edge, handling both regular and domain-wide suggestions.
*
* Regular suggestions are deployed via deploySuggestions(). Domain-wide suggestions
* update the site metaconfig's prerender allowList. Suggestions covered by domain-wide
* patterns (in the same batch or across the whole opportunity) are automatically marked.
* update the site metaconfig's prerender allowList.
*
* @param {Object} params
* @param {Object} params.site - Site entity
* @param {Object} params.opportunity - Opportunity entity
* @param {Array} params.targetSuggestions - Suggestions selected for this deployment
* @param {Array} params.allSuggestions - All suggestions for the opportunity
* @param {string} [params.updatedBy='edge-deploy'] - Value for updatedBy on saved suggestions
* @returns {Promise<Object>} { succeededSuggestions, failedSuggestions, coveredSuggestions }
* - succeededSuggestions: suggestion entities that were deployed
* - failedSuggestions: { suggestion, reason } items that couldn't be deployed
* - coveredSuggestions: suggestion entities auto-marked via domain-wide patterns
* - coveredSuggestions: always empty; covered marking now runs asynchronously
*/
async deployToEdge({
site,
opportunity,
targetSuggestions,
allSuggestions,
updatedBy = 'edge-deploy',
metadata = {},
}) {
// Step 1: classify suggestions into pattern vs per-URL buckets.
// eslint-disable-next-line max-len
const { patternSuggestions, validSuggestions: classified } = classifySuggestions(targetSuggestions, this.log);
const { patternSuggestions, validSuggestions } = classifySuggestions(targetSuggestions, this.log);
this.log.info(
`[edge-deploy] Classification: pattern=${patternSuggestions.length},`
+ ` perUrl=${classified.length}, allSuggestions=${allSuggestions.length}`,
+ ` perUrl=${validSuggestions.length}`,
);

// Step 2: remove per-URL suggestions already covered by a pattern in this batch.
// eslint-disable-next-line max-len
const { remaining: validSuggestions, skippedInBatch } = filterBatchCoveredSuggestions(classified, patternSuggestions, this.log);

let succeededSuggestions = [];
const failedSuggestions = [];

Expand All @@ -1653,7 +1551,6 @@ class TokowakaClient {
const coveredSuggestions = [];
const deployedPatternSuggestions = [];
if (patternSuggestions.length > 0) {
const skippedInBatchIds = new Set(skippedInBatch.map((item) => item.suggestion.getId()));
const baseURL = site.getBaseURL();
let metaconfig = await this.fetchMetaconfig(baseURL);
if (!metaconfig) {
Expand All @@ -1675,10 +1572,7 @@ class TokowakaClient {
allowedRegexPatterns,
metaconfig,
baseURL,
skippedInBatchIds,
allSuggestions,
updatedBy,
coveredSuggestions,
);
deployedPatternSuggestions.push(suggestion);
} catch (error) {
Expand All @@ -1702,34 +1596,6 @@ class TokowakaClient {
}
}
}

// Step 5: mark same-batch skipped suggestions, then batch-save.
if (skippedInBatch.length > 0) {
const skippedSuggestions = skippedInBatch.map((item) => {
const coverageField = item.isDomainWide ? 'coveredByDomainWide' : 'coveredByPattern';
item.suggestion.setData({
...item.suggestion.getData(),
[coverageField]: 'same-batch-deployment',
skippedInDeployment: true,
});
item.suggestion.setUpdatedBy(updatedBy);
return item.suggestion;
});

try {
await saveSuggestions(this.dataAccess, skippedSuggestions);
succeededSuggestions.push(...skippedSuggestions);
coveredSuggestions.push(...skippedSuggestions);
} catch (error) {
// eslint-disable-next-line max-len
this.log.warn(`[edge-deploy] Failed to save same-batch skipped suggestions: ${error.message}`);
skippedSuggestions.forEach((s) => {
// eslint-disable-next-line max-len
failedSuggestions.push({ suggestion: s, reason: 'Failed to mark as covered', statusCode: 500 });
});
}
}

return { succeededSuggestions, failedSuggestions, coveredSuggestions };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
* governing permissions and limitations under the License.
*/

import { buildUrlMatcher } from './pattern-utils.js';

/** Returns a shallow copy of obj with the specified keys removed. */
export function omitKeys(obj, keys) {
const keySet = new Set(keys);
Expand Down Expand Up @@ -139,36 +137,6 @@ export function stripSuggestion(suggestion, actorFallback, updatedBy) {
return suggestion;
}

/**
* Clears coverage and deployment markers from suggestions that were covered by a pattern.
* Only strips the fields relevant to the rollback type so independent coverage layers
* are preserved. For example, rolling back domain-wide should only clear
* coveredByDomainWide — not coveredByPattern (which belongs to a separate path deploy).
* @param {Object} dataAccess - Data access layer
* @param {Array} covered - Covered suggestion entities
* @param {string} actorFallback - Fallback updatedBy string
* @param {string|undefined} updatedBy - Explicit actor
* @param {string[]} fieldsToStrip - Specific fields to remove
* @param {Object} log - Logger instance
* @returns {Promise<void>}
*/
// eslint-disable-next-line max-len
export async function cleanupCoveredSuggestions(dataAccess, covered, actorFallback, updatedBy, fieldsToStrip, log) {
if (covered.length === 0) {
return;
}
covered.forEach((cs) => {
cs.setData(omitKeys(cs.getData(), fieldsToStrip));
cs.setUpdatedBy(updatedBy ?? actorFallback);
});
try {
await saveSuggestions(dataAccess, covered);
} catch (error) {
// eslint-disable-next-line max-len
log.error(`[edge-rollback-failed] Failed to clean ${covered.length} covered suggestion(s): ${error.message}`);
}
}

/**
* Classifies a batch of target suggestions into pattern-based and per-URL buckets.
* Pattern suggestions (domain-wide or path-level) are returned as
Expand Down Expand Up @@ -198,46 +166,3 @@ export function classifySuggestions(targetSuggestions, log) {

return { patternSuggestions, validSuggestions };
}

/**
* Splits validSuggestions into those covered by an in-batch pattern and those that are not.
* Returns the two arrays; validSuggestions itself is not mutated.
* @param {Array} validSuggestions - Per-URL suggestions
* @param {Array} patternSuggestions - Pattern suggestions in the same batch
* @param {Object} log - Logger instance
* @returns {{ remaining: Array, skippedInBatch: Array }}
*/
export function filterBatchCoveredSuggestions(validSuggestions, patternSuggestions, log) {
if (patternSuggestions.length === 0 || validSuggestions.length === 0) {
return { remaining: validSuggestions, skippedInBatch: [] };
}

// Build matchers per pattern suggestion so we can track which type covered each URL.
// eslint-disable-next-line max-len
const matcherEntries = patternSuggestions.flatMap(({ suggestion, allowedRegexPatterns }) => {
const isDomainWide = suggestion.getData()?.isDomainWide === true;
return allowedRegexPatterns
.map(buildUrlMatcher)
.filter(Boolean)
.map((matcher) => ({ matcher, isDomainWide }));
});

if (matcherEntries.length === 0) {
return { remaining: validSuggestions, skippedInBatch: [] };
}

const remaining = [];
const skippedInBatch = [];
validSuggestions.forEach((s) => {
const url = s.getData()?.url;
const match = url && matcherEntries.find(({ matcher }) => matcher(url));
if (match) {
skippedInBatch.push({ suggestion: s, isDomainWide: match.isDomainWide });
log.info(`[edge-deploy] Skipping suggestion ${s.getId()} - covered by pattern`);
} else {
remaining.push(s);
}
});

return { remaining, skippedInBatch };
}
Loading
Loading