@adobe/spacecat-shared-tokowaka-client 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [@adobe/spacecat-shared-tokowaka-client-v1.1.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.0.6...@adobe/spacecat-shared-tokowaka-client-v1.1.0) (2025-11-25)
2
+
3
+
4
+ ### Features
5
+
6
+ * edge rollback feature for tokowaka ([#1167](https://github.com/adobe/spacecat-shared/issues/1167)) ([b47fd05](https://github.com/adobe/spacecat-shared/commit/b47fd0518ae1fe220914bfd88810383a306775fd))
7
+
1
8
  # [@adobe/spacecat-shared-tokowaka-client-v1.0.6](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.0.5...@adobe/spacecat-shared-tokowaka-client-v1.0.6) (2025-11-22)
2
9
 
3
10
 
package/README.md CHANGED
@@ -51,6 +51,22 @@ Generates configuration and uploads to S3. **Automatically fetches existing conf
51
51
  - `succeededSuggestions` - Array of deployed suggestions
52
52
  - `failedSuggestions` - Array of `{suggestion, reason}` objects for ineligible suggestions
53
53
 
54
+ #### `rollbackSuggestions(site, opportunity, suggestions)`
55
+
56
+ Rolls back previously deployed suggestions by removing their patches from the configuration. **Automatically fetches existing configuration** and removes patches matching the provided suggestions. Invalidates CDN cache after upload.
57
+
58
+ **Mapper-Specific Rollback Behavior:**
59
+ - Each opportunity mapper handles its own rollback logic via `rollbackPatches()` method
60
+ - **FAQ:** Automatically removes the "FAQs" heading patch if no FAQ suggestions remain for that URL
61
+ - **Headings/Summarization:** Simple removal by suggestion ID (default behavior)
62
+
63
+ **Returns:** `Promise<RollbackResult>` with:
64
+ - `s3Path` - S3 key where config was uploaded
65
+ - `cdnInvalidation` - CDN invalidation result (or error)
66
+ - `succeededSuggestions` - Array of rolled back suggestions
67
+ - `failedSuggestions` - Array of `{suggestion, reason}` objects for ineligible suggestions
68
+ - `removedPatchesCount` - Number of patches removed from the configuration
69
+
54
70
  #### `fetchConfig(apiKey)`
55
71
 
56
72
  Fetches existing Tokowaka configuration from S3.
@@ -94,7 +110,6 @@ s3://{TOKOWAKA_SITE_CONFIG_BUCKET}/opportunities/{tokowakaApiKey}
94
110
 
95
111
  **Note:** The configuration is stored as a JSON file containing the complete Tokowaka optimization config for the site.
96
112
 
97
-
98
113
  ## Reference Material
99
114
 
100
115
  https://wiki.corp.adobe.com/display/AEMSites/Tokowaka+-+Spacecat+Integration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.0.6",
3
+ "version": "1.1.0",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
package/src/index.js CHANGED
@@ -407,6 +407,112 @@ class TokowakaClient {
407
407
  };
408
408
  }
409
409
 
410
+ /**
411
+ * Rolls back deployed suggestions by removing their patches from the configuration
412
+ * @param {Object} site - Site entity
413
+ * @param {Object} opportunity - Opportunity entity
414
+ * @param {Array} suggestions - Array of suggestion entities to rollback
415
+ * @returns {Promise<Object>} - Rollback result with succeeded/failed suggestions
416
+ */
417
+ async rollbackSuggestions(site, opportunity, suggestions) {
418
+ // Get site's Tokowaka API key
419
+ const { apiKey } = site.getConfig()?.getTokowakaConfig() || {};
420
+
421
+ if (!hasText(apiKey)) {
422
+ throw this.#createError(
423
+ 'Site does not have a Tokowaka API key configured. Please onboard the site to Tokowaka first.',
424
+ HTTP_BAD_REQUEST,
425
+ );
426
+ }
427
+
428
+ const opportunityType = opportunity.getType();
429
+ const mapper = this.mapperRegistry.getMapper(opportunityType);
430
+ if (!mapper) {
431
+ throw this.#createError(
432
+ `No mapper found for opportunity type: ${opportunityType}. `
433
+ + `Supported types: ${this.mapperRegistry.getSupportedOpportunityTypes().join(', ')}`,
434
+ HTTP_NOT_IMPLEMENTED,
435
+ );
436
+ }
437
+
438
+ // Validate which suggestions can be rolled back
439
+ // For rollback, we use the same canDeploy check to ensure data integrity
440
+ const {
441
+ eligible: eligibleSuggestions,
442
+ ineligible: ineligibleSuggestions,
443
+ } = filterEligibleSuggestions(suggestions, mapper);
444
+
445
+ this.log.debug(
446
+ `Rolling back ${eligibleSuggestions.length} eligible suggestions `
447
+ + `(${ineligibleSuggestions.length} ineligible)`,
448
+ );
449
+
450
+ if (eligibleSuggestions.length === 0) {
451
+ this.log.warn('No eligible suggestions to rollback');
452
+ return {
453
+ succeededSuggestions: [],
454
+ failedSuggestions: ineligibleSuggestions,
455
+ };
456
+ }
457
+
458
+ // Fetch existing configuration from S3
459
+ this.log.debug(`Fetching existing Tokowaka config for site ${site.getId()}`);
460
+ const existingConfig = await this.fetchConfig(apiKey);
461
+
462
+ if (!existingConfig || !existingConfig.tokowakaOptimizations) {
463
+ this.log.warn('No existing configuration found to rollback from');
464
+ return {
465
+ succeededSuggestions: [],
466
+ failedSuggestions: eligibleSuggestions.map((suggestion) => ({
467
+ suggestion,
468
+ reason: 'No existing configuration found',
469
+ })),
470
+ };
471
+ }
472
+
473
+ // Extract suggestion IDs to remove
474
+ const suggestionIdsToRemove = eligibleSuggestions.map((s) => s.getId());
475
+ const updatedConfig = mapper.rollbackPatches(
476
+ existingConfig,
477
+ suggestionIdsToRemove,
478
+ opportunity.getId(),
479
+ );
480
+
481
+ if (updatedConfig.removedCount === 0) {
482
+ this.log.warn('No patches found matching the provided suggestion IDs');
483
+ return {
484
+ succeededSuggestions: [],
485
+ failedSuggestions: eligibleSuggestions.map((suggestion) => ({
486
+ suggestion,
487
+ reason: 'No patches found for this suggestion in the current configuration',
488
+ })),
489
+ };
490
+ }
491
+
492
+ this.log.info(`Removed ${updatedConfig.removedCount} patches from configuration`);
493
+
494
+ // Remove the removedCount property before uploading
495
+ const { removedCount, ...configToUpload } = updatedConfig;
496
+
497
+ // Upload updated config to S3
498
+ this.log.info(`Uploading updated Tokowaka config after rolling back ${eligibleSuggestions.length} suggestions`);
499
+ const s3Path = await this.uploadConfig(apiKey, configToUpload);
500
+
501
+ // Invalidate CDN cache (non-blocking, failures are logged but don't fail rollback)
502
+ const cdnInvalidationResult = await this.invalidateCdnCache(
503
+ apiKey,
504
+ this.env.TOKOWAKA_CDN_PROVIDER,
505
+ );
506
+
507
+ return {
508
+ s3Path,
509
+ cdnInvalidation: cdnInvalidationResult,
510
+ succeededSuggestions: eligibleSuggestions,
511
+ failedSuggestions: ineligibleSuggestions,
512
+ removedPatchesCount: removedCount,
513
+ };
514
+ }
515
+
410
516
  /**
411
517
  * Previews suggestions by generating config and uploading to preview path
412
518
  * Unlike deploySuggestions, this does NOT merge with existing config
@@ -10,6 +10,8 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import { removePatchesBySuggestionIds } from '../utils/patch-utils.js';
14
+
13
15
  /**
14
16
  * Base class for opportunity mappers
15
17
  * Each opportunity type should extend this class and implement the abstract methods
@@ -93,4 +95,24 @@ export default class BaseOpportunityMapper {
93
95
  lastUpdated,
94
96
  };
95
97
  }
98
+
99
+ /**
100
+ * Removes patches from configuration for given suggestions
101
+ * Default implementation simply removes patches matching the suggestion IDs.
102
+ * Override this method in subclasses if custom rollback logic is needed
103
+ * (e.g., FAQ mapper removes heading patch when no suggestions remain).
104
+ * @param {Object} config - Current Tokowaka configuration
105
+ * @param {Array<string>} suggestionIds - Suggestion IDs to remove
106
+ * @param {string} opportunityId - Opportunity ID
107
+ * @returns {Object} - Updated configuration with patches removed
108
+ */
109
+ // eslint-disable-next-line no-unused-vars
110
+ rollbackPatches(config, suggestionIds, opportunityId) {
111
+ if (!config || !config.tokowakaOptimizations) {
112
+ return config;
113
+ }
114
+
115
+ this.log.debug(`Removing patches for ${suggestionIds.length} suggestions`);
116
+ return removePatchesBySuggestionIds(config, suggestionIds);
117
+ }
96
118
  }
