@adobe/spacecat-shared-tokowaka-client 1.17.3 → 1.18.1

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/src/index.js CHANGED
@@ -25,8 +25,21 @@ import {
25
25
  getHostName,
26
26
  normalizePath,
27
27
  } from './utils/s3-utils.js';
28
- import { groupSuggestionsByUrlPath, filterEligibleSuggestions } from './utils/suggestion-utils.js';
28
+ import {
29
+ omitKeys,
30
+ isEdgeDeployableSuggestionStatus,
31
+ isPatternSuggestion,
32
+ groupSuggestionsByUrlPath,
33
+ filterEligibleSuggestions,
34
+ saveSuggestions,
35
+ stripSuggestion,
36
+ cleanupCoveredSuggestions,
37
+ classifySuggestions,
38
+ filterBatchCoveredSuggestions,
39
+ } from './utils/suggestion-utils.js';
40
+ import { buildUrlMatcher } from './utils/pattern-utils.js';
29
41
  import { getEffectiveBaseURL } from './utils/site-utils.js';
42
+ import { removePatternFromMetaconfig, addPatternsToMetaconfig } from './utils/metaconfig-utils.js';
30
43
  import { fetchHtmlWithWarmup, calculateForwardedHost } from './utils/custom-html-utils.js';
31
44
  import {
32
45
  EDGE_OPTIMIZE_PROXY_BASE_URL_DEFAULT,
@@ -42,11 +55,6 @@ const HTTP_BAD_REQUEST = 400;
42
55
  const HTTP_INTERNAL_SERVER_ERROR = 500;
43
56
  const HTTP_NOT_IMPLEMENTED = 501;
44
57
 
45
- /** Matches SpaceCat API eligibility for edge deploy (non-domain-wide). */
46
- function isEdgeDeployableSuggestionStatus(status) {
47
- return status === 'NEW' || status === 'PENDING_VALIDATION';
48
- }
49
-
50
58
  /**
51
59
  * Tokowaka Client - Manages edge optimization configurations
52
60
  */
@@ -72,6 +80,7 @@ class TokowakaClient {
72
80
  previewBucketName,
73
81
  s3Client: s3?.s3Client ?? context.s3Client,
74
82
  env,
83
+ dataAccess: context.dataAccess,
75
84
  }, log);
76
85
  context.tokowakaClient = client;
77
86
  return client;
