@adobe/spacecat-shared-tokowaka-client 1.19.0 → 1.19.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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [@adobe/spacecat-shared-tokowaka-client-v1.19.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.19.0...@adobe/spacecat-shared-tokowaka-client-v1.19.1) (2026-06-21)
2
+
3
+ ### Bug Fixes
4
+
5
+ * apply stale config in IVE edge deployment flow ([#1691](https://github.com/adobe/spacecat-shared/issues/1691)) ([9f07a9c](https://github.com/adobe/spacecat-shared/commit/9f07a9ccc73140249aad16e9c9d0d7f5cededf59))
6
+
1
7
  ## [@adobe/spacecat-shared-tokowaka-client-v1.19.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.18.2...@adobe/spacecat-shared-tokowaka-client-v1.19.0) (2026-06-03)
2
8
 
3
9
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.19.0",
3
+ "version": "1.19.1",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
package/src/index.js CHANGED
@@ -712,7 +712,8 @@ class TokowakaClient {
712
712
  * @param {Array} suggestions - Array of suggestion entities to deploy
713
713
  * @returns {Promise<Object>} - Deployment result with succeeded/failed suggestions
714
714
  */
715
- async deploySuggestions(site, opportunity, suggestions) {
715
+ async deploySuggestions(site, opportunity, suggestions, metadata = {}) {
716
+ const { applyStale = false } = metadata;
716
717
  const opportunityType = opportunity.getType();
717
718
  const baseURL = getEffectiveBaseURL(site);
718
719
  const mapper = this.mapperRegistry.getMapper(opportunityType);
@@ -788,6 +789,10 @@ class TokowakaClient {
788
789
  continue;
789
790
  }
790
791
 
792
+ if (applyStale && newConfig.patches?.length > 0) {
793
+ newConfig.patches = newConfig.patches.map((patch) => ({ ...patch, applyStale: true }));
794
+ }
795
+
791
796
  // Merge with existing config for this URL
792
797
  const config = this.mergeConfigs(existingConfig, newConfig);
793
798
 
@@ -828,8 +833,15 @@ class TokowakaClient {
828
833
  * @returns {Promise<number>} Number of patches removed
829
834
  * @private
830
835
  */
831
- // eslint-disable-next-line max-len
832
- async #rollbackPerUrlConfig(fullUrl, urlSuggestions, opportunity, mapper, opportunityType, s3Paths, rolledBackUrls) {
836
+ async #rollbackPerUrlConfig(
837
+ fullUrl,
838
+ urlSuggestions,
839
+ opportunity,
840
+ mapper,
841
+ opportunityType,
842
+ s3Paths,
843
+ rolledBackUrls,
844
+ ) {
833
845
  const existingConfig = await this.fetchConfig(fullUrl);
834
846
  if (!existingConfig) {
835
847
  this.log.warn(`No existing configuration found for URL: ${fullUrl}`);
@@ -1427,9 +1439,11 @@ class TokowakaClient {
1427
1439
  * @returns {Promise<{ succeededSuggestions: Array, failedSuggestions: Array }>}
1428
1440
  * @private
1429
1441
  */
1430
- async #deployPerUrlSuggestions(site, opportunity, validSuggestions, updatedBy) {
1442
+ // eslint-disable-next-line max-len
1443
+ async #deployPerUrlSuggestions(site, opportunity, validSuggestions, updatedBy, metadata = {}) {
1431
1444
  try {
1432
- const result = await this.deploySuggestions(site, opportunity, validSuggestions);
1445
+ // eslint-disable-next-line max-len
1446
+ const result = await this.deploySuggestions(site, opportunity, validSuggestions, metadata);
1433
1447
  const deploymentTimestamp = Date.now();
1434
1448
 
1435
1449
  const succeeded = result.succeededSuggestions.map((s) => {
@@ -1590,6 +1604,7 @@ class TokowakaClient {
1590
1604
  targetSuggestions,
1591
1605
  allSuggestions,
1592
1606
  updatedBy = 'edge-deploy',
1607
+ metadata = {},
1593
1608
  }) {
1594
1609
  // Step 1: classify suggestions into pattern vs per-URL buckets.
1595
1610
  // eslint-disable-next-line max-len
@@ -1609,7 +1624,7 @@ class TokowakaClient {
1609
1624
  // Step 3: deploy per-URL suggestions via S3.
1610
1625
  if (validSuggestions.length > 0) {
1611
1626
  // eslint-disable-next-line max-len
1612
- const result = await this.#deployPerUrlSuggestions(site, opportunity, validSuggestions, updatedBy);
1627
+ const result = await this.#deployPerUrlSuggestions(site, opportunity, validSuggestions, updatedBy, metadata);
1613
1628
  const { succeededSuggestions: perUrlSucceeded, failedSuggestions: perUrlFailed } = result;
1614
1629
  succeededSuggestions = perUrlSucceeded;
1615
1630
  failedSuggestions.push(...perUrlFailed);
@@ -1698,6 +1713,74 @@ class TokowakaClient {
1698
1713
 
1699
1714
  return { succeededSuggestions, failedSuggestions, coveredSuggestions };
1700
1715
  }
1716
+
1717
+ /**
1718
+ * Strips the `applyStale` flag from patches in S3 for the given per-URL suggestions.
1719
+ * Called when an experiment completes so edge reverts to normal (non-stale) behaviour.
1720
+ * Pattern / domain-wide suggestions are skipped — they have no per-URL S3 config.
1721
+ * CDN cache is invalidated for every URL whose config was actually modified.
1722
+ *
1723
+ * @param {Object} site - Site entity
1724
+ * @param {Object} opportunity - Opportunity entity
1725
+ * @param {Array} suggestions - Suggestion entities to clear (may include pattern suggestions,
1726
+ * which are silently skipped)
1727
+ * @returns {Promise<void>}
1728
+ */
1729
+ async clearApplyStaleFromPatches(site, opportunity, suggestions) {
1730
+ const baseURL = getEffectiveBaseURL(site);
1731
+
1732
+ const perUrlSuggestions = suggestions.filter((s) => {
1733
+ const data = s.getData();
1734
+ return data?.url && !Array.isArray(data?.allowedRegexPatterns);
1735
+ });
1736
+
1737
+ if (perUrlSuggestions.length === 0) {
1738
+ return;
1739
+ }
1740
+
1741
+ const suggestionsByUrl = groupSuggestionsByUrlPath(perUrlSuggestions, baseURL, this.log);
1742
+ const clearedUrls = [];
1743
+
1744
+ for (const [urlPath, urlSuggestions] of Object.entries(suggestionsByUrl)) {
1745
+ const fullUrl = new URL(urlPath, baseURL).toString();
1746
+ try {
1747
+ // eslint-disable-next-line no-await-in-loop
1748
+ const existingConfig = await this.fetchConfig(fullUrl);
1749
+ if (!existingConfig?.patches?.length) {
1750
+ // eslint-disable-next-line no-continue
1751
+ continue;
1752
+ }
1753
+
1754
+ const suggestionIds = new Set(urlSuggestions.map((s) => s.getId()));
1755
+ let modified = false;
1756
+ const updatedPatches = existingConfig.patches.map((patch) => {
1757
+ if (suggestionIds.has(patch.suggestionId) && patch.applyStale) {
1758
+ modified = true;
1759
+ const { applyStale: _, ...rest } = patch;
1760
+ return rest;
1761
+ }
1762
+ return patch;
1763
+ });
1764
+
1765
+ if (modified) {
1766
+ // eslint-disable-next-line no-await-in-loop
1767
+ await this.uploadConfig(fullUrl, { ...existingConfig, patches: updatedPatches });
1768
+ clearedUrls.push(fullUrl);
1769
+ }
1770
+ } catch (err) {
1771
+ this.log.error(`[clear-apply-stale-failed] Failed to process URL ${fullUrl}: ${err.message}`, err);
1772
+ }
1773
+ }
1774
+
1775
+ if (clearedUrls.length > 0) {
1776
+ try {
1777
+ await this.invalidateCdnCache({ urls: clearedUrls });
1778
+ this.log.info(`[clear-apply-stale] Cleared applyStale from ${clearedUrls.length} URL(s) and invalidated CDN`);
1779
+ } catch (err) {
1780
+ this.log.warn(`[clear-apply-stale-failed] CDN invalidation failed for ${clearedUrls.length} URL(s), S3 configs already updated: ${err.message}`, err);
1781
+ }
1782
+ }
1783
+ }
1701
1784
  }
1702
1785
 
1703
1786
  // Export the client as default and base classes for custom implementations
@@ -2495,6 +2495,37 @@ describe('TokowakaClient', () => {
2495
2495
  expect(updatedPatch.suggestionId).to.equal('sugg-1');
2496
2496
  expect(updatedPatch.lastUpdated).to.be.greaterThan(1234567890);
2497
2497
  });
2498
+
2499
+ it('should add applyStale: true to all patches when applyStale option is true', async () => {
2500
+ const result = await client.deploySuggestions(
2501
+ mockSite,
2502
+ mockOpportunity,
2503
+ mockSuggestions,
2504
+ { applyStale: true },
2505
+ );
2506
+
2507
+ expect(result.s3Paths).to.have.length(1);
2508
+ const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
2509
+ expect(uploadedConfig.patches).to.have.length(2);
2510
+ uploadedConfig.patches.forEach((patch) => {
2511
+ expect(patch.applyStale).to.equal(true);
2512
+ });
2513
+ });
2514
+
2515
+ it('should not add applyStale to patches when applyStale option is false', async () => {
2516
+ const result = await client.deploySuggestions(
2517
+ mockSite,
2518
+ mockOpportunity,
2519
+ mockSuggestions,
2520
+ { applyStale: false },
2521
+ );
2522
+
2523
+ expect(result.s3Paths).to.have.length(1);
2524
+ const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
2525
+ uploadedConfig.patches.forEach((patch) => {
2526
+ expect(patch.applyStale).to.be.undefined;
2527
+ });
2528
+ });
2498
2529
  });
2499
2530
 
2500
2531
  describe('rollbackSuggestions', () => {
@@ -6618,5 +6649,186 @@ describe('TokowakaClient', () => {
6618
6649
  expect(result.failedSuggestions[0].reason).to.equal('Internal server error');
6619
6650
  expect(log.error).to.have.been.called;
6620
6651
  });
6652
+
6653
+ it('should pass applyStale: true to deploySuggestions when metadata.applyStale is true', async () => {
6654
+ const s1 = makeSuggestion('s1', { url: 'https://example.com/page1', transformRules: {} });
6655
+
6656
+ deploySuggestionsStub.resolves({
6657
+ succeededSuggestions: [s1],
6658
+ failedSuggestions: [],
6659
+ });
6660
+
6661
+ await client.deployToEdge({
6662
+ site: mockSite,
6663
+ opportunity: mockOpportunity,
6664
+ targetSuggestions: [s1],
6665
+ allSuggestions: [s1],
6666
+ metadata: { applyStale: true },
6667
+ });
6668
+
6669
+ expect(deploySuggestionsStub).to.have.been.calledOnce;
6670
+ const [,, , metadata] = deploySuggestionsStub.firstCall.args;
6671
+ expect(metadata).to.deep.equal({ applyStale: true });
6672
+ });
6673
+
6674
+ it('should pass empty metadata to deploySuggestions when metadata is absent', async () => {
6675
+ const s1 = makeSuggestion('s1', { url: 'https://example.com/page1', transformRules: {} });
6676
+
6677
+ deploySuggestionsStub.resolves({
6678
+ succeededSuggestions: [s1],
6679
+ failedSuggestions: [],
6680
+ });
6681
+
6682
+ await client.deployToEdge({
6683
+ site: mockSite,
6684
+ opportunity: mockOpportunity,
6685
+ targetSuggestions: [s1],
6686
+ allSuggestions: [s1],
6687
+ });
6688
+
6689
+ expect(deploySuggestionsStub).to.have.been.calledOnce;
6690
+ const [,, , metadata] = deploySuggestionsStub.firstCall.args;
6691
+ expect(metadata).to.deep.equal({});
6692
+ });
6693
+ });
6694
+
6695
+ describe('clearApplyStaleFromPatches', () => {
6696
+ let fetchConfigStub;
6697
+ let uploadConfigStub;
6698
+ let invalidateCdnCacheStub;
6699
+
6700
+ function makeCompletedSuggestion(id, urlPath) {
6701
+ return {
6702
+ getId: () => id,
6703
+ getData: () => ({ url: `https://example.com${urlPath}`, edgeDeployed: Date.now() }),
6704
+ };
6705
+ }
6706
+
6707
+ beforeEach(() => {
6708
+ fetchConfigStub = sinon.stub(client, 'fetchConfig');
6709
+ uploadConfigStub = sinon.stub(client, 'uploadConfig').resolves('s3-path');
6710
+ invalidateCdnCacheStub = sinon.stub(client, 'invalidateCdnCache').resolves([]);
6711
+ });
6712
+
6713
+ it('should strip applyStale from matching patches and invalidate CDN', async () => {
6714
+ fetchConfigStub.resolves({
6715
+ url: 'https://example.com/page1',
6716
+ version: '1.0',
6717
+ patches: [
6718
+ {
6719
+ op: 'replace', selector: 'h1', value: 'New', suggestionId: 'sugg-1', applyStale: true,
6720
+ },
6721
+ {
6722
+ op: 'replace', selector: 'h2', value: 'Other', suggestionId: 'sugg-2', applyStale: true,
6723
+ },
6724
+ ],
6725
+ });
6726
+
6727
+ const s1 = makeCompletedSuggestion('sugg-1', '/page1');
6728
+ await client.clearApplyStaleFromPatches(mockSite, mockOpportunity, [s1]);
6729
+
6730
+ expect(uploadConfigStub).to.have.been.calledOnce;
6731
+ const [, uploadedConfig] = uploadConfigStub.firstCall.args;
6732
+ const patch1 = uploadedConfig.patches.find((p) => p.suggestionId === 'sugg-1');
6733
+ expect(patch1).to.not.have.property('applyStale');
6734
+ // sugg-2 not in suggestions list — applyStale untouched
6735
+ const patch2 = uploadedConfig.patches.find((p) => p.suggestionId === 'sugg-2');
6736
+ expect(patch2.applyStale).to.equal(true);
6737
+ expect(invalidateCdnCacheStub).to.have.been.calledOnce;
6738
+ });
6739
+
6740
+ it('should skip upload when no patches have applyStale', async () => {
6741
+ fetchConfigStub.resolves({
6742
+ url: 'https://example.com/page1',
6743
+ version: '1.0',
6744
+ patches: [
6745
+ {
6746
+ op: 'replace', selector: 'h1', value: 'New', suggestionId: 'sugg-1',
6747
+ },
6748
+ ],
6749
+ });
6750
+
6751
+ const s1 = makeCompletedSuggestion('sugg-1', '/page1');
6752
+ await client.clearApplyStaleFromPatches(mockSite, mockOpportunity, [s1]);
6753
+
6754
+ expect(uploadConfigStub).to.not.have.been.called;
6755
+ expect(invalidateCdnCacheStub).to.not.have.been.called;
6756
+ });
6757
+
6758
+ it('should skip URLs with no existing config', async () => {
6759
+ fetchConfigStub.resolves(null);
6760
+
6761
+ const s1 = makeCompletedSuggestion('sugg-1', '/page1');
6762
+ await client.clearApplyStaleFromPatches(mockSite, mockOpportunity, [s1]);
6763
+
6764
+ expect(uploadConfigStub).to.not.have.been.called;
6765
+ expect(invalidateCdnCacheStub).to.not.have.been.called;
6766
+ });
6767
+
6768
+ it('should silently skip pattern and domain-wide suggestions', async () => {
6769
+ const patternSugg = {
6770
+ getId: () => 'dw-1',
6771
+ getData: () => ({ isDomainWide: true, allowedRegexPatterns: ['/.*'] }),
6772
+ };
6773
+
6774
+ await client.clearApplyStaleFromPatches(mockSite, mockOpportunity, [patternSugg]);
6775
+
6776
+ expect(fetchConfigStub).to.not.have.been.called;
6777
+ expect(uploadConfigStub).to.not.have.been.called;
6778
+ });
6779
+
6780
+ it('should handle multiple URLs, only invalidate ones that changed', async () => {
6781
+ fetchConfigStub.onFirstCall().resolves({
6782
+ patches: [{ suggestionId: 'sugg-1', applyStale: true }],
6783
+ });
6784
+ fetchConfigStub.onSecondCall().resolves({
6785
+ patches: [{ suggestionId: 'sugg-2' }],
6786
+ });
6787
+
6788
+ const s1 = makeCompletedSuggestion('sugg-1', '/page1');
6789
+ const s2 = makeCompletedSuggestion('sugg-2', '/page2');
6790
+ await client.clearApplyStaleFromPatches(mockSite, mockOpportunity, [s1, s2]);
6791
+
6792
+ expect(uploadConfigStub).to.have.been.calledOnce;
6793
+ expect(invalidateCdnCacheStub).to.have.been.calledOnce;
6794
+ const { urls } = invalidateCdnCacheStub.firstCall.args[0];
6795
+ expect(urls).to.have.length(1);
6796
+ expect(urls[0]).to.include('/page1');
6797
+ });
6798
+
6799
+ it('should do nothing when given no per-URL suggestions', async () => {
6800
+ await client.clearApplyStaleFromPatches(mockSite, mockOpportunity, []);
6801
+
6802
+ expect(fetchConfigStub).to.not.have.been.called;
6803
+ });
6804
+
6805
+ it('should log warning and return normally when CDN invalidation throws', async () => {
6806
+ fetchConfigStub.resolves({
6807
+ patches: [{ suggestionId: 'sugg-1', applyStale: true }],
6808
+ });
6809
+ invalidateCdnCacheStub.rejects(new Error('CDN rate limit'));
6810
+
6811
+ const s1 = makeCompletedSuggestion('sugg-1', '/page1');
6812
+ await client.clearApplyStaleFromPatches(mockSite, mockOpportunity, [s1]);
6813
+
6814
+ expect(uploadConfigStub).to.have.been.calledOnce;
6815
+ });
6816
+
6817
+ it('should log error and continue processing remaining URLs when one URL throws', async () => {
6818
+ fetchConfigStub.onFirstCall().rejects(new Error('S3 transient error'));
6819
+ fetchConfigStub.onSecondCall().resolves({
6820
+ patches: [{ suggestionId: 'sugg-2', applyStale: true }],
6821
+ });
6822
+
6823
+ const s1 = makeCompletedSuggestion('sugg-1', '/page1');
6824
+ const s2 = makeCompletedSuggestion('sugg-2', '/page2');
6825
+ await client.clearApplyStaleFromPatches(mockSite, mockOpportunity, [s1, s2]);
6826
+
6827
+ expect(uploadConfigStub).to.have.been.calledOnce;
6828
+ expect(invalidateCdnCacheStub).to.have.been.calledOnce;
6829
+ const { urls } = invalidateCdnCacheStub.firstCall.args[0];
6830
+ expect(urls).to.have.length(1);
6831
+ expect(urls[0]).to.include('/page2');
6832
+ });
6621
6833
  });
6622
6834
  });