@adobe/spacecat-shared-data-access 4.12.0 → 4.13.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
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [@adobe/spacecat-shared-data-access-v4.13.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.12.0...@adobe/spacecat-shared-data-access-v4.13.0) (2026-07-23)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **data-access:** list configuration versions via S3 object versioning ([#1839](https://github.com/adobe/spacecat-shared/issues/1839)) ([5dd66fc](https://github.com/adobe/spacecat-shared/commit/5dd66fc9aee60215ea4355395e280c66ae44dcb9))
|
|
6
|
+
|
|
1
7
|
## [@adobe/spacecat-shared-data-access-v4.12.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.11.0...@adobe/spacecat-shared-data-access-v4.12.0) (2026-07-23)
|
|
2
8
|
|
|
3
9
|
### Features
|
package/package.json
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
14
|
GetObjectCommand,
|
|
15
|
+
HeadObjectCommand,
|
|
16
|
+
ListObjectVersionsCommand,
|
|
15
17
|
PutObjectCommand,
|
|
16
18
|
} from '@aws-sdk/client-s3';
|
|
17
19
|
|
|
@@ -22,6 +24,12 @@ import { checkConfiguration } from './configuration.schema.js';
|
|
|
22
24
|
|
|
23
25
|
const S3_CONFIG_KEY = 'config/spacecat/global-config.json';
|
|
24
26
|
|
|
27
|
+
// Cap on concurrent HeadObject calls during version enrichment. Bounds the
|
|
28
|
+
// fan-out regardless of page size so a large `listVersions({ detail: true })`
|
|
29
|
+
// can't fire hundreds of concurrent requests in one tick (socket-pool
|
|
30
|
+
// exhaustion / S3 503 SlowDown in Lambda).
|
|
31
|
+
const ENRICH_CONCURRENCY = 25;
|
|
32
|
+
|
|
25
33
|
/**
|
|
26
34
|
* ConfigurationCollection - A standalone collection class for managing Configuration entities.
|
|
27
35
|
* Unlike other collections, this uses S3 instead of PostgREST.
|
|
@@ -108,6 +116,17 @@ class ConfigurationCollection {
|
|
|
108
116
|
Key: S3_CONFIG_KEY,
|
|
109
117
|
Body: JSON.stringify(configData),
|
|
110
118
|
ContentType: 'application/json',
|
|
119
|
+
// Stamp the audit fields into S3 user-metadata so `listVersions` can
|
|
120
|
+
// surface who/when for each version via a cheap metadata-only HeadObject
|
|
121
|
+
// (no full-body download). Keys are lowercased by S3. We stamp the
|
|
122
|
+
// already-normalized `configData` values (updatedBy defaults to 'system'
|
|
123
|
+
// above, updatedAt is the ISO `now`) — both are guaranteed non-empty
|
|
124
|
+
// strings, so no literal "undefined"/"null" can ever be persisted into
|
|
125
|
+
// the immutable per-version metadata.
|
|
126
|
+
Metadata: {
|
|
127
|
+
updatedby: configData.updatedBy,
|
|
128
|
+
updatedat: configData.updatedAt,
|
|
129
|
+
},
|
|
111
130
|
});
|
|
112
131
|
|
|
113
132
|
const response = await this.s3Client.send(command);
|
|
@@ -205,6 +224,155 @@ class ConfigurationCollection {
|
|
|
205
224
|
throw new DataAccessError(message, this, error);
|
|
206
225
|
}
|
|
207
226
|
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Enriches a version row with `updatedBy`/`updatedAt` read from the object's
|
|
230
|
+
* S3 user-metadata via a metadata-only HeadObject (no body download). Versions
|
|
231
|
+
* written before user-metadata was introduced resolve to null so one missing
|
|
232
|
+
* row never fails the whole page.
|
|
233
|
+
*
|
|
234
|
+
* A HeadObject on a version we *just listed* should only fail for a systemic
|
|
235
|
+
* reason (missing `s3:GetObjectVersion` IAM, throttling) — NOT the expected
|
|
236
|
+
* "object gone" cases. We still degrade to null (enrichment is best-effort and
|
|
237
|
+
* must not sink the primary listing), but we log such failures at `error` so a
|
|
238
|
+
* page that comes back all-null reads as an outage, not as "old versions".
|
|
239
|
+
* @private
|
|
240
|
+
* @param {Object} version - The base version row from `listVersions`.
|
|
241
|
+
* @returns {Promise<Object>} The version row with `updatedBy`/`updatedAt`.
|
|
242
|
+
*/
|
|
243
|
+
async #enrichVersion(version) {
|
|
244
|
+
try {
|
|
245
|
+
const command = new HeadObjectCommand({
|
|
246
|
+
Bucket: this.s3Bucket,
|
|
247
|
+
Key: S3_CONFIG_KEY,
|
|
248
|
+
VersionId: version.versionId,
|
|
249
|
+
});
|
|
250
|
+
const response = await this.s3Client.send(command);
|
|
251
|
+
const metadata = response.Metadata || {};
|
|
252
|
+
return {
|
|
253
|
+
...version,
|
|
254
|
+
updatedBy: metadata.updatedby || null,
|
|
255
|
+
updatedAt: metadata.updatedat || null,
|
|
256
|
+
};
|
|
257
|
+
} catch (error) {
|
|
258
|
+
// NoSuchKey/NoSuchVersion = the version was reaped between list and head;
|
|
259
|
+
// benign. Anything else (AccessDenied, SlowDown, network) is systemic.
|
|
260
|
+
const benign = error.name === 'NoSuchKey' || error.name === 'NoSuchVersion';
|
|
261
|
+
const logAt = benign ? this.log.warn : this.log.error;
|
|
262
|
+
logAt.call(
|
|
263
|
+
this.log,
|
|
264
|
+
`Failed to read metadata for configuration version ${version.versionId} `
|
|
265
|
+
+ `(${error.name || 'Error'}): ${error.message}`,
|
|
266
|
+
);
|
|
267
|
+
return { ...version, updatedBy: null, updatedAt: null };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Enriches version rows in bounded-concurrency batches of `ENRICH_CONCURRENCY`
|
|
273
|
+
* so the HeadObject fan-out stays capped regardless of page size.
|
|
274
|
+
* @private
|
|
275
|
+
* @param {Array<Object>} rawVersions - The base version rows.
|
|
276
|
+
* @returns {Promise<Array<Object>>} The enriched rows, in order.
|
|
277
|
+
*/
|
|
278
|
+
async #enrichVersions(rawVersions) {
|
|
279
|
+
const enriched = [];
|
|
280
|
+
for (let i = 0; i < rawVersions.length; i += ENRICH_CONCURRENCY) {
|
|
281
|
+
const batch = rawVersions.slice(i, i + ENRICH_CONCURRENCY);
|
|
282
|
+
// Serialize batches to bound concurrency; within a batch calls run in parallel.
|
|
283
|
+
// eslint-disable-next-line no-await-in-loop
|
|
284
|
+
const results = await Promise.all(batch.map((version) => this.#enrichVersion(version)));
|
|
285
|
+
enriched.push(...results);
|
|
286
|
+
}
|
|
287
|
+
return enriched;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Lists configuration versions from S3 object versioning, newest first.
|
|
292
|
+
*
|
|
293
|
+
* S3 `ListObjectVersions` returns version-level metadata only (VersionId,
|
|
294
|
+
* LastModified, IsLatest, Size); the human-facing `updatedBy`/`updatedAt`
|
|
295
|
+
* live inside each version's body. When `detail` is true, each row is
|
|
296
|
+
* enriched with a parallel metadata-only HeadObject (see `#enrichVersion`) —
|
|
297
|
+
* cheap because it never downloads the (multi-MB) config body.
|
|
298
|
+
*
|
|
299
|
+
* Callers MUST page on `isTruncated` + the returned markers, NOT on
|
|
300
|
+
* `versions.length`: `MaxKeys` bounds the raw S3 result (versions + any delete
|
|
301
|
+
* markers + sibling-prefix keys) before we filter to the config object, so a
|
|
302
|
+
* page can legitimately return fewer rows than `limit` — or even zero — while
|
|
303
|
+
* `isTruncated` is true. (In practice the global config is PUT-only and never
|
|
304
|
+
* deleted, so delete markers do not occur today.)
|
|
305
|
+
*
|
|
306
|
+
* @param {Object} [options] - Listing options.
|
|
307
|
+
* @param {number} [options.limit=25] - Max versions to return (coerced to an
|
|
308
|
+
* integer and clamped to [1, 1000]). Enrichment concurrency is bounded
|
|
309
|
+
* separately by `ENRICH_CONCURRENCY`, independent of this page size.
|
|
310
|
+
* @param {string} [options.keyMarker] - S3 KeyMarker for pagination.
|
|
311
|
+
* @param {string} [options.versionIdMarker] - S3 VersionIdMarker for pagination.
|
|
312
|
+
* @param {boolean} [options.detail=true] - Enrich rows with updatedBy/updatedAt.
|
|
313
|
+
* @returns {Promise<{versions: Array<Object>, isTruncated: boolean,
|
|
314
|
+
* nextKeyMarker: (string|null), nextVersionIdMarker: (string|null)}>}
|
|
315
|
+
* @throws {DataAccessError} If S3 is not configured or the operation fails.
|
|
316
|
+
*/
|
|
317
|
+
async listVersions({
|
|
318
|
+
limit = 25,
|
|
319
|
+
keyMarker,
|
|
320
|
+
versionIdMarker,
|
|
321
|
+
detail = true,
|
|
322
|
+
} = {}) {
|
|
323
|
+
this.#requireS3();
|
|
324
|
+
|
|
325
|
+
// Coerce + clamp: an unvalidated limit (NaN/negative/huge from a query
|
|
326
|
+
// string) would otherwise flow straight to S3 MaxKeys. (The HeadObject
|
|
327
|
+
// fan-out is bounded separately by #enrichVersions.)
|
|
328
|
+
const parsedLimit = Number.parseInt(limit, 10);
|
|
329
|
+
const maxKeys = Number.isInteger(parsedLimit)
|
|
330
|
+
? Math.min(Math.max(parsedLimit, 1), 1000)
|
|
331
|
+
: 25;
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const command = new ListObjectVersionsCommand({
|
|
335
|
+
Bucket: this.s3Bucket,
|
|
336
|
+
Prefix: S3_CONFIG_KEY,
|
|
337
|
+
MaxKeys: maxKeys,
|
|
338
|
+
...(keyMarker ? { KeyMarker: keyMarker } : {}),
|
|
339
|
+
...(versionIdMarker ? { VersionIdMarker: versionIdMarker } : {}),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const response = await this.s3Client.send(command);
|
|
343
|
+
|
|
344
|
+
// Defensive: the prefix is an exact key, but a shared prefix could in
|
|
345
|
+
// theory match sibling keys — keep only the config object's versions.
|
|
346
|
+
const rawVersions = (response.Versions || [])
|
|
347
|
+
.filter((version) => version.Key === S3_CONFIG_KEY)
|
|
348
|
+
.map((version) => ({
|
|
349
|
+
versionId: version.VersionId,
|
|
350
|
+
lastModified: version.LastModified instanceof Date
|
|
351
|
+
? version.LastModified.toISOString()
|
|
352
|
+
: version.LastModified,
|
|
353
|
+
isLatest: Boolean(version.IsLatest),
|
|
354
|
+
size: version.Size,
|
|
355
|
+
}));
|
|
356
|
+
|
|
357
|
+
const versions = detail
|
|
358
|
+
? await this.#enrichVersions(rawVersions)
|
|
359
|
+
: rawVersions;
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
versions,
|
|
363
|
+
isTruncated: Boolean(response.IsTruncated),
|
|
364
|
+
nextKeyMarker: response.NextKeyMarker || null,
|
|
365
|
+
nextVersionIdMarker: response.NextVersionIdMarker || null,
|
|
366
|
+
};
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error instanceof DataAccessError) {
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
const message = `Failed to list configuration versions from S3: ${error.message}`;
|
|
372
|
+
this.log.error(message, error);
|
|
373
|
+
throw new DataAccessError(message, this, error);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
208
376
|
}
|
|
209
377
|
|
|
210
378
|
export default ConfigurationCollection;
|
|
@@ -60,8 +60,30 @@ export interface Configuration {
|
|
|
60
60
|
updateQueues(queues: object): void;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
export interface ConfigurationVersion {
|
|
64
|
+
versionId: string;
|
|
65
|
+
lastModified: string;
|
|
66
|
+
isLatest: boolean;
|
|
67
|
+
size: number;
|
|
68
|
+
updatedBy?: string | null;
|
|
69
|
+
updatedAt?: string | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ConfigurationVersionsPage {
|
|
73
|
+
versions: ConfigurationVersion[];
|
|
74
|
+
isTruncated: boolean;
|
|
75
|
+
nextKeyMarker: string | null;
|
|
76
|
+
nextVersionIdMarker: string | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
export interface ConfigurationCollection {
|
|
64
80
|
create(data: object): Promise<Configuration>;
|
|
65
81
|
findByVersion(version: string): Promise<Configuration | null>;
|
|
66
82
|
findLatest(): Promise<Configuration | null>;
|
|
83
|
+
listVersions(options?: {
|
|
84
|
+
limit?: number;
|
|
85
|
+
keyMarker?: string;
|
|
86
|
+
versionIdMarker?: string;
|
|
87
|
+
detail?: boolean;
|
|
88
|
+
}): Promise<ConfigurationVersionsPage>;
|
|
67
89
|
}
|