@@ -84,10 +93,12 @@ class TokowakaClient {
84
93
  * @param {string} config.previewBucketName - S3 bucket name for preview configs
85
94
  * @param {Object} config.s3Client - AWS S3 client
86
95
  * @param {Object} config.env - Environment variables (for CDN credentials)
96
+ * @param {Object} [config.dataAccess] - Data access layer
97
+ * (provides Suggestion.saveMany for batch saves)
87
98
  * @param {Object} log - Logger instance
88
99
  */
89
100
  constructor({
90
- bucketName, previewBucketName, s3Client, env = {},
101
+ bucketName, previewBucketName, s3Client, env = {}, dataAccess,
91
102
  }, log) {
92
103
  this.log = log;
93
104
 
@@ -103,6 +114,7 @@ class TokowakaClient {
103
114
  this.previewBucketName = previewBucketName;
104
115
  this.s3Client = s3Client;
105
116
  this.env = env;
117
+ this.dataAccess = dataAccess;
106
118
 
107
119
  this.mapperRegistry = new MapperRegistry(log);
108
120
  this.cdnClientRegistry = new CdnClientRegistry(env, log);
@@ -267,6 +279,7 @@ class TokowakaClient {
267
279
  const bodyContents = await response.Body.transformToString();
268
280
  const metaconfig = JSON.parse(bodyContents);
269
281
 
282
+ // eslint-disable-next-line max-len
270
283
  this.log.debug(`Successfully fetched metaconfig from s3://${bucketName}/${s3Path} in ${Date.now() - fetchStartTime}ms`);
271
284
 
272
285
  return {
@@ -479,6 +492,7 @@ class TokowakaClient {
479
492
  const command = new PutObjectCommand(putObjectParams);
480
493
 
481
494
  await this.s3Client.send(command);
495
+ // eslint-disable-next-line max-len
482
496
  this.log.info(`Successfully uploaded metaconfig to s3://${bucketName}/${s3Path} in ${Date.now() - uploadStartTime}ms`);
483
497
 
484
498
  // Invalidate CDN cache for the metaconfig (both CloudFront and Fastly)
@@ -517,6 +531,7 @@ class TokowakaClient {
517
531
  const bodyContents = await response.Body.transformToString();
518
532
  const config = JSON.parse(bodyContents);
519
533
 
534
+ // eslint-disable-next-line max-len
520
535
  this.log.debug(`Successfully fetched existing Tokowaka config from s3://${bucketName}/${s3Path} in ${Date.now() - fetchStartTime}ms`);
521
536
  return config;
522
537
  } catch (error) {
@@ -597,6 +612,7 @@ class TokowakaClient {
597
612
  });
598
613
 
599
614
  await this.s3Client.send(command);
615
+ // eslint-disable-next-line max-len
600
616
  this.log.info(`Successfully uploaded Tokowaka config to s3://${bucketName}/${s3Path} in ${Date.now() - uploadStartTime}ms`);
601
617
 
602
618
  return s3Path;
@@ -684,6 +700,7 @@ class TokowakaClient {
684
700
  });
685
701
  }
686
702
  }
703
+ // eslint-disable-next-line max-len
687
704
  this.log.info(`CDN cache invalidation completed in total ${Date.now() - invalidationStartTime}ms`);
688
705
  return results;
689
706
  }
@@ -797,15 +814,80 @@ class TokowakaClient {
797
814
  };
798
815
  }
799
816
 
817
+ /**
818
+ * Rolls back a single URL's S3 config by removing the relevant patches.
819
+ * Returns the number of patches removed, or 0 if nothing changed.
820
+ * Pushes the uploaded S3 path into s3Paths and the URL into rolledBackUrls on success.
821
+ * @param {string} fullUrl
822
+ * @param {Array} urlSuggestions - Suggestions for this URL
823
+ * @param {Object} opportunity
824
+ * @param {Object} mapper
825
+ * @param {string} opportunityType
826
+ * @param {Array} s3Paths - Accumulator array (mutated)
827
+ * @param {Array} rolledBackUrls - Accumulator array (mutated)
828
+ * @returns {Promise<number>} Number of patches removed
829
+ * @private
830
+ */
831
+ // eslint-disable-next-line max-len
832
+ async #rollbackPerUrlConfig(fullUrl, urlSuggestions, opportunity, mapper, opportunityType, s3Paths, rolledBackUrls) {
833
+ const existingConfig = await this.fetchConfig(fullUrl);
834
+ if (!existingConfig) {
835
+ this.log.warn(`No existing configuration found for URL: ${fullUrl}`);
836
+ return 0;
837
+ }
838
+
839
+ if (opportunityType === 'prerender') {
840
+ this.log.info(`Rolling back prerender config for URL: ${fullUrl}`);
841
+ const s3Path = await this.uploadConfig(fullUrl, { ...existingConfig, prerender: false });
842
+ s3Paths.push(s3Path);
843
+ rolledBackUrls.push(fullUrl);
844
+ return 1;
845
+ }
846
+
847
+ if (!existingConfig.patches) {
848
+ this.log.info(`No patches found in configuration for URL: ${fullUrl}`);
849
+ return 0;
850
+ }
851
+
852
+ const suggestionIdsToRemove = urlSuggestions.map((s) => s.getId());
853
+ const updatedConfig = mapper.rollbackPatches(
854
+ existingConfig,
855
+ suggestionIdsToRemove,
856
+ opportunity.getId(),
857
+ );
858
+
859
+ if (updatedConfig.removedCount === 0) {
860
+ this.log.warn(`No patches found for suggestions at URL: ${fullUrl}`);
861
+ return 0;
862
+ }
863
+
864
+ this.log.info(`Removed ${updatedConfig.removedCount} patches for URL: ${fullUrl}`);
865
+ const removed = updatedConfig.removedCount;
866
+ delete updatedConfig.removedCount;
867
+
868
+ const s3Path = await this.uploadConfig(fullUrl, updatedConfig);
869
+ s3Paths.push(s3Path);
870
+ rolledBackUrls.push(fullUrl);
871
+ return removed;
872
+ }
873
+
800
874
  /**
801
875
  * Rolls back deployed suggestions by removing their patches from the configuration
802
876
  * Now updates one file per URL instead of a single file with all URLs
803
877
  * @param {Object} site - Site entity
804
878
  * @param {Object} opportunity - Opportunity entity
805
879
  * @param {Array} suggestions - Array of suggestion entities to rollback
880
+ * @param {Object} [options] - Optional parameters
881
+ * @param {Array} [options.allSuggestions=[]] - All suggestions for the opportunity,
882
+ * used to clean up suggestions covered by a domain-wide or path-level pattern rollback
883
+ * @param {string} [options.updatedBy] - Actor identity (e.g. profile email). When undefined,
884
+ * context-specific fallback strings are used: 'tokowaka-rollback' for direct suggestions,
885
+ * 'domain-wide-rollback' for per-URL suggestions covered by a domain-wide pattern, and
886
+ * 'path-rollback' for per-URL suggestions covered by a path pattern.
806
887
  * @returns {Promise<Object>} - Rollback result with succeeded/failed suggestions
807
888
  */
808
- async rollbackSuggestions(site, opportunity, suggestions) {
889
+ // eslint-disable-next-line max-len
890
+ async rollbackSuggestions(site, opportunity, suggestions, { allSuggestions = [], updatedBy } = {}) {
809
891
  const opportunityType = opportunity.getType();
810
892
  const baseURL = getEffectiveBaseURL(site);
811
893
  const mapper = this.mapperRegistry.getMapper(opportunityType);
@@ -817,19 +899,25 @@ class TokowakaClient {
817
899
  );
818
900
  }
819
901
 
820
- // Validate which suggestions can be rolled back
821
- // For rollback, we use the same canDeploy check to ensure data integrity
902
+ // Separate pattern-based suggestions (path and domain-wide allowList entries)
903
+ // from per-URL suggestions each group is rolled back via a different code path.
904
+ const patternSuggestions = suggestions.filter(isPatternSuggestion);
905
+ const perUrlSuggestions = suggestions.filter((s) => !isPatternSuggestion(s));
906
+ // eslint-disable-next-line max-len
907
+ this.log.info(`[edge-rollback] Site: ${baseURL}, total: ${suggestions.length}, pattern: ${patternSuggestions.length}, perUrl: ${perUrlSuggestions.length}, allSuggestions: ${allSuggestions.length}`);
908
+
822
909
  const {
823
910
  eligible: eligibleSuggestions,
824
911
  ineligible: ineligibleSuggestions,
825
- } = filterEligibleSuggestions(suggestions, mapper);
912
+ } = filterEligibleSuggestions(perUrlSuggestions, mapper);
826
913
 
827
914
  this.log.debug(
828
915
  `Rolling back ${eligibleSuggestions.length} eligible suggestions `
829
- + `(${ineligibleSuggestions.length} ineligible)`,
916
+ + `(${ineligibleSuggestions.length} ineligible), `
917
+ + `${patternSuggestions.length} pattern suggestions`,
830
918
  );
831
919
 
