@adobe/spacecat-shared-tokowaka-client 1.19.1 → 1.21.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/src/index.d.ts CHANGED
@@ -155,6 +155,268 @@ export class FastlyKVClient {
155
155
  */
156
156
  export function calculateForwardedHost(url: string, logger?: { debug?: (msg: string) => void; error?: (msg: string) => void }): string;
157
157
 
158
+ /**
159
+ * Temporary AWS credentials returned by {@link assumeConnectorRole}.
160
+ */
161
+ export interface AWSCredentials {
162
+ accessKeyId: string;
163
+ secretAccessKey: string;
164
+ sessionToken: string;
165
+ expiration?: Date;
166
+ }
167
+
168
+ /**
169
+ * One row of the Edge Optimize deploy/plan step contract.
170
+ */
171
+ export interface DeployStep {
172
+ key: string;
173
+ label: string;
174
+ status?: string;
175
+ action?: string;
176
+ detail?: string;
177
+ probe?: Record<string, any>;
178
+ }
179
+
180
+ // ── CloudFront "Optimize at Edge" control-plane (free functions) ──────────────
181
+
182
+ /**
183
+ * Assume the customer's cross-account connector role and return short-lived credentials.
184
+ */
185
+ export function assumeConnectorRole(params: {
186
+ accountId: string;
187
+ externalId: string;
188
+ roleName?: string;
189
+ region?: string;
190
+ }): Promise<{ roleArn: string; accountId: string; credentials: AWSCredentials }>;
191
+
192
+ /**
193
+ * List the CloudFront distributions in the customer account using assumed-role credentials.
194
+ */
195
+ export function listDistributions(
196
+ credentials: AWSCredentials,
197
+ region?: string,
198
+ ): Promise<Array<{
199
+ id: string;
200
+ domainName: string;
201
+ aliases: string[];
202
+ status: string;
203
+ enabled: boolean;
204
+ comment: string;
205
+ }>>;
206
+
207
+ /**
208
+ * Fetch a single CloudFront distribution's configuration using assumed-role credentials.
209
+ */
210
+ export function getDistributionConfig(
211
+ credentials: AWSCredentials,
212
+ distributionId: string,
213
+ region?: string,
214
+ ): Promise<{
215
+ origins: Array<{ id: string; domainName: string; originPath: string }>;
216
+ defaultCacheBehavior: { pathPattern: string; targetOriginId: string } | null;
217
+ cacheBehaviors: Array<{ pathPattern: string; targetOriginId: string }>;
218
+ }>;
219
+
220
+ /**
221
+ * Add the Edge Optimize origin to a CloudFront distribution (idempotent + self-healing).
222
+ */
223
+ export function createOrigin(
224
+ credentials: AWSCredentials,
225
+ distributionId: string,
226
+ originDomain?: string,
227
+ headers?: { apiKey?: string; forwardedHost?: string; fetcherKey?: string },
228
+ region?: string,
229
+ ): Promise<{ created: boolean; alreadyExisted: boolean; updated: boolean; originId: string }>;
230
+
231
+ /**
232
+ * Create or update the routing CloudFront Function and publish it to LIVE (idempotent).
233
+ */
234
+ export function createCloudFrontFunction(
235
+ credentials: AWSCredentials,
236
+ defaultOriginId: string,
237
+ distributionId: string,
238
+ targetedPaths?: string[] | null,
239
+ region?: string,
240
+ ): Promise<{ name: string; created: boolean; stage: string }>;
241
+
242
+ /**
243
+ * Add the Edge Optimize routing headers to the cache key for the target behavior.
244
+ */
245
+ export function updateCacheSettings(
246
+ credentials: AWSCredentials,
247
+ distributionId: string,
248
+ pathPattern: string,
249
+ opts?: { setMinTTLZero?: boolean; region?: string },
250
+ ): Promise<{
251
+ scenario: string;
252
+ policyId: string | null;
253
+ updated: boolean;
254
+ alreadyForwarded: boolean;
255
+ reused?: boolean;
256
+ }>;
257
+
258
+ /**
259
+ * Create (or update) the Edge Optimize Lambda@Edge function and publish a version (idempotent).
260
+ */
261
+ export function createLambdaAtEdge(
262
+ credentials: AWSCredentials,
263
+ accountId: string,
264
+ opts?: {
265
+ region?: string;
266
+ distributionId?: string;
267
+ originDomain?: string;
268
+ retryDelayMs?: number;
269
+ },
270
+ ): Promise<{
271
+ status: string;
272
+ functionArn?: string;
273
+ versionArn: string | null;
274
+ version?: string;
275
+ roleArn: string;
276
+ created: boolean;
277
+ alreadyExisted?: boolean;
278
+ }>;
279
+
280
+ /**
281
+ * Read-only status of the Edge Optimize Lambda@Edge function and its execution role.
282
+ */
283
+ export function getLambdaAtEdgeStatus(
284
+ credentials: AWSCredentials,
285
+ distributionId: string,
286
+ region?: string,
287
+ ): Promise<{
288
+ exists: boolean;
289
+ roleExists: boolean;
290
+ roleOk: boolean;
291
+ state?: string;
292
+ lastUpdateStatus?: string;
293
+ functionArn?: string;
294
+ versionArn: string | null;
295
+ version?: string;
296
+ ready: boolean;
297
+ }>;
298
+
299
+ /**
300
+ * Wire the routing CloudFront Function and the Lambda@Edge function onto a cache behavior.
301
+ */
302
+ export function applyAssociations(
303
+ credentials: AWSCredentials,
304
+ distributionId: string,
305
+ pathPattern: string,
306
+ lambdaVersionArn: string,
307
+ region?: string,
308
+ ): Promise<{ cloudFrontFunctionArn: string; lambdaArn: string }>;
309
+
310
+ /**
311
+ * Verify Edge Optimize routing end-to-end by probing as a bot and as a human.
312
+ */
313
+ export function verifyRouting(url: string): Promise<{
314
+ passed: boolean;
315
+ requestId: string | null;
316
+ details: { bot: Record<string, any>; human: Record<string, any> };
317
+ }>;
318
+
319
+ /**
320
+ * Run one poll of the idempotent Edge Optimize "Deploy routing" orchestrator.
321
+ */
322
+ export function runDeployStep(
323
+ credentials: AWSCredentials,
324
+ params: {
325
+ distributionId: string;
326
+ originId: string;
327
+ behavior: string;
328
+ originDomain?: string;
329
+ originHeaders?: { apiKey?: string; forwardedHost?: string; fetcherKey?: string };
330
+ accountId: string;
331
+ },
332
+ region?: string,
333
+ ): Promise<{ routingDeployed: boolean; verified: boolean; steps: DeployStep[] }>;
334
+
335
+ /**
336
+ * Read-only "preview" of what {@link runDeployStep} would do, without mutating.
337
+ */
338
+ export function planDeploy(
339
+ credentials: AWSCredentials,
340
+ params: {
341
+ distributionId: string;
342
+ originId?: string;
343
+ behavior: string;
344
+ originDomain?: string;
345
+ originHeaders?: { apiKey?: string; forwardedHost?: string; fetcherKey?: string };
346
+ accountId?: string;
347
+ },
348
+ region?: string,
349
+ ): Promise<{ canProceed: boolean; blocker: string | null; steps: DeployStep[] }>;
350
+
351
+ export interface CloudFrontEdgeClientOptions {
352
+ credentials: AWSCredentials;
353
+ region?: string;
354
+ }
355
+
356
+ /**
357
+ * CloudFront control-plane client initialized with short-lived AWS credentials.
358
+ */
359
+ export class CloudFrontEdgeClient {
360
+ constructor(options: CloudFrontEdgeClientOptions);
361
+
362
+ listDistributions(): ReturnType<typeof listDistributions>;
363
+
364
+ getDistributionConfig(distributionId: string): ReturnType<typeof getDistributionConfig>;
365
+
366
+ createOrigin(
367
+ distributionId: string,
368
+ originDomain?: string,
369
+ headers?: { apiKey?: string; forwardedHost?: string; fetcherKey?: string },
370
+ ): ReturnType<typeof createOrigin>;
371
+
372
+ createCloudFrontFunction(
373
+ defaultOriginId: string,
374
+ distributionId: string,
375
+ targetedPaths?: string[] | null,
376
+ ): ReturnType<typeof createCloudFrontFunction>;
377
+
378
+ updateCacheSettings(
379
+ distributionId: string,
380
+ pathPattern: string,
381
+ opts?: { setMinTTLZero?: boolean; region?: string },
382
+ ): ReturnType<typeof updateCacheSettings>;
383
+
384
+ createLambdaAtEdge(
385
+ accountId: string,
386
+ opts?: {
387
+ region?: string;
388
+ distributionId?: string;
389
+ originDomain?: string;
390
+ retryDelayMs?: number;
391
+ },
392
+ ): ReturnType<typeof createLambdaAtEdge>;
393
+
394
+ getLambdaAtEdgeStatus(distributionId: string): ReturnType<typeof getLambdaAtEdgeStatus>;
395
+
396
+ applyAssociations(
397
+ distributionId: string,
398
+ pathPattern: string,
399
+ lambdaVersionArn: string,
400
+ ): ReturnType<typeof applyAssociations>;
401
+
402
+ runDeployStep(params: Parameters<typeof runDeployStep>[1]): ReturnType<typeof runDeployStep>;
403
+
404
+ planDeploy(params: Parameters<typeof planDeploy>[1]): ReturnType<typeof planDeploy>;
405
+ }
406
+
407
+ /**
408
+ * Build the CloudFront Function (viewer-request) routing code.
409
+ */
410
+ export function buildCloudfrontFunctionCode(
411
+ defaultOriginId: string,
412
+ targetedPaths?: string[] | null,
413
+ ): string;
414
+
415
+ /**
416
+ * Build an in-memory zip containing a single file (used to package the Lambda@Edge code).
417
+ */
418
+ export function buildLambdaZip(filename: string, content: string | Buffer): Buffer;
419
+
158
420
  /**
159
421
  * Base class for opportunity mappers
160
422
  * Extend this class to create custom mappers for new opportunity types
@@ -481,4 +743,3 @@ export default class TokowakaClient {
481
743
  */
482
744
  getSupportedOpportunityTypes(): string[];
483
745
  }