@@ -14,6 +14,7 @@ import { hasText, isValidUrl } from '@adobe/spacecat-shared-utils';
14
14
  import { TARGET_USER_AGENTS_CATEGORIES } from '../constants.js';
15
15
  import BaseOpportunityMapper from './base-mapper.js';
16
16
  import { markdownToHast } from '../utils/markdown-utils.js';
17
+ import { removePatchesBySuggestionIds } from '../utils/patch-utils.js';
17
18
 
18
19
  /**
19
20
  * Mapper for FAQ opportunity
@@ -201,4 +202,51 @@ export default class FaqMapper extends BaseOpportunityMapper {
201
202
 
202
203
  return { eligible: true };
203
204
  }
205
+
206
+ /**
207
+ * Removes patches from configuration for FAQ suggestions
208
+ * FAQ-specific logic: Also removes the heading patch when no FAQ suggestions remain for a URL
209
+ * @param {Object} config - Current Tokowaka configuration
210
+ * @param {Array<string>} suggestionIds - Suggestion IDs to remove
211
+ * @param {string} opportunityId - Opportunity ID
212
+ * @returns {Object} - Updated configuration with patches removed
213
+ */
214
+ rollbackPatches(config, suggestionIds, opportunityId) {
215
+ if (!config || !config.tokowakaOptimizations) {
216
+ return config;
217
+ }
218
+
219
+ const suggestionIdsSet = new Set(suggestionIds);
220
+ const additionalPatchKeys = [];
221
+
222
+ // Analyze each URL to determine if FAQ heading patch should be removed
223
+ Object.entries(config.tokowakaOptimizations).forEach(([urlPath, optimization]) => {
224
+ const { patches = [] } = optimization;
225
+
226
+ // Find FAQ patches for this opportunity
227
+ const opportunityPatches = patches.filter((p) => p.opportunityId === opportunityId);
228
+
229
+ // Get FAQ suggestion IDs that will remain after rollback (for this URL)
230
+ const remainingSuggestionIds = opportunityPatches
231
+ .filter((p) => p.suggestionId && !suggestionIdsSet.has(p.suggestionId))
232
+ .map((p) => p.suggestionId);
233
+
234
+ // If no FAQ suggestions remain for this URL, remove the heading patch too
235
+ if (remainingSuggestionIds.length === 0) {
236
+ this.log.debug(`No remaining FAQ suggestions for ${urlPath}, marking heading patch for removal`);
237
+ // Add heading patch key (opportunityId only, no suggestionId)
238
+ additionalPatchKeys.push(`${urlPath}:${opportunityId}`);
239
+ } else {
240
+ this.log.debug(`${remainingSuggestionIds.length} FAQ suggestions remain for ${urlPath}, keeping heading patch`);
241
+ }
242
+ });
243
+
244
+ // Remove FAQ suggestion patches and any orphaned heading patches
245
+ this.log.debug(
246
+ `Removing ${suggestionIds.length} FAQ suggestion patches `
247
+ + `and ${additionalPatchKeys.length} heading patches`,
248
+ );
249
+
250
+ return removePatchesBySuggestionIds(config, suggestionIds, additionalPatchKeys);
251
+ }
204
252
  }
