@adobe/spacecat-shared-tokowaka-client 1.21.0 → 1.21.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.21.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.21.0...@adobe/spacecat-shared-tokowaka-client-v1.21.1) (2026-07-15)
2
+
3
+ ### Bug Fixes
4
+
5
+ * utility for marking suggestions as covered ([#1810](https://github.com/adobe/spacecat-shared/issues/1810)) ([2709d47](https://github.com/adobe/spacecat-shared/commit/2709d4741bfe2968dfb481ceedf3e283525f5e71))
6
+
1
7
  ## [@adobe/spacecat-shared-tokowaka-client-v1.21.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.20.0...@adobe/spacecat-shared-tokowaka-client-v1.21.0) (2026-07-14)
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.21.0",
3
+ "version": "1.21.1",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
package/src/index.js CHANGED
@@ -27,7 +27,6 @@ import {
27
27
  } from './utils/s3-utils.js';
28
28
  import {
29
29
  omitKeys,
30
- isEdgeDeployableSuggestionStatus,
31
30
  isPatternSuggestion,
32
31
  groupSuggestionsByUrlPath,
33
32
  filterEligibleSuggestions,
@@ -36,8 +35,8 @@ import {
36
35
  cleanupCoveredSuggestions,
37
36
  classifySuggestions,
38
37
  filterBatchCoveredSuggestions,
38
+ findCoveredSuggestions,
39
39
  } from './utils/suggestion-utils.js';
40
- import { buildUrlMatcher } from './utils/pattern-utils.js';
41
40
  import { getEffectiveBaseURL } from './utils/site-utils.js';
42
41
  import { removePatternFromMetaconfig, addPatternsToMetaconfig } from './utils/metaconfig-utils.js';
43
42
  import { fetchHtmlWithWarmup, calculateForwardedHost } from './utils/custom-html-utils.js';
@@ -1559,41 +1558,13 @@ class TokowakaClient {
1559
1558
  suggestion.setUpdatedBy(updatedBy);
1560
1559
  // suggestion.save() is deferred — caller batches saves via saveSuggestions.
1561
1560
 
1562
- // Mark per-URL suggestions covered by this pattern.
1563
- const matchers = allowedRegexPatterns.flatMap((p) => {
1564
- const m = buildUrlMatcher(p);
1565
- if (!m) {
1566
- // eslint-disable-next-line max-len
1567
- this.log.warn(`[edge-deploy] Pattern '${p}' for suggestion ${suggestion.getId()} is invalid, skipping`);
1568
- }
1569
- return m ? [m] : [];
1570
- });
1571
-
1572
- if (matchers.length === 0) {
1573
- return;
1574
- }
1575
-
1576
- const covered = allSuggestions.filter((s) => {
1577
- if (s.getId() === suggestion.getId()) {
1578
- return false;
1579
- }
1580
- if (skippedInBatchIds.has(s.getId())) {
1581
- return false;
1582
- }
1583
- if (!isEdgeDeployableSuggestionStatus(s.getStatus())) {
1584
- return false;
1585
- }
1586
- if (s.getData()?.edgeDeployed) {
1587
- return false;
1588
- }
1589
- // Path-level pattern suggestions (not domain-wide) are fully covered by a
1590
- // domain-wide deployment. Other pattern suggestions (including other DW ones) are not.
1591
- if (isPatternSuggestion(s)) {
1592
- return coverageField === 'coveredByDomainWide' && !s.getData()?.isDomainWide;
1593
- }
1594
- const url = s.getData()?.url;
1595
- return url && matchers.some((match) => match(url));
1596
- });
1561
+ const covered = findCoveredSuggestions(
1562
+ suggestion,
1563
+ allowedRegexPatterns,
1564
+ allSuggestions,
1565
+ skippedInBatchIds,
1566
+ this.log,
1567
+ );
1597
1568
 
1598
1569
  // eslint-disable-next-line max-len
1599
1570
  this.log.info(`[edge-deploy] Pattern ${suggestion.getId()}: found ${covered.length} coverable per-URL suggestions (field=${coverageField})`);
@@ -1759,6 +1730,64 @@ class TokowakaClient {
1759
1730
  return { succeededSuggestions, failedSuggestions, coveredSuggestions };
1760
1731
  }
1761
1732
 
1733
+ /**
1734
+ * Finds and marks the suggestions that fall within a pattern suggestion's
1735
+ * scope (domain-wide or segment/path) as covered and saves them.
1736
+ *
1737
+ * @param {Object} patternSuggestion - The domain-wide / segment pattern suggestion
1738
+ * @param {Array} allSuggestions - Full opportunity suggestion list to search for matches
1739
+ * @param {string} siteId - Site ID
1740
+ * @param {string} [updatedBy]
1741
+ * @returns {Promise<Array>} the marked suggestions
1742
+ */
1743
+ async markPatternCoveredSuggestions(
1744
+ patternSuggestion,
1745
+ allSuggestions,
1746
+ siteId,
1747
+ updatedBy = 'edge-deploy',
1748
+ ) {
1749
+ const allowedRegexPatterns = patternSuggestion.getData()?.allowedRegexPatterns;
1750
+ if (!Array.isArray(allowedRegexPatterns) || allowedRegexPatterns.length === 0) {
1751
+ this.log.warn(`[edge-deploy] Pattern suggestion ${patternSuggestion.getId()} has `
1752
+ + 'no allowedRegexPatterns, skipping cover-marking');
1753
+ return [];
1754
+ }
1755
+ const covered = findCoveredSuggestions(
1756
+ patternSuggestion,
1757
+ allowedRegexPatterns,
1758
+ allSuggestions,
1759
+ new Set(),
1760
+ this.log,
1761
+ );
1762
+ if (covered.length === 0) {
1763
+ return [];
1764
+ }
1765
+ const coverageField = patternSuggestion.getData()?.isDomainWide
1766
+ ? 'coveredByDomainWide' : 'coveredByPattern';
1767
+ covered.forEach((s) => {
1768
+ s.setData({ ...s.getData(), [coverageField]: patternSuggestion.getId() });
1769
+ s.setUpdatedBy(updatedBy);
1770
+ });
1771
+ try {
1772
+ await saveSuggestions(this.dataAccess, covered, {
1773
+ sqs: this.sqs,
1774
+ queueUrl: this.importWorkerQueueUrl,
1775
+ siteId,
1776
+ set: { [coverageField]: patternSuggestion.getId() },
1777
+ updatedBy,
1778
+ log: this.log,
1779
+ });
1780
+ this.log.info(`[edge-deploy] Marked ${covered.length} suggestions as ${coverageField}=${patternSuggestion.getId()}`);
1781
+ return covered;
1782
+ } catch (saveError) {
1783
+ this.log.warn(
1784
+ '[edge-deploy] Failed to mark covered suggestions for pattern suggestion '
1785
+ + `${patternSuggestion.getId()}: ${saveError.message}`,
1786
+ );
1787
+ return [];
1788
+ }
1789
+ }
1790
+
1762
1791
  /**
1763
1792
  * Strips the `applyStale` flag from patches in S3 for the given per-URL suggestions.
1764
1793
  * Called when an experiment completes so edge reverts to normal (non-stale) behaviour.
@@ -284,3 +284,58 @@ export function filterBatchCoveredSuggestions(validSuggestions, patternSuggestio
284
284
 
285
285
  return { remaining, skippedInBatch };
286
286
  }
287
+
288
+ /**
289
+ * Finds the suggestions in `allSuggestions` that are covered by a pattern suggestion's
290
+ * allowedRegexPatterns
291
+ *
292
+ * @param {Object} patternSuggestion - The domain-wide / segment pattern suggestion
293
+ * @param {Array<string>} allowedRegexPatterns
294
+ * @param {Array} allSuggestions - Full opportunity suggestion list to search
295
+ * @param {Set<string>} [excludeIds] - Suggestion IDs to skip (e.g. already handled in-batch)
296
+ * @param {Object} log - Logger instance
297
+ * @returns {Array} Suggestions covered by the pattern
298
+ */
299
+ export function findCoveredSuggestions(
300
+ patternSuggestion,
301
+ allowedRegexPatterns,
302
+ allSuggestions,
303
+ excludeIds,
304
+ log,
305
+ ) {
306
+ const isDomainWide = patternSuggestion.getData()?.isDomainWide === true;
307
+ const matchers = allowedRegexPatterns.flatMap((p) => {
308
+ const m = buildUrlMatcher(p);
309
+ if (!m) {
310
+ // eslint-disable-next-line max-len
311
+ log.warn(`[edge-deploy] Pattern '${p}' for suggestion ${patternSuggestion.getId()} is invalid, skipping`);
312
+ }
313
+ return m ? [m] : [];
314
+ });
315
+
316
+ if (matchers.length === 0) {
317
+ return [];
318
+ }
319
+
320
+ return allSuggestions.filter((s) => {
321
+ if (s.getId() === patternSuggestion.getId()) {
322
+ return false;
323
+ }
324
+ if (excludeIds?.has(s.getId())) {
325
+ return false;
326
+ }
327
+ if (!isEdgeDeployableSuggestionStatus(s.getStatus())) {
328
+ return false;
329
+ }
330
+ if (s.getData()?.edgeDeployed) {
331
+ return false;
332
+ }
333
+ // Path-level pattern suggestions (not domain-wide) are fully covered by a
334
+ // domain-wide deployment. Other pattern suggestions (including other DW ones) are not.
335
+ if (isPatternSuggestion(s)) {
336
+ return isDomainWide && !s.getData()?.isDomainWide;
337
+ }
338
+ const url = s.getData()?.url;
339
+ return url && matchers.some((match) => match(url));
340
+ });
341
+ }
@@ -147,6 +147,98 @@ describe('TokowakaClient', () => {
147
147
  });
148
148
  });
149
149
 
150
+ describe('markPatternCoveredSuggestions', () => {
151
+ const mkPattern = (isDomainWide) => ({
152
+ getId: () => (isDomainWide ? 'dw-1' : 'path-1'),
153
+ getData: () => (isDomainWide
154
+ ? { isDomainWide: true, allowedRegexPatterns: ['/*'] }
155
+ : { allowedRegexPatterns: ['/foo/*'] }),
156
+ });
157
+ const mkCovered = (id, url = `https://example.com/foo/${id}`) => ({
158
+ getId: () => id,
159
+ getStatus: () => 'NEW',
160
+ getData: () => ({ url }),
161
+ setData: sinon.stub(),
162
+ setUpdatedBy: sinon.stub(),
163
+ save: sinon.stub().resolves(),
164
+ });
165
+
166
+ it('marks all matching suggestions in the opportunity with coveredByDomainWide', async () => {
167
+ const covered = [mkCovered('a'), mkCovered('b')];
168
+
169
+ const result = await client.markPatternCoveredSuggestions(
170
+ mkPattern(true),
171
+ covered,
172
+ 'site-1',
173
+ 'tester',
174
+ );
175
+
176
+ expect(result).to.deep.equal(covered);
177
+ covered.forEach((s) => {
178
+ expect(s.setData.calledWithMatch({ coveredByDomainWide: 'dw-1' })).to.be.true;
179
+ expect(s.setUpdatedBy.calledOnceWith('tester')).to.be.true;
180
+ });
181
+ expect(client.dataAccess.Suggestion.saveMany.calledOnceWith(covered)).to.be.true;
182
+ });
183
+
184
+ it('marks only URLs matching the segment/path pattern with coveredByPattern', async () => {
185
+ const matching = mkCovered('a', 'https://example.com/foo/a');
186
+ const nonMatching = mkCovered('b', 'https://example.com/bar/b');
187
+
188
+ const result = await client.markPatternCoveredSuggestions(
189
+ mkPattern(false),
190
+ [matching, nonMatching],
191
+ 'site-1',
192
+ );
193
+
194
+ expect(result).to.deep.equal([matching]);
195
+ expect(matching.setData.calledWithMatch({ coveredByPattern: 'path-1' })).to.be.true;
196
+ expect(matching.setUpdatedBy.calledOnceWith('edge-deploy')).to.be.true;
197
+ expect(nonMatching.setData.called).to.be.false;
198
+ });
199
+
200
+ it('is a no-op when the pattern suggestion has no allowedRegexPatterns', async () => {
201
+ const noPattern = { getId: () => 'dw-1', getData: () => ({ isDomainWide: true }) };
202
+
203
+ expect(await client.markPatternCoveredSuggestions(noPattern, [mkCovered('a')], 'site-1'))
204
+ .to.deep.equal([]);
205
+ expect(client.dataAccess.Suggestion.saveMany.called).to.be.false;
206
+ });
207
+
208
+ it('is a no-op when nothing in allSuggestions matches', async () => {
209
+ const result = await client.markPatternCoveredSuggestions(mkPattern(true), [], 'site-1');
210
+
211
+ expect(result).to.deep.equal([]);
212
+ expect(client.dataAccess.Suggestion.saveMany.called).to.be.false;
213
+ });
214
+
215
+ it('enqueues a bulk-update job with the passed-in siteId when covered count is large', async () => {
216
+ const covered = Array.from({ length: 1701 }, (_, i) => mkCovered(`c${i}`));
217
+ client.sqs = { sendMessage: sinon.stub().resolves() };
218
+ client.importWorkerQueueUrl = 'https://sqs.test/import-worker-queue';
219
+
220
+ await client.markPatternCoveredSuggestions(mkPattern(true), covered, 'site-1', 'tester');
221
+
222
+ expect(client.sqs.sendMessage).to.have.been.calledOnce;
223
+ const [queueUrl, payload] = client.sqs.sendMessage.firstCall.args;
224
+ expect(queueUrl).to.equal('https://sqs.test/import-worker-queue');
225
+ expect(payload).to.include({ type: 'suggestion-bulk-update', siteId: 'site-1' });
226
+ expect(payload.set).to.deep.equal({ coveredByDomainWide: 'dw-1' });
227
+ expect(payload.updatedBy).to.equal('tester');
228
+ expect(client.dataAccess.Suggestion.saveMany.called).to.be.false;
229
+ });
230
+
231
+ it('warns and returns [] instead of throwing when saveSuggestions fails', async () => {
232
+ client.dataAccess.Suggestion.saveMany.rejects(new Error('DB down'));
233
+ const covered = [mkCovered('a')];
234
+
235
+ const result = await client.markPatternCoveredSuggestions(mkPattern(true), covered, 'site-1');
236
+
237
+ expect(result).to.deep.equal([]);
238
+ expect(client.log.warn.calledWithMatch(/Failed to mark covered suggestions/)).to.be.true;
239
+ });
240
+ });
241
+
150
242
  describe('createFrom', () => {
151
243
  it('should create client from context', () => {
152
244
  const context = {