832
- if (eligibleSuggestions.length === 0) {
920
+ if (eligibleSuggestions.length === 0 && patternSuggestions.length === 0) {
833
921
  this.log.warn('No eligible suggestions to rollback');
834
922
  return {
835
923
  succeededSuggestions: [],
@@ -837,95 +925,148 @@ class TokowakaClient {
837
925
  };
838
926
  }
839
927
 
840
- // Group suggestions by URL
928
+ // Roll back per-URL suggestions: one S3 config file per URL.
841
929
  const suggestionsByUrl = groupSuggestionsByUrlPath(eligibleSuggestions, baseURL, this.log);
842
-
843
- // Process each URL separately
844
930
  const s3Paths = [];
845
- const rolledBackUrls = []; // Track URLs for batch CDN invalidation
931
+ const rolledBackUrls = [];
846
932
  let totalRemovedCount = 0;
847
933
 
848
934
  for (const [urlPath, urlSuggestions] of Object.entries(suggestionsByUrl)) {
849
935
  const fullUrl = new URL(urlPath, baseURL).toString();
850
936
  this.log.debug(`Rolling back ${urlSuggestions.length} suggestions for URL: ${fullUrl}`);
851
-
852
- // Fetch existing configuration for this URL from S3
853
937
  // eslint-disable-next-line no-await-in-loop
854
- const existingConfig = await this.fetchConfig(fullUrl);
855
-
856
- if (!existingConfig) {
857
- this.log.warn(`No existing configuration found for URL: ${fullUrl}`);
858
- // eslint-disable-next-line no-continue
859
- continue;
860
- }
861
-
862
- // Extract suggestion IDs to remove for this URL
863
- const suggestionIdsToRemove = urlSuggestions.map((s) => s.getId());
938
+ const removed = await this.#rollbackPerUrlConfig(
939
+ fullUrl,
940
+ urlSuggestions,
941
+ opportunity,
942
+ mapper,
943
+ opportunityType,
944
+ s3Paths,
945
+ rolledBackUrls,
946
+ );
947
+ totalRemovedCount += removed;
948
+ }
864
949
 
865
- // For prerender opportunities, disable prerender flag
866
- if (opportunityType === 'prerender') {
867
- this.log.info(`Rolling back prerender config for URL: ${fullUrl}`);
950
+ // eslint-disable-next-line max-len
951
+ this.log.info(`Updated Tokowaka configs for ${s3Paths.length} URLs, removed ${totalRemovedCount} patches total`);
868
952
 
869
- // Set prerender to false (keep other patches if they exist)
870
- const updatedConfig = {
871
- ...existingConfig,
872
- prerender: false,
873
- };
953
+ // Strip deployment markers and batch-save all eligible per-URL suggestions.
954
+ const savedEligibleSuggestions = eligibleSuggestions
955
+ .map((s) => stripSuggestion(s, 'tokowaka-rollback', updatedBy));
956
+ await saveSuggestions(this.dataAccess, savedEligibleSuggestions);
957
+
958
+ // Roll back pattern suggestions: fetch metaconfig once, remove all patterns in a single
959
+ // in-memory pass, upload once, then save each suggestion and clean up its covered entries.
960
+ const succeededPatternSuggestions = [];
961
+ const failedPatternSuggestions = [];
962
+
963
+ if (patternSuggestions.length > 0) {
964
+ const metaconfig = await this.fetchMetaconfig(baseURL);
965
+ if (!metaconfig) {
966
+ // eslint-disable-next-line max-len
967
+ this.log.warn(`[edge-rollback] No metaconfig found for ${baseURL}, skipping all pattern suggestions`);
968
+ // eslint-disable-next-line max-len
969
+ patternSuggestions.forEach((s) => ineligibleSuggestions.push({ suggestion: s, reason: 'No metaconfig found' }));
970
+ } else {
971
+ // Pass 1: remove all patterns from the in-memory metaconfig, stage suggestions for saving.
972
+ const toSave = [];
973
+ let metaconfigChanged = false;
974
+
975
+ for (const suggestion of patternSuggestions) {
976
+ const data = suggestion.getData();
977
+ const patternToRemove = data?.allowedRegexPatterns?.[0];
978
+ if (!patternToRemove) {
979
+ // eslint-disable-next-line max-len
980
+ this.log.warn(`[edge-rollback] Suggestion ${suggestion.getId()} has no allowedRegexPatterns, skipping`);
981
+ ineligibleSuggestions.push({ suggestion, reason: 'Missing allowedRegexPatterns' });
982
+ // eslint-disable-next-line no-continue
983
+ continue;
984
+ }
874
985
 
875
- // Upload updated config to S3 for this URL
876
- // eslint-disable-next-line no-await-in-loop
877
- const s3Path = await this.uploadConfig(fullUrl, updatedConfig);
878
- s3Paths.push(s3Path);
879
- rolledBackUrls.push(fullUrl); // Collect URL for batch invalidation
986
+ const existingAllowList = metaconfig.prerender?.allowList ?? [];
987
+ const changed = removePatternFromMetaconfig(metaconfig, patternToRemove);
988
+ const updatedAllowList = metaconfig.prerender?.allowList ?? [];
989
+ // eslint-disable-next-line max-len
990
+ this.log.info(`[edge-rollback] Pattern ${patternToRemove}: allowList before=${JSON.stringify(existingAllowList)}, after=${JSON.stringify(updatedAllowList)}`);
991
+ if (changed) {
992
+ metaconfigChanged = true;
993
+ } else {
994
+ // eslint-disable-next-line max-len
995
+ this.log.info(`[edge-rollback] Pattern ${patternToRemove} not found in allowList, skipping CDN write`);
996
+ }
880
997
 
881
- totalRemovedCount += 1; // Count as 1 rollback
882
- // eslint-disable-next-line no-continue
883
- continue;
884
- }
998
+ suggestion.setData(omitKeys(data, ['edgeDeployed', 'tokowakaDeployed']));
999
+ suggestion.setUpdatedBy(updatedBy ?? 'tokowaka-rollback');
1000
+ toSave.push(suggestion);
1001
+ }
885
1002
 
886
- if (!existingConfig.patches) {
887
- this.log.info(`No patches found in configuration for URL: ${fullUrl}`);
888
- // eslint-disable-next-line no-continue
889
- continue;
890
- }
1003
+ // Pass 2: upload the mutated metaconfig once, then save each suggestion and clean up.
1004
+ try {
1005
+ if (metaconfigChanged) {
1006
+ await this.uploadMetaconfig(baseURL, metaconfig);
1007
+ }
1008
+ } catch (error) {
1009
+ this.log.error(`[edge-rollback] Metaconfig upload failed: ${error.message}`, error);
1010
+ toSave.forEach((s) => failedPatternSuggestions.push({
1011
+ suggestion: s, reason: 'Internal server error', statusCode: 500,
1012
+ }));
1013
+ // Skip per-suggestion saves when metaconfig upload failed
1014
+ toSave.length = 0;
1015
+ }
891
1016
 
892
- // Use mapper to remove patches
893
- const updatedConfig = mapper.rollbackPatches(
894
- existingConfig,
895
- suggestionIdsToRemove,
896
- opportunity.getId(),
897
- );
1017
+ // Batch-save all pattern suggestions.
1018
+ if (toSave.length > 0) {
1019
+ try {
1020
+ await saveSuggestions(this.dataAccess, toSave);
1021
+ succeededPatternSuggestions.push(...toSave);
1022
+ } catch (error) {
1023
+ // eslint-disable-next-line max-len
1024
+ this.log.error(`[edge-rollback] Error saving pattern suggestions: ${error.message}`);
1025
+ toSave.forEach((s) => failedPatternSuggestions.push({
1026
+ suggestion: s, reason: 'Internal server error', statusCode: 500,
1027
+ }));
1028
+ }
1029
+ }
898
1030
 
899
- if (updatedConfig.removedCount === 0) {
900
- this.log.warn(`No patches found for suggestions at URL: ${fullUrl}`);
901
- // eslint-disable-next-line no-continue
902
- continue;
1031
+ // Clean up covered suggestions for each succeeded pattern suggestion.
1032
+ for (const suggestion of succeededPatternSuggestions) {
1033
+ const suggData = suggestion.getData();
1034
+ const isDomainWide = suggData?.isDomainWide === true;
1035
+ const coveredFallback = isDomainWide ? 'domain-wide-rollback' : 'path-rollback';
1036
+ const coverageField = isDomainWide
1037
+ ? 'coveredByDomainWide' : 'coveredByPattern';
1038
+ const covered = allSuggestions.filter(
1039
+ (s) => s.getData()?.[coverageField] === suggestion.getId(),
1040
+ );
1041
+ const fieldsToStrip = isDomainWide
1042
+ ? ['coveredByDomainWide']
1043
+ : ['edgeDeployed', 'tokowakaDeployed', 'coveredByPattern'];
1044
+ if (covered.length > 0) {
1045
+ // eslint-disable-next-line max-len
1046
+ this.log.info(`[edge-rollback] Cleaning ${covered.length} covered suggestion(s) for pattern ${suggestion.getId()} (isDomainWide=${isDomainWide}, fallback=${coveredFallback})`);
1047
+ }
1048
+ // eslint-disable-next-line no-await-in-loop, max-len
1049
+ await cleanupCoveredSuggestions(this.dataAccess, covered, coveredFallback, updatedBy, fieldsToStrip, this.log);
1050
+ }
903
1051
  }
1052
+ }
904
1053
 
905
- this.log.info(`Removed ${updatedConfig.removedCount} patches for URL: ${fullUrl}`);
906
- totalRemovedCount += updatedConfig.removedCount;
1054
+ const cdnInvalidations = await this.invalidateCdnCache({ urls: rolledBackUrls });
907
1055
 
908
- // Remove the removedCount property before uploading
909
- delete updatedConfig.removedCount;
1056
+ const allSucceeded = [...savedEligibleSuggestions, ...succeededPatternSuggestions];
1057
+ const allFailed = [...ineligibleSuggestions, ...failedPatternSuggestions];
1058
+ const total = allSucceeded.length + allFailed.length;
910
1059
 
911
- // Upload updated config to S3 for this URL
912
- // eslint-disable-next-line no-await-in-loop
913
- const s3Path = await this.uploadConfig(fullUrl, updatedConfig);
914
- s3Paths.push(s3Path);
915
- rolledBackUrls.push(fullUrl); // Collect URL for batch invalidation
1060
+ if (allFailed.length > 0) {
1061
+ // eslint-disable-next-line max-len
1062
+ this.log.error(`[edge-rollback-failed] ${allFailed.length} out of ${total} suggestion(s) failed to rollback for ${baseURL}`);
916
1063
  }
917
1064
 
918
- this.log.info(`Updated Tokowaka configs for ${s3Paths.length} URLs, removed ${totalRemovedCount} patches total`);
919
-
920
- // Batch invalidate CDN cache for all rolled back URLs at once
921
- // (much more efficient than individual invalidations)
922
- const cdnInvalidations = await this.invalidateCdnCache({ urls: rolledBackUrls });
923
-
924
1065
  return {
925
1066
  s3Paths,
926
1067
  cdnInvalidations,
927
- succeededSuggestions: eligibleSuggestions,
928
- failedSuggestions: ineligibleSuggestions,
1068
+ succeededSuggestions: allSucceeded,
1069
+ failedSuggestions: allFailed,
929
1070
  removedPatchesCount: totalRemovedCount,
930
1071
  };
931
1072
  }
@@ -1063,6 +1204,7 @@ class TokowakaClient {
1063
1204
  }
1064
1205
 
1065
1206
  // Upload to preview S3 path for this URL
1207
+ // eslint-disable-next-line max-len
1066
1208
  this.log.info(`Uploading preview Tokowaka config with ${eligibleSuggestions.length} new suggestions`);
1067
1209
  const s3Path = await this.uploadConfig(previewUrl, config, true);
1068
1210
 
@@ -1094,6 +1236,7 @@ class TokowakaClient {
1094
1236
  ]);
