@adobe/spacecat-shared-data-access 4.12.0 → 4.14.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,15 @@
1
+ ## [@adobe/spacecat-shared-data-access-v4.14.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.13.0...@adobe/spacecat-shared-data-access-v4.14.0) (2026-07-27)
2
+
3
+ ### Features
4
+
5
+ * **data-access:** derive portable JSON Schema artifact for changeDetails v2 (SITES-47997) ([#1830](https://github.com/adobe/spacecat-shared/issues/1830)) ([02163de](https://github.com/adobe/spacecat-shared/commit/02163dea72d007678777b8341d0ded2b97fb56cf)), closes [#1828](https://github.com/adobe/spacecat-shared/issues/1828) [blocking-#4](https://github.com/adobe/blocking-/issues/4)
6
+
7
+ ## [@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)
8
+
9
+ ### Features
10
+
11
+ * **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))
12
+
1
13
  ## [@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
14
 
3
15
  ### Features
package/CLAUDE.md CHANGED
@@ -277,6 +277,17 @@ validated "deploy action" record (who/when/what/which-pages/result) from ADR
277
277
  `CALL_STATUSES`, `APPLIED`, `CHANGE_RESULT_STATUSES`, `VERIFY_VERDICTS`,
278
278
  `SCHEMA_VERSION`, `DEPLOY_RESPONSE_PAYLOAD_MAX_BYTES`). Kept on the model
279
279
  (not the barrel) to avoid collisions on these generic names.
280
+ - **Portable JSON Schema artifact** (for the Python runtime, mystique). The Joi
281
+ schema is the single source of truth; a **JSON Schema is derived from it** and
282
+ shipped at `schemas/fix-entity-change-details.v2.schema.json` (draft-2019-09).
283
+ Regenerate with `npm run generate:schemas` (script:
284
+ `scripts/generate-change-details-schema.js`, uses `joi-to-json`); a unit test
285
+ fails CI if the committed artifact drifts from the Joi schema. mystique vendors
286
+ this file and validates against it. **Caveat:** JSON Schema captures
287
+ structure/enums/required/patterns only — the cross-field invariants
288
+ (`changeResults`↔`target.changes` key-match, `applied:ALL` completeness,
289
+ `deployResponsePayload` 4 KB cap) are Joi `.custom()` rules and are NOT in the
290
+ artifact; a consumer needing them must port those checks.
280
291
  - **Not yet done** (tracked on SITES-47997): the `publishedBy` column +
281
292
  `executedBy/At` → `deployedBy/At` rename (dual-write, needs a companion
282
293
  mysticat-data-service migration), and gating the `DEPLOYED`/`PUBLISHED`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-data-access",
3
- "version": "4.12.0",
3
+ "version": "4.14.0",
4
4
  "description": "Shared modules of the Spacecat Services - Data Access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -19,7 +19,8 @@
19
19
  "test:debug:grep": "mocha --inspect-brk --require ./test/setup-env.js --grep",
20
20
  "lint": "eslint .",
21
21
  "lint:fix": "eslint --fix .",
22
- "clean": "rm -rf package-lock.json node_modules"
22
+ "clean": "rm -rf package-lock.json node_modules",
23
+ "generate:schemas": "node scripts/generate-change-details-schema.js"
23
24
  },
