@adobe/spacecat-shared-tokowaka-client 1.17.3 → 1.18.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 +6 -0
- package/package.json +1 -1
- package/src/index.js +450 -236
- package/src/utils/metaconfig-utils.js +52 -0
- package/src/utils/pattern-utils.js +43 -0
- package/src/utils/suggestion-utils.js +174 -0
- package/test/index.test.js +1575 -229
- package/test/utils/suggestion-utils.test.js +43 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Adobe. All rights reserved.
|
|
3
|
+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
* governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Removes a single pattern from the metaconfig's prerender allowList in-place.
|
|
15
|
+
* Deletes the prerender key entirely when the resulting list would be empty.
|
|
16
|
+
* @param {Object} metaconfig - Metaconfig object (mutated in place)
|
|
17
|
+
* @param {string} pattern - Pattern to remove
|
|
18
|
+
* @returns {boolean} True if the allowList was changed
|
|
19
|
+
*/
|
|
20
|
+
export function removePatternFromMetaconfig(metaconfig, pattern) {
|
|
21
|
+
const existing = metaconfig.prerender?.allowList ?? [];
|
|
22
|
+
const updated = existing.filter((p) => p !== pattern);
|
|
23
|
+
if (updated.length === existing.length) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (updated.length === 0) {
|
|
27
|
+
// eslint-disable-next-line no-param-reassign
|
|
28
|
+
delete metaconfig.prerender;
|
|
29
|
+
} else {
|
|
30
|
+
// eslint-disable-next-line no-param-reassign
|
|
31
|
+
metaconfig.prerender = { allowList: updated };
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Appends patterns to the metaconfig's prerender allowList (deduplicated).
|
|
38
|
+
* Mutates the metaconfig in place.
|
|
39
|
+
* @param {Object} metaconfig - Metaconfig object (mutated in place)
|
|
40
|
+
* @param {Array<string>} patterns - Patterns to add
|
|
41
|
+
* @returns {boolean} True if the allowList was changed
|
|
42
|
+
*/
|
|
43
|
+
export function addPatternsToMetaconfig(metaconfig, patterns) {
|
|
44
|
+
const existing = metaconfig.prerender?.allowList ?? [];
|
|
45
|
+
const merged = [...new Set([...existing, ...patterns])];
|
|
46
|
+
if (merged.length === existing.length) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
// eslint-disable-next-line no-param-reassign
|
|
50
|
+
metaconfig.prerender = { allowList: merged };
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Adobe. All rights reserved.
|
|
3
|
+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
* governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Builds a URL matcher function for a given allowedRegexPatterns entry.
|
|
15
|
+
* Patterns ending with `/*` use pathname prefix matching (e.g. `/products/*` matches `/products/`).
|
|
16
|
+
* When the pattern is `/*`, the prefix becomes '' and matches all URLs
|
|
17
|
+
* (intentional for domain-wide).
|
|
18
|
+
* Other patterns are compiled as regular expressions for backward compatibility.
|
|
19
|
+
* Returns null for patterns that cannot be compiled.
|
|
20
|
+
* @param {string} pattern
|
|
21
|
+
* @returns {((url: string) => boolean)|null}
|
|
22
|
+
*/
|
|
23
|
+
export function buildUrlMatcher(pattern) {
|
|
24
|
+
if (typeof pattern !== 'string' || pattern.length === 0) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (pattern.endsWith('/*')) {
|
|
28
|
+
const prefix = pattern.slice(0, -2); // '/products/*' → '/products', '/*' → ''
|
|
29
|
+
return (url) => {
|
|
30
|
+
try {
|
|
31
|
+
return new URL(url).pathname.startsWith(prefix);
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const regex = new RegExp(pattern);
|
|
39
|
+
return (url) => regex.test(url);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -10,6 +10,30 @@
|
|
|
10
10
|
* governing permissions and limitations under the License.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { buildUrlMatcher } from './pattern-utils.js';
|
|
14
|
+
|
|
15
|
+
/** Returns a shallow copy of obj with the specified keys removed. */
|
|
16
|
+
export function omitKeys(obj, keys) {
|
|
17
|
+
const keySet = new Set(keys);
|
|
18
|
+
return Object.fromEntries(Object.entries(obj).filter(([k]) => !keySet.has(k)));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Matches SpaceCat API eligibility for edge deploy (non-domain-wide). */
|
|
22
|
+
export function isEdgeDeployableSuggestionStatus(status) {
|
|
23
|
+
return status === 'NEW' || status === 'PENDING_VALIDATION';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns true if this suggestion uses pattern-based allow-list deploy (domain-wide or path-level).
|
|
28
|
+
* Detected by the presence of a non-empty allowedRegexPatterns array, or by isDomainWide: true
|
|
29
|
+
* (which may have an empty array when malformed — handled gracefully in the deploy loop).
|
|
30
|
+
*/
|
|
31
|
+
export function isPatternSuggestion(suggestion) {
|
|
32
|
+
const data = suggestion.getData();
|
|
33
|
+
return data?.isDomainWide === true
|
|
34
|
+
|| (Array.isArray(data?.allowedRegexPatterns) && data.allowedRegexPatterns.length > 0);
|
|
35
|
+
}
|
|
36
|
+
|
|
13
37
|
/**
|
|
14
38
|
* Groups suggestions by URL pathname
|
|
15
39
|
* @param {Array} suggestions - Array of suggestion entities
|
|
@@ -67,3 +91,153 @@ export function filterEligibleSuggestions(suggestions, mapper) {
|
|
|
67
91
|
|
|
68
92
|
return { eligible, ineligible };
|
|
69
93
|
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Batch-saves suggestions using the optimal strategy based on count.
|
|
97
|
+
*
|
|
98
|
+
* - <= 1700: sequential chunked upsert via saveMany (chunkSize 25).
|
|
99
|
+
* ~4s for 1700 suggestions from the DB layer, but ~11s observed end-to-end
|
|
100
|
+
* from the API due to serialization, network, and Lambda overhead.
|
|
101
|
+
* - > 1700: parallel individual .save() via Promise.allSettled to avoid
|
|
102
|
+
* sequential chunk bottleneck at scale.
|
|
103
|
+
*
|
|
104
|
+
* @param {Object} dataAccess - Data access layer
|
|
105
|
+
* @param {Array} suggestions - Suggestion entities to save
|
|
106
|
+
* @returns {Promise<void>}
|
|
107
|
+
*/
|
|
108
|
+
const PARALLEL_SAVE_THRESHOLD = 1700;
|
|
109
|
+
|
|
110
|
+
export async function saveSuggestions(dataAccess, suggestions) {
|
|
111
|
+
if (suggestions.length === 0) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (suggestions.length > PARALLEL_SAVE_THRESHOLD) {
|
|
115
|
+
const results = await Promise.allSettled(suggestions.map((s) => s.save()));
|
|
116
|
+
const failed = results.filter((r) => r.status === 'rejected');
|
|
117
|
+
if (failed.length > 0) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`${failed.length} of ${suggestions.length} suggestions failed to save: `
|
|
120
|
+
+ `${failed[0].reason?.message}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await dataAccess.Suggestion.saveMany(suggestions, { chunkSize: 25 });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Strips deployment markers from a suggestion's data and sets updatedBy.
|
|
130
|
+
* Does not save — caller is responsible for batching saves via saveSuggestions.
|
|
131
|
+
* @param {Object} suggestion - Suggestion entity
|
|
132
|
+
* @param {string} actorFallback - Fallback string when updatedBy is undefined
|
|
133
|
+
* @param {string|undefined} updatedBy - Explicit actor (overrides fallback when defined)
|
|
134
|
+
* @returns {Object} The mutated suggestion (not yet persisted)
|
|
135
|
+
*/
|
|
136
|
+
export function stripSuggestion(suggestion, actorFallback, updatedBy) {
|
|
137
|
+
suggestion.setData(omitKeys(suggestion.getData(), ['edgeDeployed', 'tokowakaDeployed']));
|
|
138
|
+
suggestion.setUpdatedBy(updatedBy ?? actorFallback);
|
|
139
|
+
return suggestion;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Clears coverage and deployment markers from suggestions that were covered by a pattern.
|
|
144
|
+
* Only strips the fields relevant to the rollback type so independent coverage layers
|
|
145
|
+
* are preserved. For example, rolling back domain-wide should only clear
|
|
146
|
+
* coveredByDomainWide — not coveredByPattern (which belongs to a separate path deploy).
|
|
147
|
+
* @param {Object} dataAccess - Data access layer
|
|
148
|
+
* @param {Array} covered - Covered suggestion entities
|
|
149
|
+
* @param {string} actorFallback - Fallback updatedBy string
|
|
150
|
+
* @param {string|undefined} updatedBy - Explicit actor
|
|
151
|
+
* @param {string[]} fieldsToStrip - Specific fields to remove
|
|
152
|
+
* @param {Object} log - Logger instance
|
|
153
|
+
* @returns {Promise<void>}
|
|
154
|
+
*/
|
|
155
|
+
// eslint-disable-next-line max-len
|
|
156
|
+
export async function cleanupCoveredSuggestions(dataAccess, covered, actorFallback, updatedBy, fieldsToStrip, log) {
|
|
157
|
+
if (covered.length === 0) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
covered.forEach((cs) => {
|
|
161
|
+
cs.setData(omitKeys(cs.getData(), fieldsToStrip));
|
|
162
|
+
cs.setUpdatedBy(updatedBy ?? actorFallback);
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
await saveSuggestions(dataAccess, covered);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
// eslint-disable-next-line max-len
|
|
168
|
+
log.error(`[edge-rollback-failed] Failed to clean ${covered.length} covered suggestion(s): ${error.message}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Classifies a batch of target suggestions into pattern-based and per-URL buckets.
|
|
174
|
+
* Pattern suggestions (domain-wide or path-level) are returned as
|
|
175
|
+
* `{ suggestion, allowedRegexPatterns }` objects; per-URL suggestions are filtered
|
|
176
|
+
* to only those with a deployable status.
|
|
177
|
+
* @param {Array} targetSuggestions
|
|
178
|
+
* @param {Object} log - Logger instance
|
|
179
|
+
* @returns {{ patternSuggestions: Array, validSuggestions: Array }}
|
|
180
|
+
*/
|
|
181
|
+
export function classifySuggestions(targetSuggestions, log) {
|
|
182
|
+
const patternSuggestions = [];
|
|
183
|
+
const validSuggestions = [];
|
|
184
|
+
|
|
185
|
+
targetSuggestions.forEach((suggestion) => {
|
|
186
|
+
const data = suggestion.getData();
|
|
187
|
+
if (isPatternSuggestion(suggestion)) {
|
|
188
|
+
log.info(
|
|
189
|
+
`[edge-deploy] Classified as PATTERN: ${suggestion.getId()} `
|
|
190
|
+
+ `patterns=${JSON.stringify(data.allowedRegexPatterns)} `
|
|
191
|
+
+ `isDomainWide=${data?.isDomainWide}`,
|
|
192
|
+
);
|
|
193
|
+
patternSuggestions.push({ suggestion, allowedRegexPatterns: data.allowedRegexPatterns });
|
|
194
|
+
} else if (isEdgeDeployableSuggestionStatus(suggestion.getStatus())) {
|
|
195
|
+
validSuggestions.push(suggestion);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return { patternSuggestions, validSuggestions };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Splits validSuggestions into those covered by an in-batch pattern and those that are not.
|
|
204
|
+
* Returns the two arrays; validSuggestions itself is not mutated.
|
|
205
|
+
* @param {Array} validSuggestions - Per-URL suggestions
|
|
206
|
+
* @param {Array} patternSuggestions - Pattern suggestions in the same batch
|
|
207
|
+
* @param {Object} log - Logger instance
|
|
208
|
+
* @returns {{ remaining: Array, skippedInBatch: Array }}
|
|
209
|
+
*/
|
|
210
|
+
export function filterBatchCoveredSuggestions(validSuggestions, patternSuggestions, log) {
|
|
211
|
+
if (patternSuggestions.length === 0 || validSuggestions.length === 0) {
|
|
212
|
+
return { remaining: validSuggestions, skippedInBatch: [] };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Build matchers per pattern suggestion so we can track which type covered each URL.
|
|
216
|
+
// eslint-disable-next-line max-len
|
|
217
|
+
const matcherEntries = patternSuggestions.flatMap(({ suggestion, allowedRegexPatterns }) => {
|
|
218
|
+
const isDomainWide = suggestion.getData()?.isDomainWide === true;
|
|
219
|
+
return allowedRegexPatterns
|
|
220
|
+
.map(buildUrlMatcher)
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
.map((matcher) => ({ matcher, isDomainWide }));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (matcherEntries.length === 0) {
|
|
226
|
+
return { remaining: validSuggestions, skippedInBatch: [] };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const remaining = [];
|
|
230
|
+
const skippedInBatch = [];
|
|
231
|
+
validSuggestions.forEach((s) => {
|
|
232
|
+
const url = s.getData()?.url;
|
|
233
|
+
const match = url && matcherEntries.find(({ matcher }) => matcher(url));
|
|
234
|
+
if (match) {
|
|
235
|
+
skippedInBatch.push({ suggestion: s, isDomainWide: match.isDomainWide });
|
|
236
|
+
log.info(`[edge-deploy] Skipping suggestion ${s.getId()} - covered by pattern`);
|
|
237
|
+
} else {
|
|
238
|
+
remaining.push(s);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return { remaining, skippedInBatch };
|
|
243
|
+
}
|