1095
1237
  /* c8 ignore start */
1096
1238
  if (!originalHtml || !optimizedHtml) {
1239
+ // eslint-disable-next-line max-len
1097
1240
  throw this.#createError('Failed to fetch original or optimized HTML', HTTP_INTERNAL_SERVER_ERROR);
1098
1241
  }
1099
1242
  /* c8 ignore stop */
@@ -1156,12 +1299,14 @@ class TokowakaClient {
1156
1299
 
1157
1300
  while (attempt <= maxRetries) {
1158
1301
  try {
1302
+ // eslint-disable-next-line max-len
1159
1303
  this.log.debug(`Attempt ${attempt + 1}/${maxRetries + 1}: Checking edge optimize status for ${targetUrl}`);
1160
1304
 
1161
1305
  // eslint-disable-next-line no-await-in-loop
1162
1306
  const response = await tracingFetch(targetUrl, {
1163
1307
  method: 'GET',
1164
1308
  headers: {
1309
+ // eslint-disable-next-line max-len
1165
1310
  'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Tokowaka-AI Tokowaka/1.0 AdobeEdgeOptimize-AI AdobeEdgeOptimize/1.0',
1166
1311
  'fastly-debug': '1',
1167
1312
  },
@@ -1182,6 +1327,7 @@ class TokowakaClient {
1182
1327
  const isTimeout = error?.code === 'ETIMEOUT';
1183
1328
 
1184
1329
  if (isTimeout) {
1330
+ // eslint-disable-next-line max-len
1185
1331
  this.log.warn(`Request timed out after ${REQUEST_TIMEOUT_MS}ms for ${targetUrl}, returning edgeOptimizeEnabled: false`);
1186
1332
  return { edgeOptimizeEnabled: false };
1187
1333
  }
@@ -1266,6 +1412,154 @@ class TokowakaClient {
1266
1412
  return probeResult;
1267
1413
  }
1268
1414
 
1415
+ /**
1416
+ * Deploys per-URL suggestions via deploySuggestions(), stamps them with edgeDeployed,
1417
+ * and returns { succeededSuggestions, failedSuggestions }.
1418
+ * Throws if the underlying deployment throws.
1419
+ * @param {Object} site
1420
+ * @param {Object} opportunity
1421
+ * @param {Array} validSuggestions
1422
+ * @param {string} updatedBy
1423
+ * @returns {Promise<{ succeededSuggestions: Array, failedSuggestions: Array }>}
1424
+ * @private
1425
+ */
1426
+ async #deployPerUrlSuggestions(site, opportunity, validSuggestions, updatedBy) {
1427
+ try {
1428
+ const result = await this.deploySuggestions(site, opportunity, validSuggestions);
1429
+ const deploymentTimestamp = Date.now();
1430
+
1431
+ const succeeded = result.succeededSuggestions.map((s) => {
1432
+ const currentData = s.getData();
1433
+ const updated = { ...currentData, edgeDeployed: deploymentTimestamp };
1434
+ if (updated.edgeOptimizeStatus === 'STALE') {
1435
+ delete updated.edgeOptimizeStatus;
1436
+ }
1437
+ s.setData(updated);
1438
+ s.setUpdatedBy(updatedBy);
1439
+ return s;
1440
+ });
1441
+ await saveSuggestions(this.dataAccess, succeeded);
1442
+
1443
+ const failed = result.failedSuggestions.map((item) => {
1444
+ this.log.info(
1445
+ `[edge-deploy] ${opportunity.getType()} suggestion `
1446
+ + `${item.suggestion.getId()} is ineligible: ${item.reason}`,
1447
+ );
1448
+ return { ...item, statusCode: 400 };
1449
+ });
1450
+
1451
+ return { succeededSuggestions: succeeded, failedSuggestions: failed };
1452
+ } catch (error) {
1453
+ this.log.error(`[edge-deploy] Error deploying suggestions: ${error.message}`, error);
1454
+ throw error;
1455
+ }
1456
+ }
1457
+
1458
+ /**
1459
+ * Deploys a single pattern suggestion by appending its patterns to the metaconfig allowList
1460
+ * (append-and-deduplicate; CDN write is skipped when nothing changes), stamps the suggestion
1461
+ * with edgeDeployed, and marks qualifying per-URL suggestions as covered.
1462
+ * @param {Object} suggestion - The pattern suggestion entity
1463
+ * @param {Array<string>} allowedRegexPatterns
1464
+ * @param {Object} metaconfig - Metaconfig object (mutated in place)
1465
+ * @param {string} baseURL
1466
+ * @param {Set<string>} skippedInBatchIds - IDs already handled as same-batch skips
1467
+ * @param {Array} allSuggestions - Full opportunity suggestion list
1468
+ * @param {string} updatedBy
1469
+ * @param {Array} coveredSuggestions - Accumulator (mutated)
1470
+ * @returns {Promise<void>} Throws on metaconfig upload failure
1471
+ * @private
1472
+ */
1473
+ async #deployPatternSuggestion(
1474
+ suggestion,
1475
+ allowedRegexPatterns,
1476
+ metaconfig,
1477
+ baseURL,
1478
+ skippedInBatchIds,
1479
+ allSuggestions,
1480
+ updatedBy,
1481
+ coveredSuggestions,
1482
+ ) {
1483
+ const data = suggestion.getData();
1484
+ const coverageField = data?.isDomainWide ? 'coveredByDomainWide' : 'coveredByPattern';
1485
+
1486
+ const beforeAllowList = metaconfig.prerender?.allowList ?? [];
1487
+ const changed = addPatternsToMetaconfig(metaconfig, allowedRegexPatterns);
1488
+ const afterAllowList = changed ? metaconfig.prerender.allowList : beforeAllowList;
1489
+ this.log.info(
1490
+ // eslint-disable-next-line max-len
1491
+ `[edge-deploy] Pattern ${suggestion.getId()}: allowList before=${JSON.stringify(beforeAllowList)}, after=${JSON.stringify(afterAllowList)}`,
1492
+ );
1493
+
1494
+ if (changed) {
1495
+ await this.uploadMetaconfig(baseURL, metaconfig);
1496
+ // eslint-disable-next-line max-len
1497
+ this.log.info(`[edge-deploy] Uploaded metaconfig for ${baseURL} with allowList=${JSON.stringify(afterAllowList)}`);
1498
+ } else {
1499
+ // eslint-disable-next-line max-len
1500
+ this.log.info(`[edge-deploy] Patterns already in allowList for suggestion ${suggestion.getId()}, skipping CDN write`);
1501
+ }
1502
+
1503
+ suggestion.setData({ ...suggestion.getData(), edgeDeployed: Date.now() });
1504
+ suggestion.setUpdatedBy(updatedBy);
1505
+ // suggestion.save() is deferred — caller batches saves via saveSuggestions.
1506
+
1507
+ // Mark per-URL suggestions covered by this pattern.
1508
+ const matchers = allowedRegexPatterns.flatMap((p) => {
1509
+ const m = buildUrlMatcher(p);
1510
+ if (!m) {
1511
+ // eslint-disable-next-line max-len
1512
+ this.log.warn(`[edge-deploy] Pattern '${p}' for suggestion ${suggestion.getId()} is invalid, skipping`);
1513
+ }
1514
+ return m ? [m] : [];
1515
+ });
1516
+
1517
+ if (matchers.length === 0) {
1518
+ return;
1519
+ }
1520
+
1521
+ const covered = allSuggestions.filter((s) => {
1522
+ if (s.getId() === suggestion.getId()) {
1523
+ return false;
1524
+ }
1525
+ if (skippedInBatchIds.has(s.getId())) {
1526
+ return false;
1527
+ }
1528
+ if (!isEdgeDeployableSuggestionStatus(s.getStatus())) {
1529
+ return false;
1530
+ }
1531
+ if (isPatternSuggestion(s)) {
1532
+ return false;
1533
+ }
1534
+ if (s.getData()?.edgeDeployed) {
1535
+ return false;
1536
+ }
1537
+ const url = s.getData()?.url;
1538
+ return url && matchers.some((match) => match(url));
1539
+ });
1540
+
1541
+ // eslint-disable-next-line max-len
1542
+ this.log.info(`[edge-deploy] Pattern ${suggestion.getId()}: found ${covered.length} coverable per-URL suggestions (field=${coverageField})`);
1543
+
1544
+ if (covered.length > 0) {
1545
+ covered.forEach((cs) => {
1546
+ cs.setData({ ...cs.getData(), [coverageField]: suggestion.getId() });
1547
+ cs.setUpdatedBy(updatedBy);
1548
+ });
1549
+ try {
1550
+ await saveSuggestions(this.dataAccess, covered);
1551
+ coveredSuggestions.push(...covered);
1552
+ // eslint-disable-next-line max-len
1553
+ this.log.info(`[edge-deploy] Marked ${covered.length} suggestions as ${coverageField}=${suggestion.getId()}`);
1554
+ } catch (coverError) {
1555
+ this.log.warn(
1556
+ '[edge-deploy] Failed to mark covered suggestions for pattern suggestion '
1557
+ + `${suggestion.getId()}: ${coverError.message}`,
1558
+ );
1559
+ }
1560
+ }
1561
+ }
1562
+
1269
1563
  /**
1270
1564
  * Deploys suggestions to edge, handling both regular and domain-wide suggestions.
1271
1565
  *
@@ -1291,189 +1585,109 @@ class TokowakaClient {
1291
1585
  allSuggestions,
1292
1586
  updatedBy = 'edge-deploy',
1293
1587
  }) {
1294
- const validSuggestions = [];
1295
- const domainWideSuggestions = [];
1296
-
1297
- targetSuggestions.forEach((suggestion) => {
1298
- const data = suggestion.getData();
1299
- if (data?.isDomainWide === true) {
1300
- const { allowedRegexPatterns } = data;
1301
- if (Array.isArray(allowedRegexPatterns) && allowedRegexPatterns.length > 0) {
1302
- domainWideSuggestions.push({ suggestion, allowedRegexPatterns });
1303
- }
1304
- } else if (isEdgeDeployableSuggestionStatus(suggestion.getStatus())) {
1305
- validSuggestions.push(suggestion);
1306
- }
1307
- });
1588
+ // Step 1: classify suggestions into pattern vs per-URL buckets.
1589
+ // eslint-disable-next-line max-len
1590
+ const { patternSuggestions, validSuggestions: classified } = classifySuggestions(targetSuggestions, this.log);
1591
+ this.log.info(
1592
+ `[edge-deploy] Classification: pattern=${patternSuggestions.length},`
1593
+ + ` perUrl=${classified.length}, allSuggestions=${allSuggestions.length}`,
1594
+ );
1308
1595
 
1309
- // Filter valid suggestions that are covered by domain-wide patterns in the same batch
1310
- const skippedInBatch = [];
1311
- if (domainWideSuggestions.length > 0 && validSuggestions.length > 0) {
1312
- const allPatterns = [];
1313
- domainWideSuggestions.forEach(({ allowedRegexPatterns }) => {
1314
- allowedRegexPatterns.forEach((pattern) => {
1315
- try {
1316
- allPatterns.push(new RegExp(pattern));
1317
- } catch {
1318
- this.log.warn(`[edge-deploy] Skipping domain-wide pattern ${pattern} - invalid regex`);
1319
- }
1320
- });
1321
- });
1322
-
1323
- const remaining = [];
1324
- validSuggestions.forEach((s) => {
1325
- const url = s.getData()?.url;
1326
- if (url && allPatterns.some((regex) => regex.test(url))) {
1327
- skippedInBatch.push(s);
1328
- this.log.info(`[edge-deploy] Skipping suggestion ${s.getId()} - covered by domain-wide pattern`);
1329
- } else {
1330
- remaining.push(s);
1331
- }
1332
- });
1333
- validSuggestions.length = 0;
1334
- validSuggestions.push(...remaining);
1335
- }
1596
+ // Step 2: remove per-URL suggestions already covered by a pattern in this batch.
1597
+ // eslint-disable-next-line max-len
1598
+ const { remaining: validSuggestions, skippedInBatch } = filterBatchCoveredSuggestions(classified, patternSuggestions, this.log);
1336
1599
 
1337
1600
  let succeededSuggestions = [];
1338
1601
  const failedSuggestions = [];
1339
1602
 
1340
- // Deploy regular suggestions via tokowaka
1603
+ // Step 3: deploy per-URL suggestions via S3.
1341
1604
  if (validSuggestions.length > 0) {
1342
- try {
1343
- const result = await this.deploySuggestions(site, opportunity, validSuggestions);
1344
- const deploymentTimestamp = Date.now();
1345
-
1346
- succeededSuggestions = await Promise.all(
1347
- result.succeededSuggestions.map(async (s) => {
1348
- const currentData = s.getData();
1349
- const updated = { ...currentData, edgeDeployed: deploymentTimestamp };
1350
- if (updated.edgeOptimizeStatus === 'STALE') {
1351
- delete updated.edgeOptimizeStatus;
1352
- }
1353
- s.setData(updated);
1354
- s.setUpdatedBy(updatedBy);
1355
- await s.save();
1356
- return s;
1357
- }),
1358
- );
1359
-
1360
- // ineligible suggestions get statusCode 400 — they weren't deployable
1361
- result.failedSuggestions.forEach((item) => {
1362
- failedSuggestions.push({ ...item, statusCode: 400 });
1363
- this.log.info(`[edge-deploy] ${opportunity.getType()} suggestion ${item.suggestion.getId()} is ineligible: ${item.reason}`);
1364
- });
1365
- } catch (error) {
1366
- this.log.error(`[edge-deploy] Error deploying suggestions: ${error.message}`, error);
1367
- throw error;
1368
- }
1605
+ // eslint-disable-next-line max-len
1606
+ const result = await this.#deployPerUrlSuggestions(site, opportunity, validSuggestions, updatedBy);
1607
+ const { succeededSuggestions: perUrlSucceeded, failedSuggestions: perUrlFailed } = result;
1608
+ succeededSuggestions = perUrlSucceeded;
1609
+ failedSuggestions.push(...perUrlFailed);
1369
1610
  }
1370
1611
 
1371
- // Deploy domain-wide suggestions via metaconfig
1612
+ // Step 4: deploy pattern suggestions via metaconfig allowList.
1372
1613
  const coveredSuggestions = [];
1373
- if (domainWideSuggestions.length > 0) {
1614
+ const deployedPatternSuggestions = [];
1615
+ if (patternSuggestions.length > 0) {
1616
+ const skippedInBatchIds = new Set(skippedInBatch.map((item) => item.suggestion.getId()));
1374
1617
  const baseURL = site.getBaseURL();
1375
- const skippedInBatchIds = new Set(skippedInBatch.map((s) => s.getId()));
1618
+ let metaconfig = await this.fetchMetaconfig(baseURL);
1619
+ if (!metaconfig) {
1620
+ metaconfig = { siteId: site.getId() };
1621
+ }
1376
1622
 
1377
- for (const { suggestion, allowedRegexPatterns } of domainWideSuggestions) {
1378
- // Fix #1: compile + validate regexes before any upload/save so a bad pattern
1379
- // never causes a suggestion to land in both succeeded and failed lists.
1380
- const regexPatterns = [];
1381
- for (const pattern of allowedRegexPatterns) {
1382
- try {
1383
- regexPatterns.push(new RegExp(pattern));
1384
- } catch {
1385
- this.log.warn(`[edge-deploy] Invalid regex pattern "${pattern}" for domain-wide suggestion ${suggestion.getId()}, skipping`);
1386
- }
1623
+ for (const { suggestion, allowedRegexPatterns } of patternSuggestions) {
1624
+ if (!allowedRegexPatterns || allowedRegexPatterns.length === 0) {
1625
+ // eslint-disable-next-line max-len
1626
+ this.log.warn(`[edge-deploy] Pattern suggestion ${suggestion.getId()} has no allowedRegexPatterns, skipping`);
1627
+ // eslint-disable-next-line no-continue
1628
+ continue;
1387
1629
  }
1388
1630
 
1389
1631
  try {
1390
1632
  // eslint-disable-next-line no-await-in-loop
1391
- let metaconfig = await this.fetchMetaconfig(baseURL);
1392
- if (!metaconfig) {
1393
- metaconfig = { siteId: site.getId() };
1394
- }
1395
- metaconfig.prerender = { allowList: allowedRegexPatterns };
1396
- // eslint-disable-next-line no-await-in-loop
1397
- await this.uploadMetaconfig(baseURL, metaconfig);
1633
+ await this.#deployPatternSuggestion(
1634
+ suggestion,
1635
+ allowedRegexPatterns,
1636
+ metaconfig,
1637
+ baseURL,
1638
+ skippedInBatchIds,
1639
+ allSuggestions,
1640
+ updatedBy,
1641
+ coveredSuggestions,
1642
+ );
1643
+ deployedPatternSuggestions.push(suggestion);
1644
+ } catch (error) {
1645
+ // eslint-disable-next-line max-len
1646
+ this.log.error(`[edge-deploy] Error deploying pattern suggestion ${suggestion.getId()}: ${error.message}`, error);
1647
+ failedSuggestions.push({ suggestion, reason: 'Internal server error', statusCode: 500 });
1648
+ }
1649
+ }
1398
1650
 
1399
- const deploymentTimestamp = Date.now();
1400
- suggestion.setData({ ...suggestion.getData(), edgeDeployed: deploymentTimestamp });
1401
- suggestion.setUpdatedBy(updatedBy);
1402
- // eslint-disable-next-line no-await-in-loop
1403
- await suggestion.save();
1404
- succeededSuggestions.push(suggestion);
1405
-
1406
- if (regexPatterns.length > 0) {
1407
- const covered = allSuggestions.filter((s) => {
1408
- if (s.getId() === suggestion.getId()) {
1409
- return false;
1410
- }
1411
- if (skippedInBatchIds.has(s.getId())) {
1412
- return false;
1413
- }
1414
- if (!isEdgeDeployableSuggestionStatus(s.getStatus())) {
1415
- return false;
1416
- }
1417
- if (s.getData()?.isDomainWide === true) {
1418
- return false;
1419
- }
1420
- if (s.getData()?.edgeDeployed) {
1421
- return false;
1422
- }
1423
- const url = s.getData()?.url;
1424
- return url && regexPatterns.some((r) => r.test(url));
1425
- });
1426
-
1427
- if (covered.length > 0) {
1428
- try {
1429
- // eslint-disable-next-line no-await-in-loop
1430
- await Promise.all(covered.map(async (cs) => {
1431
- cs.setData({
1432
- ...cs.getData(),
1433
- coveredByDomainWide: suggestion.getId(),
1434
- });
1435
- cs.setUpdatedBy(updatedBy);
1436
- return cs.save();
1437
- }));
1438
- coveredSuggestions.push(...covered);
1439
- } catch (coverError) {
1440
- this.log.warn(`[edge-deploy] Failed to mark covered suggestions for domain-wide ${suggestion.getId()}: ${coverError.message}`);
1441
- }
1442
- }
1443
- }
1651
+ // Batch-save all pattern suggestions.
1652
+ if (deployedPatternSuggestions.length > 0) {
1653
+ try {
1654
+ await saveSuggestions(this.dataAccess, deployedPatternSuggestions);
1655
+ succeededSuggestions.push(...deployedPatternSuggestions);
1444
1656
  } catch (error) {
1445
- this.log.error(`[edge-deploy] Error deploying domain-wide suggestion ${suggestion.getId()}: ${error.message}`, error);
1446
- // statusCode 500 — deploy itself failed, not an eligibility issue
1447
- failedSuggestions.push({ suggestion, reason: error.message, statusCode: 500 });
1657
+ // eslint-disable-next-line max-len
1658
+ this.log.error(`[edge-deploy] Failed to save pattern suggestions: ${error.message}`);
1659
+ deployedPatternSuggestions.forEach((s) => {
1660
+ failedSuggestions.push({ suggestion: s, reason: 'Internal server error', statusCode: 500 });
1661
+ });
1448
1662
  }
1449
1663
  }
1450
1664
  }
1451
1665
 
1452
- // Mark same-batch skipped suggestions individually so a single save failure
1453
- // surfaces as a per-item failure rather than swallowing the whole batch.
1666
+ // Step 5: mark same-batch skipped suggestions, then batch-save.
1454
1667
  if (skippedInBatch.length > 0) {
1455
- const results = await Promise.allSettled(skippedInBatch.map(async (s) => {
1456
- s.setData({
1457
- ...s.getData(),
1458
- coveredByDomainWide: 'same-batch-deployment',
1668
+ const skippedSuggestions = skippedInBatch.map((item) => {
1669
+ const coverageField = item.isDomainWide ? 'coveredByDomainWide' : 'coveredByPattern';
1670
+ item.suggestion.setData({
1671
+ ...item.suggestion.getData(),
1672
+ [coverageField]: 'same-batch-deployment',
1459
1673
  skippedInDeployment: true,
1460
1674
  });
1461
- s.setUpdatedBy(updatedBy);
1462
- await s.save();
1463
- return s;
1464
- }));
1465
-
1466
- results.forEach((result, i) => {
1467
- const s = skippedInBatch[i];
1468
- if (result.status === 'fulfilled') {
1469
- succeededSuggestions.push(s);
1470
- coveredSuggestions.push(s);
1471
- } else {
1472
- this.log.warn(`[edge-deploy] Failed to mark same-batch skipped suggestion ${s.getId()}`
1473
- + ` - ${result.reason?.message}`);
1474
- failedSuggestions.push({ suggestion: s, reason: 'Failed to mark as covered by domain-wide', statusCode: 500 });
1475
- }
1675
+ item.suggestion.setUpdatedBy(updatedBy);
1676
+ return item.suggestion;
1476
1677
  });
1678
+
1679
+ try {
1680
+ await saveSuggestions(this.dataAccess, skippedSuggestions);
1681
+ succeededSuggestions.push(...skippedSuggestions);
1682
+ coveredSuggestions.push(...skippedSuggestions);
1683
+ } catch (error) {
1684
+ // eslint-disable-next-line max-len
1685
+ this.log.warn(`[edge-deploy] Failed to save same-batch skipped suggestions: ${error.message}`);
1686
+ skippedSuggestions.forEach((s) => {
1687
+ // eslint-disable-next-line max-len
1688
+ failedSuggestions.push({ suggestion: s, reason: 'Failed to mark as covered', statusCode: 500 });
1689
+ });
1690
+ }
1477
1691
  }
1478
1692
 
1479
1693
  return { succeededSuggestions, failedSuggestions, coveredSuggestions };