24
25
  "mocha": {
25
26
  "require": "test/setup-env.js",
@@ -42,9 +43,9 @@
42
43
  "dependencies": {
43
44
  "@adobe/fetch": "^4.2.3",
44
45
  "@adobe/spacecat-shared-utils": "1.105.0",
45
- "@supabase/postgrest-js": "2.106.2",
46
46
  "@aws-sdk/client-s3": "^3.940.0",
47
47
  "@smithy/util-retry": "^4.0.0",
48
+ "@supabase/postgrest-js": "2.106.2",
48
49
  "@types/joi": "17.2.3",
49
50
  "aws-xray-sdk": "3.12.0",
50
51
  "joi": "18.2.1",
@@ -54,6 +55,7 @@
54
55
  "chai": "6.2.2",
55
56
  "chai-as-promised": "8.0.2",
56
57
  "electrodb": "3.9.0",
58
+ "joi-to-json": "^5.0.5",
57
59
  "nock": "14.0.15",
58
60
  "sinon": "22.0.0",
59
61
  "sinon-chai": "4.0.1"
@@ -0,0 +1,268 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
3
+ "$id": "https://ns.adobe.com/spacecat/fix-entity/change-details/v2",
4
+ "title": "FixEntity changeDetails v2",
5
+ "description": "Canonical v2 deploy-action record for FixEntity.changeDetails (SITES-47997, ADR adobe/mysticat-architecture#200). GENERATED from the Joi source of truth (changeDetailsV2Schema in spacecat-shared-data-access) — DO NOT EDIT BY HAND; run `npm run generate:schemas -w packages/spacecat-shared-data-access`. Structure/enums/required/patterns only: the cross-field invariants (changeResults<->target.changes key-match, applied:ALL completeness, deployResponsePayload 4KB cap) are Joi-enforced and NOT expressible in JSON Schema.",
6
+ "type": "object",
7
+ "properties": {
8
+ "schemaVersion": {
9
+ "const": 2
10
+ },
11
+ "surface": {
12
+ "type": "string",
13
+ "enum": [
14
+ "ASO",
15
+ "AUTHOR_PUBLISH",
16
+ "GIT_MERGE",
17
+ "SYSTEM"
18
+ ]
19
+ },
20
+ "actorType": {
21
+ "type": "string",
22
+ "enum": [
23
+ "IMS_USER",
24
+ "SERVICE",
25
+ "GITHUB",
26
+ "DETECTOR",
27
+ "UNKNOWN"
28
+ ]
29
+ },
30
+ "target": {
31
+ "type": "object",
32
+ "properties": {
33
+ "system": {
34
+ "type": "string"
35
+ },
36
+ "changeType": {
37
+ "type": "string"
38
+ },
39
+ "siteId": {
40
+ "type": "string"
41
+ },
42
+ "pageId": {
43
+ "type": "string"
44
+ },
45
+ "documentPath": {
46
+ "type": "string"
47
+ },
48
+ "detectedPageAuthorUrl": {
49
+ "type": "string"
50
+ },
51
+ "changes": {
52
+ "type": "array",
53
+ "minItems": 1,
54
+ "items": {
55
+ "type": "object",
56
+ "properties": {
57
+ "targetPath": {
58
+ "type": "string"
59
+ },
60
+ "property": {
61
+ "type": "string"
62
+ },
63
+ "intendedValue": {
64
+ "type": [
65
+ "array",
66
+ "boolean",
67
+ "number",
68
+ "object",
69
+ "string",
70
+ "null"
71
+ ]
72
+ }
73
+ },
74
+ "required": [
75
+ "targetPath",
76
+ "property",
77
+ "intendedValue"
78
+ ],
79
+ "additionalProperties": false
80
+ }
81
+ }
82
+ },
83
+ "required": [
84
+ "changeType",
85
+ "changes"
86
+ ],
87
+ "additionalProperties": false
88
+ },
89
+ "result": {
90
+ "type": "object",
91
+ "properties": {
92
+ "callStatus": {
93
+ "type": "string",
94
+ "enum": [
95
+ "success",
96
+ "no_op",
97
+ "client_error",
98
+ "server_error",
99
+ "timeout"
100
+ ]
101
+ },
102
+ "applied": {
103
+ "type": "string",
104
+ "enum": [
105
+ "ALL",
106
+ "PARTIAL",
107
+ "NONE"
108
+ ]
109
+ },
110
+ "deployResponsePayload": {
111
+ "type": [
112
+ "array",
113
+ "boolean",
114
+ "number",
115
+ "object",
116
+ "string",
117
+ "null"
118
+ ],
119
+ "description": "Raw deploy/apply API response (body + status + relevant headers). MUST be <= 4096 bytes at the write chokepoint; larger payloads must be hashed into deployResponseSha256 and truncated. Size cap enforced by the Joi validator, not by this JSON Schema."
120
+ },
121
+ "deployResponseSha256": {
122
+ "type": "string",
123
+ "pattern": "^[a-fA-F0-9]{64}$"
124
+ },
125
+ "changeResults": {
126
+ "type": "array",
127
+ "items": {
128
+ "type": "object",
129
+ "properties": {
130
+ "targetPath": {
131
+ "type": "string"
132
+ },
133
+ "property": {
134
+ "type": "string"
135
+ },
136
+ "previousValue": {
137
+ "type": [
138
+ "array",
139
+ "boolean",
140
+ "number",
141
+ "object",
142
+ "string",
143
+ "null"
144
+ ]
145
+ },
146
+ "appliedValue": {
147
+ "type": [
148
+ "array",
149
+ "boolean",
150
+ "number",
151
+ "object",
152
+ "string",
153
+ "null"
154
+ ]
155
+ },
156
+ "status": {
157
+ "type": "string",
158
+ "enum": [
159
+ "applied",
160
+ "unchanged",
161
+ "failed"
162
+ ]
163
+ }
164
+ },
165
+ "required": [
166
+ "targetPath",
167
+ "property",
168
+ "status"
169
+ ],
170
+ "additionalProperties": false
171
+ }
172
+ },
173
+ "preVerify": {
174
+ "type": "object",
175
+ "properties": {
176
+ "verdict": {
177
+ "type": "string",
178
+ "enum": [
179
+ "verified",
180
+ "rejected",
181
+ "inconclusive",
182
+ "verify_failed"
183
+ ]
184
+ },
185
+ "reasonCode": {
186
+ "type": "string"
187
+ },
188
+ "evidence": {
189
+ "type": [
190
+ "array",
191
+ "boolean",
192
+ "number",
193
+ "object",
194
+ "string",
195
+ "null"
196
+ ]
197
+ },
198
+ "preVerifiedAt": {
199
+ "type": "string",
200
+ "format": "date-time"
201
+ }
202
+ },
203
+ "required": [
204
+ "verdict"
205
+ ],
206
+ "additionalProperties": false
207
+ },
208
+ "postVerify": {
209
+ "type": "object",
210
+ "properties": {
211
+ "verdict": {
212
+ "type": "string",
213
+ "enum": [
214
+ "verified",
215
+ "rejected",
216
+ "inconclusive",
217
+ "verify_failed"
218
+ ]
219
+ },
220
+ "reasonCode": {
221
+ "type": "string"
222
+ },
223
+ "evidence": {
224
+ "type": [
225
+ "array",
226
+ "boolean",
227
+ "number",
228
+ "object",
229
+ "string",
230
+ "null"
231
+ ]
232
+ },
233
+ "revert": {
234
+ "type": [
235
+ "array",
236
+ "boolean",
237
+ "number",
238
+ "object",
239
+ "string",
240
+ "null"
241
+ ]
242
+ },
243
+ "postVerifiedAt": {
244
+ "type": "string",
245
+ "format": "date-time"
246
+ }
247
+ },
248
+ "required": [
249
+ "verdict"
250
+ ],
251
+ "additionalProperties": false
252
+ }
253
+ },
254
+ "required": [
255
+ "callStatus",
256
+ "applied"
257
+ ],
258
+ "additionalProperties": false
259
+ }
260
+ },
261
+ "required": [
262
+ "schemaVersion",
263
+ "surface",
264
+ "actorType",
265
+ "target"
266
+ ],
267
+ "additionalProperties": false
268
+ }
@@ -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
  }