@@ -17,7 +17,7 @@
17
17
  * Patches with no suggestionId:
18
18
  * → Key: opportunityId
19
19
  */
20
- function getPatchKey(patch) {
20
+ export function getPatchKey(patch) {
21
21
  // Heading patch (no suggestionId): use special key
22
22
  if (!patch.suggestionId) {
23
23
  return `${patch.opportunityId}`;
@@ -64,3 +64,53 @@ export function mergePatches(existingPatches, newPatches) {
64
64
 
65
65
  return { patches: mergedPatches, updateCount, addCount };
66
66
  }
67
+
68
+ /**
69
+ * Removes patches matching the given suggestion IDs from a config
70
+ * @param {Object} config - Tokowaka configuration object
71
+ * @param {Array<string>} suggestionIds - Array of suggestion IDs to remove
72
+ * @param {Array<string>} additionalPatchKeys - Optional array of additional patch keys to remove
73
+ * @returns {Object} - Updated configuration with patches removed
74
+ */
75
+ export function removePatchesBySuggestionIds(config, suggestionIds, additionalPatchKeys = []) {
76
+ if (!config || !config.tokowakaOptimizations) {
77
+ return config;
78
+ }
79
+
80
+ const suggestionIdSet = new Set(suggestionIds);
81
+ const patchKeysToRemove = new Set(additionalPatchKeys);
82
+ const updatedOptimizations = {};
83
+ let removedCount = 0;
84
+
85
+ // Iterate through each URL path and filter out patches matching suggestionIds or patch keys
86
+ Object.entries(config.tokowakaOptimizations).forEach(([urlPath, optimization]) => {
87
+ const { patches = [] } = optimization;
88
+
89
+ // Filter out patches with matching suggestionIds or patch keys
90
+ const filteredPatches = patches.filter((patch) => {
91
+ const shouldRemoveBySuggestionId = suggestionIdSet.has(patch.suggestionId);
92
+ const patchKey = `${urlPath}:${getPatchKey(patch)}`;
93
+ const shouldRemoveByPatchKey = patchKeysToRemove.has(patchKey);
94
+
95
+ if (shouldRemoveBySuggestionId || shouldRemoveByPatchKey) {
96
+ removedCount += 1;
97
+ return false;
98
+ }
99
+ return true;
100
+ });
101
+
102
+ // Only keep URL paths that still have patches
103
+ if (filteredPatches.length > 0) {
104
+ updatedOptimizations[urlPath] = {
105
+ ...optimization,
106
+ patches: filteredPatches,
107
+ };
108
+ }
109
+ });
110
+
111
+ return {
112
+ ...config,
113
+ tokowakaOptimizations: updatedOptimizations,
114
+ removedCount,
115
+ };
116
+ }