484
-
package/src/index.js CHANGED
@@ -50,6 +50,26 @@ import {
50
50
 
51
51
  export { FastlyKVClient } from './fastly-kv-client.js';
52
52
  export { calculateForwardedHost } from './utils/custom-html-utils.js';
53
+ export { SUGGESTION_BULK_UPDATE_TYPE } from './utils/suggestion-utils.js';
54
+
55
+ // CloudFront control-plane (free functions + constants).
56
+ export {
57
+ assumeConnectorRole,
58
+ listDistributions,
59
+ getDistributionConfig,
60
+ createOrigin,
61
+ createCloudFrontFunction,
62
+ updateCacheSettings,
63
+ createLambdaAtEdge,
64
+ getLambdaAtEdgeStatus,
65
+ applyAssociations,
66
+ verifyRouting,
67
+ runDeployStep,
68
+ planDeploy,
69
+ CloudFrontEdgeClient,
70
+ buildCloudfrontFunctionCode,
71
+ buildLambdaZip,
72
+ } from './cdn/cloudfront/index.js';
53
73
 
54
74
  const HTTP_BAD_REQUEST = 400;
55
75
  const HTTP_INTERNAL_SERVER_ERROR = 500;
@@ -69,6 +89,7 @@ class TokowakaClient {
69
89
  const {
70
90
  TOKOWAKA_SITE_CONFIG_BUCKET: bucketName,
71
91
  TOKOWAKA_PREVIEW_BUCKET: previewBucketName,
92
+ IMPORT_WORKER_QUEUE_URL: importWorkerQueueUrl,
72
93
  } = env;
73
94
 
74
95
  if (context.tokowakaClient) {
@@ -81,6 +102,8 @@ class TokowakaClient {
81
102
  s3Client: s3?.s3Client ?? context.s3Client,
82
103
  env,
83
104
  dataAccess: context.dataAccess,
105
+ sqs: context.sqs,
106
+ importWorkerQueueUrl,
84
107
  }, log);
85
108
  context.tokowakaClient = client;
86
109
  return client;
@@ -95,10 +118,13 @@ class TokowakaClient {
95
118
  * @param {Object} config.env - Environment variables (for CDN credentials)
96
119
  * @param {Object} [config.dataAccess] - Data access layer
97
120
  * (provides Suggestion.saveMany for batch saves)
121
+ * @param {Object} [config.sqs] - SQS client (sendMessage(queueUrl, payload)). Used to offload
122
+ * very large covered-suggestion saves to the import worker instead of saving them directly.
123
+ * @param {string} [config.importWorkerQueueUrl] - Import worker SQS queue URL.
98
124
  * @param {Object} log - Logger instance
99
125
  */
100
126
  constructor({
101
- bucketName, previewBucketName, s3Client, env = {}, dataAccess,
127
+ bucketName, previewBucketName, s3Client, env = {}, dataAccess, sqs, importWorkerQueueUrl,
102
128
  }, log) {
103
129
  this.log = log;
104
130
 
@@ -115,6 +141,8 @@ class TokowakaClient {
115
141
  this.s3Client = s3Client;
116
142
  this.env = env;
117
143
  this.dataAccess = dataAccess;
144
+ this.sqs = sqs;
145
+ this.importWorkerQueueUrl = importWorkerQueueUrl;
118
146
 
119
147
  this.mapperRegistry = new MapperRegistry(log);
120
148
  this.cdnClientRegistry = new CdnClientRegistry(env, log);
@@ -1058,7 +1086,14 @@ class TokowakaClient {
1058
1086
  this.log.info(`[edge-rollback] Cleaning ${covered.length} covered suggestion(s) for pattern ${suggestion.getId()} (isDomainWide=${isDomainWide}, fallback=${coveredFallback})`);
1059
1087
  }
1060
1088
  // eslint-disable-next-line no-await-in-loop, max-len
1061
- await cleanupCoveredSuggestions(this.dataAccess, covered, coveredFallback, updatedBy, fieldsToStrip, this.log);
1089
+ await cleanupCoveredSuggestions(this.dataAccess, covered, coveredFallback, updatedBy, fieldsToStrip, this.log, {
1090
+ sqs: this.sqs,
1091
+ queueUrl: this.importWorkerQueueUrl,
1092
+ siteId: site.getId(),
1093
+ unset: fieldsToStrip,
1094
+ updatedBy: updatedBy ?? coveredFallback,
1095
+ log: this.log,
1096
+ });
1062
1097
  }
1063
1098
  }
1064
1099
  }
@@ -1485,6 +1520,7 @@ class TokowakaClient {
1485
1520
  * @param {Array} allSuggestions - Full opportunity suggestion list
1486
1521
  * @param {string} updatedBy
1487
1522
  * @param {Array} coveredSuggestions - Accumulator (mutated)
1523
+ * @param {string} siteId
1488
1524
  * @returns {Promise<void>} Throws on metaconfig upload failure
1489
1525
  * @private
1490
1526
  */
@@ -1497,6 +1533,7 @@ class TokowakaClient {
1497
1533
  allSuggestions,
1498
1534
  updatedBy,
1499
1535
  coveredSuggestions,
1536
+ siteId,
1500
1537
  ) {
1501
1538
  const data = suggestion.getData();
1502
1539
  const coverageField = data?.isDomainWide ? 'coveredByDomainWide' : 'coveredByPattern';
@@ -1567,7 +1604,14 @@ class TokowakaClient {
1567
1604
  cs.setUpdatedBy(updatedBy);
1568
1605
  });
1569
1606
  try {
1570
- await saveSuggestions(this.dataAccess, covered);
1607
+ await saveSuggestions(this.dataAccess, covered, {
1608
+ sqs: this.sqs,
1609
+ queueUrl: this.importWorkerQueueUrl,
1610
+ siteId,
1611
+ set: { [coverageField]: suggestion.getId() },
1612
+ updatedBy,
1613
+ log: this.log,
1614
+ });
1571
1615
  coveredSuggestions.push(...covered);
1572
1616
  // eslint-disable-next-line max-len
1573
1617
  this.log.info(`[edge-deploy] Marked ${covered.length} suggestions as ${coverageField}=${suggestion.getId()}`);
@@ -1660,6 +1704,7 @@ class TokowakaClient {
1660
1704
  allSuggestions,
1661
1705
  updatedBy,
1662
1706
  coveredSuggestions,
1707
+ site.getId(),
1663
1708
  );
1664
1709
  deployedPatternSuggestions.push(suggestion);
1665
1710
  } catch (error) {
@@ -92,26 +92,68 @@ export function filterEligibleSuggestions(suggestions, mapper) {
92
92
  return { eligible, ineligible };
93
93
  }
94
94
 
95
+ // Import worker job type for a bulk suggestion field update (agnostic set/unset by id).
96
+ export const SUGGESTION_BULK_UPDATE_TYPE = 'suggestion-bulk-update';
97
+
95
98
  /**
96
99
  * Batch-saves suggestions using the optimal strategy based on count.
97
100
  *
98
101
  * - <= 1700: sequential chunked upsert via saveMany (chunkSize 25).
99
102
  * ~4s for 1700 suggestions from the DB layer, but ~11s observed end-to-end
100
103
  * 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.
104
+ * - > 1700 with no queueContext: parallel individual .save() via Promise.allSettled to
105
+ * avoid sequential chunk bottleneck at scale.
106
+ * - > 1700 with queueContext: enqueues a SUGGESTION_BULK_UPDATE_TYPE job to the import
107
+ * worker instead of saving here (the worker fetches by id and applies
108
+ * queueContext.set/unset itself). Falls back to the Promise.allSettled path above
109
+ * if sqs/queueUrl aren't configured or sendMessage fails, so a queue outage
110
+ * degrades rather than silently drops the save.
103
111
  *
104
112
  * @param {Object} dataAccess - Data access layer
105
113
  * @param {Array} suggestions - Suggestion entities to save
114
+ * @param {Object} [queueContext] - sqs, queueUrl, siteId, set/unset,
115
+ * updatedBy (already resolved), log
106
116
  * @returns {Promise<void>}
107
117
  */
108
118
  const PARALLEL_SAVE_THRESHOLD = 1700;
109
119
 
110
- export async function saveSuggestions(dataAccess, suggestions) {
120
+ export async function saveSuggestions(dataAccess, suggestions, queueContext) {
111
121
  if (suggestions.length === 0) {
112
122
  return;
113
123
  }
114
124
  if (suggestions.length > PARALLEL_SAVE_THRESHOLD) {
125
+ if (queueContext) {
126
+ const {
127
+ sqs, queueUrl, siteId, set, unset, updatedBy, log,
128
+ } = queueContext;
129
+
130
+ if (queueUrl && sqs) {
131
+ try {
132
+ await sqs.sendMessage(queueUrl, {
133
+ type: SUGGESTION_BULK_UPDATE_TYPE,
134
+ siteId,
135
+ suggestionIds: suggestions.map((s) => s.getId()),
136
+ ...(set && { set }),
137
+ ...(unset && { unset }),
138
+ updatedBy,
139
+ });
140
+ log.info(
141
+ `[suggestion-bulk-update] Queued bulk update for ${suggestions.length} suggestion(s)`,
142
+ );
143
+ return;
144
+ } catch (error) {
145
+ log.error(
146
+ `[suggestion-bulk-update] Failed to queue bulk update, falling back to direct save: ${error.message}`,
147
+ );
148
+ }
149
+ } else {
150
+ log.warn(
151
+ '[suggestion-bulk-update] SQS/IMPORT_WORKER_QUEUE_URL not configured; '
152
+ + `falling back to direct save for ${suggestions.length} suggestion(s)`,
153
+ );
154
+ }
155
+ }
156
+
115
157
  const results = await Promise.allSettled(suggestions.map((s) => s.save()));
116
158
  const failed = results.filter((r) => r.status === 'rejected');
117
159
  if (failed.length > 0) {
@@ -150,10 +192,11 @@ export function stripSuggestion(suggestion, actorFallback, updatedBy) {
150
192
  * @param {string|undefined} updatedBy - Explicit actor
151
193
  * @param {string[]} fieldsToStrip - Specific fields to remove
152
194
  * @param {Object} log - Logger instance
195
+ * @param {Object} [queueContext] - Passed through to saveSuggestions unchanged
153
196
  * @returns {Promise<void>}
154
197
  */
155
198
  // eslint-disable-next-line max-len
156
- export async function cleanupCoveredSuggestions(dataAccess, covered, actorFallback, updatedBy, fieldsToStrip, log) {
199
+ export async function cleanupCoveredSuggestions(dataAccess, covered, actorFallback, updatedBy, fieldsToStrip, log, queueContext) {
157
200
  if (covered.length === 0) {
158
201
  return;
159
202
  }
@@ -162,7 +205,7 @@ export async function cleanupCoveredSuggestions(dataAccess, covered, actorFallba
162
205
  cs.setUpdatedBy(updatedBy ?? actorFallback);
163
206
  });
164
207
  try {
165
- await saveSuggestions(dataAccess, covered);
208
+ await saveSuggestions(dataAccess, covered, queueContext);
166
209
  } catch (error) {
167
210
  // eslint-disable-next-line max-len
168
211
  log.error(`[edge-rollback-failed] Failed to clean ${covered.length} covered suggestion(s): ${error.message}`);