@@ -157,10 +157,17 @@ const resultSchema = Joi.object({
157
157
  return helpers.error('any.invalid');
158
158
  }
159
159
  return value;
160
- }).messages({
161
- 'any.invalid': `result.deployResponsePayload exceeds ${DEPLOY_RESPONSE_PAYLOAD_MAX_BYTES} bytes; hash it into deployResponseSha256 and truncate at the write chokepoint`,
162
- }),
163
- deployResponseSha256: Joi.string().pattern(/^[a-f0-9]{64}$/i),
160
+ })
161
+ // Carried into the derived JSON Schema so the size-cap caveat travels with
162
+ // the field for JSON-Schema-only (mystique) consumers — the cap itself is a
163
+ // Joi `.custom()` rule and is NOT expressible/enforced in JSON Schema.
164
+ .description(`Raw deploy/apply API response (body + status + relevant headers). MUST be <= ${DEPLOY_RESPONSE_PAYLOAD_MAX_BYTES} bytes at the write chokepoint; larger payloads must be hashed into deployResponseSha256 and truncated. Size cap enforced by the Joi validator, not by this JSON Schema.`)
165
+ .messages({
166
+ 'any.invalid': `result.deployResponsePayload exceeds ${DEPLOY_RESPONSE_PAYLOAD_MAX_BYTES} bytes; hash it into deployResponseSha256 and truncate at the write chokepoint`,
167
+ }),
168
+ // Explicit case ranges (not the /i flag) so the derived JSON Schema `pattern`
169
+ // stays flag-free and portable to Python's re-based validators.
170
+ deployResponseSha256: Joi.string().pattern(/^[a-fA-F0-9]{64}$/),
164
171
  changeResults: Joi.array().items(changeResultSchema),
165
172
  preVerify: preVerifySchema,
166
173
  postVerify: postVerifySchema,