@adobe/spacecat-shared-data-access 4.11.0 → 4.12.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.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
+
3
+ ### Features
4
+
5
+ * **data-access:** canonical v2 changeDetails schema + validator for FixEntity (SITES-47997) ([#1828](https://github.com/adobe/spacecat-shared/issues/1828)) ([afdb546](https://github.com/adobe/spacecat-shared/commit/afdb5464d6af6ac6ea9f6f0075d798feb38a5699))
6
+
1
7
  ## [@adobe/spacecat-shared-data-access-v4.11.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.10.1...@adobe/spacecat-shared-data-access-v4.11.0) (2026-07-21)
2
8
 
3
9
  ### Features
package/CLAUDE.md CHANGED
@@ -247,6 +247,43 @@ Consumer adoption (bump + route writes + warn→enforce) is tracked in SITES-472
247
247
 
248
248
  [adobe/mysticat-architecture#174]: https://github.com/adobe/mysticat-architecture/blob/main/platform/decisions/design-suggestion-fix-entity-status-lifecycle.md
249
249
 
250
+ ## Deploy-action `changeDetails` v2 (SITES-47997)
251
+
252
+ `FixEntity.changeDetails` has a canonical **v2 shape** — the structured,
253
+ validated "deploy action" record (who/when/what/which-pages/result) from ADR
254
+ [adobe/mysticat-architecture#200]. It is defined in
255
+ `src/models/fix-entity/change-details.schema.js` (Joi) and wired into the
256
+ `changeDetails` attribute validator.
257
+
258
+ - **Reader-tolerant / additive.** The DB column stays `type: 'any'` — no
259
+ migration. Records **without `schemaVersion: 2`** are legacy freeform (v1) and
260
+ keep the old non-empty-object guard; only `schemaVersion: 2` records are
261
+ schema-validated. This runs on the **create** path (`#validateItem` →
262
+ `collection.create`), not on `save()` updates.
263
+ - **Shape.** `{ schemaVersion: 2, surface, actorType, target, result? }`.
264
+ `target` = the proposal (intent): `changeType` + `changes[{ targetPath,
265
+ property, intendedValue }]`. `result` = the outcome: `callStatus`
266
+ (`success|no_op|client_error|server_error|timeout`), `applied`
267
+ (**enum `ALL|PARTIAL|NONE`, not a boolean**), `changeResults[]` (key-matched to
268
+ `target.changes` by `(targetPath, property)`), `pre/postVerify` verdicts, and
269
+ `deployResponsePayload` (**≤ 4 KB cap**) + `deployResponseSha256` (hex).
270
+ - **APIs** (exported from the package root):
271
+ - `validateChangeDetails(value)` — the attribute validator; `false` on a
272
+ non-object, `true` for legacy/valid-v2, **throws** with a descriptive message
273
+ on an invalid v2 record.
274
+ - `changeDetailsV2Schema` — the raw Joi `ObjectSchema` (strict; unknown keys
275
+ rejected) for consumers that want to validate directly.
276
+ - `FixEntity.CHANGE_DETAILS` — the enum bundle (`SURFACES`, `ACTOR_TYPES`,
277
+ `CALL_STATUSES`, `APPLIED`, `CHANGE_RESULT_STATUSES`, `VERIFY_VERDICTS`,
278
+ `SCHEMA_VERSION`, `DEPLOY_RESPONSE_PAYLOAD_MAX_BYTES`). Kept on the model
279
+ (not the barrel) to avoid collisions on these generic names.
280
+ - **Not yet done** (tracked on SITES-47997): the `publishedBy` column +
281
+ `executedBy/At` → `deployedBy/At` rename (dual-write, needs a companion
282
+ mysticat-data-service migration), and gating the `DEPLOYED`/`PUBLISHED`
283
+ transition on `result` presence (extends the SITES-47091 guard).
284
+
285
+ [adobe/mysticat-architecture#200]: https://github.com/adobe/mysticat-architecture/blob/main/platform/decisions/deploy-action-provenance-and-verify.md
286
+
250
287
  ## Site Config: Import Types
251
288
 
252
289
  Site configuration lives in `src/models/site/config.js` and defines the available import types, their validation schemas, and default configs. This is one of the most frequently changed files in the package.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-data-access",
3
- "version": "4.11.0",
3
+ "version": "4.12.0",
4
4
  "description": "Shared modules of the Spacecat Services - Data Access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,262 @@
1
+ /*
2
+ * Copyright 2025 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
+ import Joi from 'joi';
14
+ import { isNonEmptyObject } from '@adobe/spacecat-shared-utils';
15
+
16
+ /**
17
+ * Canonical v2 shape for `FixEntity.changeDetails` — the structured, validated
18
+ * "deploy action" record defined by ADR
19
+ * adobe/mysticat-architecture#200 (platform/decisions/deploy-action-provenance-and-verify.md,
20
+ * SITES-47997): *who* deployed/published, *when*, *what entity/property* is
21
+ * proposed to change, *which pages* are affected, and *the result of the call*.
22
+ *
23
+ * The DB column stays `type: 'any'` (no destructive migration); this module is
24
+ * the machine-readable schema the JS write chokepoint validates against, so the
25
+ * freeform bag cannot silently drift across the two runtimes (autofix-worker JS
26
+ * + mystique Python). Records without `schemaVersion === 2` are legacy freeform
27
+ * (v1) and are intentionally NOT schema-validated — the migration is additive
28
+ * and reader-tolerant (ADR "V1 / V2 backwards compatibility").
29
+ */
30
+
31
+ export const CHANGE_DETAILS_SCHEMA_VERSION = 2;
32
+
33
+ // Deploy CHANNEL / "where" the fix went live.
34
+ export const SURFACES = {
35
+ ASO: 'ASO',
36
+ AUTHOR_PUBLISH: 'AUTHOR_PUBLISH',
37
+ GIT_MERGE: 'GIT_MERGE',
38
+ SYSTEM: 'SYSTEM',
39
+ };
40
+
41
+ // WHO acted. Orthogonal to `surface` — DETECTOR is an actor (an audit that
42
+ // observed a live fix), not a deploy channel, so it is an actorType not a surface.
43
+ export const ACTOR_TYPES = {
44
+ IMS_USER: 'IMS_USER',
45
+ SERVICE: 'SERVICE',
46
+ GITHUB: 'GITHUB',
47
+ DETECTOR: 'DETECTOR',
48
+ UNKNOWN: 'UNKNOWN',
49
+ };
50
+
51
+ // Classified outcome of the deploy/apply call — transport-agnostic (JCR writes
52
+ // and git ops classify the same way HTTP does). Set independently by the
53
+ // transport adapter; NOT derived from `changeResults`. `no_op` is the case
54
+ // SITES-47077 / SITES-47078 silently mis-read as success.
55
+ export const CALL_STATUSES = {
56
+ SUCCESS: 'success',
57
+ NO_OP: 'no_op',
58
+ CLIENT_ERROR: 'client_error',
59
+ SERVER_ERROR: 'server_error',
60
+ TIMEOUT: 'timeout',
61
+ };
62
+
63
+ // Whole-action roll-up of the per-change `changeResults[].status`. NOT a boolean
64
+ // — a deploy action may write >1 property, so "2-of-3" is first-class.
65
+ export const APPLIED = {
66
+ ALL: 'ALL',
67
+ PARTIAL: 'PARTIAL',
68
+ NONE: 'NONE',
69
+ };
70
+
71
+ // Per-change outcome, key-matched to `target.changes` by (targetPath, property).
72
+ export const CHANGE_RESULT_STATUSES = {
73
+ APPLIED: 'applied',
74
+ UNCHANGED: 'unchanged',
75
+ FAILED: 'failed',
76
+ };
77
+
78
+ // Verify verdict. Distinguishes NEGATIVE (`rejected` — verify ran, the change did
79
+ // not render as expected → revert-eligible) from ERRORED (`verify_failed` —
80
+ // verify could not complete → MUST NOT auto-revert). Mirrors the post-verify POC.
81
+ export const VERIFY_VERDICTS = {
82
+ VERIFIED: 'verified',
83
+ REJECTED: 'rejected',
84
+ INCONCLUSIVE: 'inconclusive',
85
+ VERIFY_FAILED: 'verify_failed',
86
+ };
87
+
88
+ // `deployResponsePayload` size cap (bytes). MUST hold on a shared DB column — one
89
+ // runaway payload degrades read latency for every consumer. Writers hash-first,
90
+ // then cap/redact, so `deployResponseSha256` always covers the pre-redact payload.
91
+ export const DEPLOY_RESPONSE_PAYLOAD_MAX_BYTES = 4096;
92
+
93
+ const enumValues = (obj) => Object.values(obj);
94
+
95
+ const byteLength = (value) => Buffer.byteLength(
96
+ typeof value === 'string' ? value : JSON.stringify(value ?? ''),
97
+ 'utf8',
98
+ );
99
+
100
+ const verdictBase = {
101
+ verdict: Joi.string().valid(...enumValues(VERIFY_VERDICTS)).required(),
102
+ reasonCode: Joi.string(),
103
+ evidence: Joi.any(),
104
+ };
105
+
106
+ const preVerifySchema = Joi.object({
107
+ ...verdictBase,
108
+ preVerifiedAt: Joi.string().isoDate(),
109
+ });
110
+
111
+ const postVerifySchema = Joi.object({
112
+ ...verdictBase,
113
+ revert: Joi.any(),
114
+ postVerifiedAt: Joi.string().isoDate(),
115
+ });
116
+
117
+ // The PROPOSAL — intent only (`intendedValue`). Baseline (`previousValue`) and
118
+ // observed outcome (`appliedValue`, `status`) live per-change in `changeResults`.
119
+ const targetChangeSchema = Joi.object({
120
+ targetPath: Joi.string().required(),
121
+ property: Joi.string().required(),
122
+ intendedValue: Joi.any().required(),
123
+ });
124
+
125
+ const changeResultSchema = Joi.object({
126
+ targetPath: Joi.string().required(),
127
+ property: Joi.string().required(),
128
+ previousValue: Joi.any(),
129
+ appliedValue: Joi.any(),
130
+ status: Joi.string().valid(...enumValues(CHANGE_RESULT_STATUSES)).required(),
131
+ });
132
+
133
+ const targetSchema = Joi.object({
134
+ system: Joi.string(),
135
+ changeType: Joi.string().required(),
136
+ siteId: Joi.string(),
137
+ pageId: Joi.string(),
138
+ documentPath: Joi.string(),
139
+ detectedPageAuthorUrl: Joi.string(),
140
+ changes: Joi.array().items(targetChangeSchema).min(1).required(),
141
+ });
142
+
143
+ const resultSchema = Joi.object({
144
+ callStatus: Joi.string().valid(...enumValues(CALL_STATUSES)).required(),
145
+ applied: Joi.string().valid(...enumValues(APPLIED)).required(),
146
+ deployResponsePayload: Joi.any().custom((value, helpers) => {
147
+ let size;
148
+ try {
149
+ size = byteLength(value);
150
+ } catch {
151
+ // Un-serializable payload (e.g. a circular reference) — it must be
152
+ // serialized/truncated into deployResponseSha256 at the write chokepoint
153
+ // before persist, so reject it here rather than throw a raw TypeError.
154
+ return helpers.error('any.invalid');
155
+ }
156
+ if (size > DEPLOY_RESPONSE_PAYLOAD_MAX_BYTES) {
157
+ return helpers.error('any.invalid');
158
+ }
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),
164
+ changeResults: Joi.array().items(changeResultSchema),
165
+ preVerify: preVerifySchema,
166
+ postVerify: postVerifySchema,
167
+ });
168
+
169
+ // Composite match key for (targetPath, property). JSON.stringify gives an
170
+ // unambiguous, collision-safe delimiter regardless of what the strings contain.
171
+ const changeKey = (change) => JSON.stringify([change.targetPath, change.property]);
172
+
173
+ /**
174
+ * Canonical Joi schema for a v2 `changeDetails` record. Strict (unknown keys are
175
+ * rejected) so v2 writers cannot re-introduce freeform drift.
176
+ */
177
+ export const changeDetailsV2Schema = Joi.object({
178
+ // `.strict()` — no type coercion: writers MUST pass the integer 2, never the
179
+ // string '2'. A coerced-then-persisted '2' would read as legacy (!= 2) in
180
+ // mystique's Python runtime, the exact cross-runtime divergence this
181
+ // discriminator exists to prevent.
182
+ schemaVersion: Joi.number().valid(CHANGE_DETAILS_SCHEMA_VERSION).strict().required(),
183
+ surface: Joi.string().valid(...enumValues(SURFACES)).required(),
184
+ actorType: Joi.string().valid(...enumValues(ACTOR_TYPES)).required(),
185
+ target: targetSchema.required(),
186
+ result: resultSchema,
187
+ }).custom((value, helpers) => {
188
+ // Blocking constraint: `result.changeResults` and `target.changes` MUST
189
+ // key-match by (targetPath, property) — not positional — so a writer that
190
+ // skips or reorders an entry cannot silently misalign the proposal (intent)
191
+ // against the observed outcome. When either side is absent the required-field
192
+ // rules already reject the record, so we only cross-check once both are arrays.
193
+ const changeResults = value.result?.changeResults;
194
+ const changes = value.target?.changes;
195
+ const appliedAll = value.result?.applied === APPLIED.ALL;
196
+ // (a) `applied: ALL` claims every proposed change landed, so it MUST carry the
197
+ // per-change evidence — an ALL with no changeResults is an unfalsifiable claim.
198
+ if (appliedAll && !Array.isArray(changeResults)) {
199
+ return helpers.message('result.applied is ALL but result.changeResults is missing');
200
+ }
201
+ if (Array.isArray(changeResults) && Array.isArray(changes)) {
202
+ const intentKeys = new Set(changes.map(changeKey));
203
+ // (b) no orphan outcome — every changeResult maps to a proposed change.
204
+ if (changeResults.some((r) => !intentKeys.has(changeKey(r)))) {
205
+ return helpers.message('result.changeResults contains an entry with no matching target.changes (targetPath, property)');
206
+ }
207
+ // (c) completeness for a whole-action success — every proposed change MUST
208
+ // have a recorded outcome. PARTIAL/NONE legitimately omit entries.
209
+ if (appliedAll) {
210
+ const resultKeys = new Set(changeResults.map(changeKey));
211
+ if (changes.some((c) => !resultKeys.has(changeKey(c)))) {
212
+ return helpers.message('result.applied is ALL but a target.changes entry has no matching result.changeResults');
213
+ }
214
+ }
215
+ }
216
+ return value;
217
+ });
218
+
219
+ /**
220
+ * Validator for the `FixEntity.changeDetails` attribute. Reader-tolerant:
221
+ * legacy freeform records (schemaVersion absent or !== 2) keep the pre-existing
222
+ * non-empty-object guard; v2 records are validated against {@link changeDetailsV2Schema}.
223
+ *
224
+ * @param {*} value - the changeDetails value being persisted.
225
+ * @returns {boolean} true when valid (schema builders treat `false` as a failure).
226
+ * @throws {Error} with a descriptive message when a v2 record is invalid.
227
+ */
228
+ export function validateChangeDetails(value) {
229
+ if (!isNonEmptyObject(value)) {
230
+ return false;
231
+ }
232
+ // Only an ABSENT schemaVersion (or an explicit v1) is legacy freeform, per the
233
+ // ADR ("absent/1 ⇒ legacy freeform"). Anything else is claiming to be
234
+ // structured, so it MUST run through the v2 schema — a stray `schemaVersion:
235
+ // '2'` (int-vs-string drift) or a future version must never silently pass.
236
+ const { schemaVersion } = value;
237
+ if (schemaVersion === undefined || schemaVersion === null
238
+ || schemaVersion === 1 || schemaVersion === '1') {
239
+ return true;
240
+ }
241
+ const { error } = changeDetailsV2Schema.validate(value, { abortEarly: false });
242
+ if (error) {
243
+ throw new Error(`changeDetails (v2) invalid: ${error.message}`);
244
+ }
245
+ return true;
246
+ }
247
+
248
+ /**
249
+ * Bundle of the v2 changeDetails enums + limits, attached to the FixEntity model
250
+ * as `FixEntity.CHANGE_DETAILS` (mirrors `FixEntity.STATUSES`). Kept off the
251
+ * package root barrel to avoid collisions on these generic names.
252
+ */
253
+ export const CHANGE_DETAILS = {
254
+ SCHEMA_VERSION: CHANGE_DETAILS_SCHEMA_VERSION,
255
+ SURFACES,
256
+ ACTOR_TYPES,
257
+ CALL_STATUSES,
258
+ APPLIED,
259
+ CHANGE_RESULT_STATUSES,
260
+ VERIFY_VERDICTS,
261
+ DEPLOY_RESPONSE_PAYLOAD_MAX_BYTES,
262
+ };
@@ -12,6 +12,7 @@
12
12
  import BaseModel from '../base/base.model.js';
13
13
  import { guardTransition } from '../../util/status-transition-guard.js';
14
14
  import { isAllowedFixTransition } from './fix-entity.transitions.js';
15
+ import { CHANGE_DETAILS } from './change-details.schema.js';
15
16
 
16
17
  /**
17
18
  * FixEntity - A class representing a FixEntity for a Suggestion.
@@ -41,6 +42,10 @@ class FixEntity extends BaseModel {
41
42
  REPORTING: 'reporting',
42
43
  };
43
44
 
45
+ // Canonical v2 changeDetails enums + limits (SITES-47997, ADR
46
+ // adobe/mysticat-architecture#200). e.g. FixEntity.CHANGE_DETAILS.SURFACES.ASO.
47
+ static CHANGE_DETAILS = CHANGE_DETAILS;
48
+
44
49
  /**
45
50
  * Sets the fix status, guarding the transition against the canonical table
46
51
  * (fix-entity.transitions.js). Overrides the auto-generated setter so EVERY
@@ -10,12 +10,13 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import { isIsoDate, isNonEmptyObject } from '@adobe/spacecat-shared-utils';
13
+ import { isIsoDate } from '@adobe/spacecat-shared-utils';
14
14
 
15
15
  import SchemaBuilder from '../base/schema.builder.js';
16
16
  import FixEntity from './fix-entity.model.js';
17
17
  import FixEntityCollection from './fix-entity.collection.js';
18
18
  import Suggestion from '../suggestion/suggestion.model.js';
19
+ import { validateChangeDetails } from './change-details.schema.js';
19
20
 
20
21
  const schema = new SchemaBuilder(FixEntity, FixEntityCollection)
21
22
  .addReference('has_many', 'FixEntitySuggestion', ['updatedAt'], { removeDependents: true })
@@ -39,7 +40,10 @@ const schema = new SchemaBuilder(FixEntity, FixEntityCollection)
39
40
  .addAttribute('changeDetails', {
40
41
  type: 'any',
41
42
  required: true,
42
- validate: (value) => isNonEmptyObject(value),
43
+ // Reader-tolerant: legacy freeform (v1) records keep the non-empty guard;
44
+ // schemaVersion:2 records are validated against the canonical shape
45
+ // (SITES-47997, ADR adobe/mysticat-architecture#200).
46
+ validate: (value) => validateChangeDetails(value),
43
47
  })
44
48
  .addAttribute('status', {
45
49
  type: Object.values(FixEntity.STATUSES),
@@ -10,6 +10,7 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import type { ObjectSchema } from 'joi';
13
14
  import type {
14
15
  BaseCollection, BaseModel, Opportunity, Suggestion, FixEntitySuggestion,
15
16
  } from '../index';
@@ -52,3 +53,9 @@ export declare function isAllowedFixTransition(
52
53
  from: string | null | undefined,
53
54
  to: string,
54
55
  ): boolean;
56
+
57
+ // Canonical v2 changeDetails shape + validator (SITES-47997, ADR
58
+ // adobe/mysticat-architecture#200).
59
+ export declare const CHANGE_DETAILS_SCHEMA_VERSION: number;
60
+ export declare const changeDetailsV2Schema: ObjectSchema;
61
+ export declare function validateChangeDetails(value: unknown): boolean;
@@ -24,3 +24,12 @@ export {
24
24
  FIX_ENTITY_CREATE,
25
25
  isAllowedFixTransition,
26
26
  } from './fix-entity.transitions.js';
27
+
28
+ // Canonical v2 changeDetails shape + validator (SITES-47997, ADR
29
+ // adobe/mysticat-architecture#200). The generic enum names live on
30
+ // FixEntity.CHANGE_DETAILS to keep them off the package-root barrel.
31
+ export {
32
+ CHANGE_DETAILS_SCHEMA_VERSION,
33
+ changeDetailsV2Schema,
34
+ validateChangeDetails,
35
+ } from './change-details.schema.js';