@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 +12 -0
- package/CLAUDE.md +11 -0
- package/package.json +5 -3
- package/schemas/fix-entity-change-details.v2.schema.json +268 -0
- package/src/models/configuration/configuration.collection.js +168 -0
- package/src/models/configuration/index.d.ts +22 -0
- package/src/models/fix-entity/change-details.schema.js +11 -4
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.
|
|
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
|
-
})
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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,
|