@adobe/spacecat-shared-data-access 4.3.1 → 4.5.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.5.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.4.0...@adobe/spacecat-shared-data-access-v4.5.0) (2026-07-10)
2
+
3
+ ### Features
4
+
5
+ * **data-access:** generalize deriveSuggestionStatus bubble-up + classifyStatus (SITES-47285) ([#1797](https://github.com/adobe/spacecat-shared/issues/1797)) ([732f647](https://github.com/adobe/spacecat-shared/commit/732f647724d1922f70d32b42fedd46824b0c1bde))
6
+
7
+ ## [@adobe/spacecat-shared-data-access-v4.4.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.3.1...@adobe/spacecat-shared-data-access-v4.4.0) (2026-07-09)
8
+
9
+ ### Features
10
+
11
+ * **fix-entity:** add allByOpportunityIds for site-wide fix queries ([#1792](https://github.com/adobe/spacecat-shared/issues/1792)) ([25309bf](https://github.com/adobe/spacecat-shared/commit/25309bff4be7d90c0f7cdbf854c48a20adfd3e57))
12
+
1
13
  ## [@adobe/spacecat-shared-data-access-v4.3.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.3.0...@adobe/spacecat-shared-data-access-v4.3.1) (2026-07-09)
2
14
 
3
15
  ### Bug Fixes
package/CLAUDE.md CHANGED
@@ -237,7 +237,8 @@ Rollout intent: ship `warn` to surface today's illegal transitions in logs for
237
237
  **APIs** (exported from the package root):
238
238
  - `setStatus(value)` / `transitionStatus(to)` on `Suggestion` and `FixEntity` — both guard the transition; `transitionStatus` is the intention-revealing alias for new code.
239
239
  - `isAllowedFixTransition(from, to)` / `isAllowedSuggestionTransition(from, to)` + the `FIX_ENTITY_TRANSITIONS` / `SUGGESTION_TRANSITIONS` tables (a null/undefined `from` means entity creation).
240
- - `deriveSuggestionStatus(fixes, issues = [])` — derives a Suggestion status from its fix entities. **Implements the non-CWV 1:1 bubble-up only** (`DEPLOYED`/`PUBLISHED`→`FIXED`, `FAILED`→`ERROR`, `PENDING`→`IN_PROGRESS`, `ROLLED_BACK`→`SKIPPED`; severity-collapses for >1 fix). **Throws** if a non-empty `issues` array is passed — the CWV multi-issue bubble-up is deferred (SITES-47285), because the per-issue codefix vocabulary (`PATCH_*`/`GUIDANCE_GENERATED`, written by mystique into the overloaded `issue.status` field) is not yet reconciled with the JS `ISSUE_STATUSES` enum.
240
+ - `deriveSuggestionStatus(outcomes, issues = [], currentStatus = null)` — derives a Suggestion status from a per-suggestion list of **outcome signals**. Each entry is a FixEntity, `{status}`, or a status string from **either** vocabulary: FixEntity statuses (`DEPLOYED`/`PUBLISHED`→FIXED, `FAILED`→ERROR, `PENDING`→IN_PROGRESS, `ROLLED_BACK`/`REJECTED`→SKIPPED) **or** explicit Suggestion-status outcomes a handler asserts for no-fix cases (e.g. consciously skipped → `'SKIPPED'`, not-actionable → `'NEW'`). Signals are classified via `classifyStatus` and collapsed **first-match-wins by severity: ERROR > IN_PROGRESS > FIXED > SKIPPED**; all-NEUTRAL (e.g. only `NEW`) → `NEW`; no recognized signals → `currentStatus` (default null, so a derive call never clobbers a status set elsewhere). **Throws** if a non-empty `issues` array is passed — the CWV multi-issue bubble-up is deferred (SITES-47285): the per-issue codefix vocabulary (`PATCH_*`/`GUIDANCE_GENERATED`, written by mystique into the overloaded `issue.status` field) is not yet reconciled with the JS layer.
241
+ - `classifyStatus(token)` — maps a fix- or suggestion-status token to its severity class (`ERROR`/`IN_PROGRESS`/`FIXED`/`SKIPPED`/`NEUTRAL`) or `null`. The shared building block behind the bubble-up; consumers building custom collapses should reuse it rather than re-encode the mapping.
241
242
 
242
243
  Notes: the `FixEntity` table is the canonical one from the ADR; the `Suggestion`
243
244
  table is intentionally **permissive in V1** (tune it from the warn-log findings
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-data-access",
3
- "version": "4.3.1",
3
+ "version": "4.5.0",
4
4
  "description": "Shared modules of the Spacecat Services - Data Access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -15,6 +15,11 @@ import ValidationError from '../../errors/validation.error.js';
15
15
  import { guardId, guardArray, guardString } from '../../util/guards.js';
16
16
  import { resolveUpdates } from '../../util/util.js';
17
17
 
18
+ // PostgREST GET requests serialize IN-filters into the URL, so large ID lists must be
19
+ // chunked to stay well under the ~8KB URL length limit (see batchGetByKeys / llmo-brand-presence
20
+ // for the same pattern elsewhere in this package).
21
+ const IN_FILTER_CHUNK_SIZE = 50;
22
+
18
23
  /**
19
24
  * FixEntityCollection - A collection class responsible for managing FixEntities.
20
25
  * Extends the BaseCollection to provide specific methods for interacting with
@@ -226,6 +231,49 @@ class FixEntityCollection extends BaseCollection {
226
231
  }
227
232
  }
228
233
 
234
+ /**
235
+ * Gets all fixes across a set of opportunities (e.g. every opportunity for a site),
236
+ * by filtering on opportunityId IN (...). Chunks the IN-filter to stay under
237
+ * PostgREST's URL length limit.
238
+ *
239
+ * @async
240
+ * @param {string[]} opportunityIds - The IDs of the opportunities.
241
+ * @returns {Promise<FixEntity[]>} - A promise that resolves to an array of FixEntity models.
242
+ * @throws {DataAccessError} - Throws an error if the query fails.
243
+ * @throws {ValidationError} - Throws an error if opportunityIds is not an array of strings.
244
+ */
245
+ async allByOpportunityIds(opportunityIds) {
246
+ guardArray('opportunityIds', opportunityIds, 'FixEntityCollection', 'string');
247
+
248
+ if (opportunityIds.length === 0) {
249
+ return [];
250
+ }
251
+
252
+ try {
253
+ const uniqueIds = [...new Set(opportunityIds)];
254
+
255
+ const chunks = [];
256
+ for (let i = 0; i < uniqueIds.length; i += IN_FILTER_CHUNK_SIZE) {
257
+ chunks.push(uniqueIds.slice(i, i + IN_FILTER_CHUNK_SIZE));
258
+ }
259
+
260
+ const results = await Promise.all(
261
+ chunks.map((chunk) => this.all(
262
+ {},
263
+ { where: (attrs, op) => op.in(attrs.opportunityId, chunk) },
264
+ )),
265
+ );
266
+
267
+ return results.flat();
268
+ } catch (error) {
269
+ if (error instanceof DataAccessError) {
270
+ throw error;
271
+ }
272
+ this.log.error('Failed to get all fixes by opportunity IDs', error);
273
+ throw new DataAccessError('Failed to get all fixes by opportunity IDs', this, error);
274
+ }
275
+ }
276
+
229
277
  async #buildFixesWithSuggestions(fixEntitySuggestions) {
230
278
  if (fixEntitySuggestions.length === 0) {
231
279
  return [];
@@ -11,10 +11,10 @@
11
11
  */
12
12
 
13
13
  /**
14
- * Suggestion + FixEntity status literals.
14
+ * Suggestion status literals (return values).
15
15
  *
16
- * Duplicated from the model enums to keep this a dependency-light pure module.
17
- * Keep in sync if either enum changes. (Same pattern as suggestion.data-schemas.js.)
16
+ * Duplicated from the model enum to keep this a dependency-light pure module.
17
+ * Keep in sync if the enum changes. (Same pattern as suggestion.data-schemas.js.)
18
18
  */
19
19
  const SUGGESTION = {
20
20
  NEW: 'NEW',
@@ -24,89 +24,135 @@ const SUGGESTION = {
24
24
  ERROR: 'ERROR',
25
25
  };
26
26
 
27
- const FIX = {
28
- PENDING: 'PENDING',
29
- DEPLOYED: 'DEPLOYED',
30
- PUBLISHED: 'PUBLISHED',
31
- FAILED: 'FAILED',
32
- ROLLED_BACK: 'ROLLED_BACK',
27
+ /**
28
+ * Severity classes for the bubble-up (ADR adobe/mysticat-architecture#174).
29
+ * Highest severity first-match-wins; NEUTRAL never contributes to severity
30
+ * (a suggestion whose only signals are NEUTRAL resolves to NEW).
31
+ */
32
+ const CLASS = {
33
+ ERROR: 'ERROR',
34
+ IN_PROGRESS: 'IN_PROGRESS',
35
+ FIXED: 'FIXED',
36
+ SKIPPED: 'SKIPPED',
37
+ NEUTRAL: 'NEUTRAL',
38
+ };
39
+
40
+ /**
41
+ * Maps a status token to its severity class. Covers BOTH vocabularies so a
42
+ * caller can pass FixEntity statuses (DEPLOYED/FAILED/PENDING/ROLLED_BACK/...)
43
+ * and/or explicit Suggestion-status outcomes (FIXED/ERROR/SKIPPED/NEW/...) for
44
+ * cases with no fix entity (e.g. a consciously-skipped or not-actionable
45
+ * suggestion). `REJECTED` collapses to SKIPPED in either vocabulary. Unknown
46
+ * tokens are ignored (null).
47
+ */
48
+ const CLASS_BY_STATUS = {
49
+ // ERROR-class
50
+ FAILED: CLASS.ERROR, // fix
51
+ ERROR: CLASS.ERROR, // suggestion
52
+ // IN_PROGRESS-class
53
+ PENDING: CLASS.IN_PROGRESS, // fix
54
+ IN_PROGRESS: CLASS.IN_PROGRESS, // suggestion
55
+ // FIXED-class
56
+ DEPLOYED: CLASS.FIXED, // fix
57
+ PUBLISHED: CLASS.FIXED, // fix
58
+ FIXED: CLASS.FIXED, // suggestion
59
+ // SKIPPED-class
60
+ ROLLED_BACK: CLASS.SKIPPED, // fix
61
+ REJECTED: CLASS.SKIPPED, // fix / suggestion
62
+ SKIPPED: CLASS.SKIPPED, // suggestion
63
+ // NEUTRAL — present but non-severity; all-NEUTRAL resolves to NEW
64
+ NEW: CLASS.NEUTRAL,
65
+ OUTDATED: CLASS.NEUTRAL,
66
+ APPROVED: CLASS.NEUTRAL,
67
+ PENDING_VALIDATION: CLASS.NEUTRAL,
33
68
  };
34
69
 
35
70
  /**
36
- * Normalizes a fix entry to its status string. Accepts a FixEntity model
71
+ * Classifies a status token into its bubble-up severity class, or null if the
72
+ * token is unknown.
73
+ *
74
+ * @param {string|null|undefined} token
75
+ * @returns {'ERROR'|'IN_PROGRESS'|'FIXED'|'SKIPPED'|'NEUTRAL'|null}
76
+ */
77
+ export const classifyStatus = (token) => (
78
+ Object.prototype.hasOwnProperty.call(CLASS_BY_STATUS, token)
79
+ ? CLASS_BY_STATUS[token]
80
+ : null
81
+ );
82
+
83
+ /**
84
+ * Normalizes an outcome entry to its status string. Accepts a FixEntity model
37
85
  * instance (`getStatus()`), a plain `{ status }` object, or a status string.
38
86
  */
39
- const toStatus = (fix) => {
40
- if (typeof fix === 'string') {
41
- return fix;
87
+ const toStatus = (outcome) => {
88
+ if (typeof outcome === 'string') {
89
+ return outcome;
42
90
  }
43
- if (fix && typeof fix.getStatus === 'function') {
44
- return fix.getStatus();
91
+ if (outcome && typeof outcome.getStatus === 'function') {
92
+ return outcome.getStatus();
45
93
  }
46
- return fix?.status;
94
+ return outcome?.status;
47
95
  };
48
96
 
49
97
  /**
50
- * Derives a Suggestion's status from its fix entities the **non-CWV (1:1)**
51
- * bubble-up from ADR adobe/mysticat-architecture#174 §"Bubble-up rule (non-CWV
52
- * opportunities)":
98
+ * Derives a Suggestion's status from a per-suggestion list of outcome signals
99
+ * the bubble-up from ADR adobe/mysticat-architecture#174.
53
100
  *
54
- * FAILED -> ERROR
55
- * PENDING -> IN_PROGRESS
56
- * DEPLOYED -> FIXED
57
- * PUBLISHED -> FIXED
58
- * ROLLED_BACK -> SKIPPED
101
+ * Each `outcomes` entry is a FixEntity, a `{ status }` object, or a status
102
+ * string, from EITHER vocabulary:
103
+ * - FixEntity statuses: DEPLOYED/PUBLISHED -> FIXED, FAILED -> ERROR,
104
+ * PENDING -> IN_PROGRESS, ROLLED_BACK/REJECTED -> SKIPPED.
105
+ * - explicit Suggestion-status outcomes for no-fix cases a handler asserts
106
+ * (e.g. a consciously skipped suggestion -> 'SKIPPED', a not-actionable one
107
+ * -> 'NEW').
59
108
  *
60
- * For a single fix this is exactly the ADR map. For multiple fixes it collapses
61
- * by severity (ERROR > IN_PROGRESS > FIXED > SKIPPED) so partial failure wins
62
- * consistent with the CWV severity ordering. `Fix.REJECTED` is intentionally
63
- * absent: it is not in `FixEntity.STATUSES` (see SITES-47076).
109
+ * Signals are classified (`classifyStatus`) and collapsed **first-match-wins by
110
+ * severity**: ERROR > IN_PROGRESS > FIXED > SKIPPED. If signals are present but
111
+ * all NEUTRAL (e.g. only 'NEW') the result is NEW. If there are no recognized
112
+ * signals (empty / all-null / all-unknown) the result is `currentStatus`
113
+ * (default null), so a derive call never clobbers a status set elsewhere.
64
114
  *
65
115
  * The **CWV multi-issue** bubble-up is deliberately NOT implemented here: the
66
- * ADR's per-issue vocabulary (`cwvIssueStatus` = PATCH_GENERATED / GUIDANCE_*
67
- * / PATCH_FAILED_*) does not match the data layer (mystique writes those into
68
- * the overloaded per-issue `status` field; the JS `ISSUE_STATUSES` enum lists
69
- * only Suggestion statuses). Reconciling that is a follow-up sub-task,
70
- * SITES-47285. Passing a non-empty `issues` argument throws to prevent a caller
71
- * from silently receiving an unimplemented CWV result.
116
+ * per-issue vocabulary (mystique's cwvIssueStatus = PATCH_GENERATED /
117
+ * GUIDANCE_* / PATCH_FAILED_*) is not yet reconciled with the JS layer
118
+ * (SITES-47285). Passing a non-empty `issues` argument throws to prevent a
119
+ * caller from silently receiving an unimplemented CWV result.
72
120
  *
73
- * @param {Array<object|string>} fixes - fix entities / `{status}` / status strings
121
+ * @param {Array<object|string>} outcomes - fix entities / `{status}` / status strings
74
122
  * @param {Array<object>} [issues] - CWV per-issue data; MUST be empty for now
75
- * @param {string|null} [currentStatus] - the Suggestion's current status, returned as the
76
- * fallback when nothing is derivable (no fixes / all-null / no matching rule) so a derive
77
- * call never clobbers a status set elsewhere. Defaults to null (prior behavior).
78
- * @returns {string|null} derived Suggestion status, or `currentStatus` when nothing is derivable
123
+ * @param {string|null} [currentStatus] - fallback when nothing is derivable
124
+ * @returns {string|null} derived Suggestion status, or `currentStatus`
79
125
  * @throws {Error} if a non-empty `issues` array is supplied (CWV deferred)
80
126
  */
81
- export const deriveSuggestionStatus = (fixes, issues = [], currentStatus = null) => {
127
+ export const deriveSuggestionStatus = (outcomes, issues = [], currentStatus = null) => {
82
128
  if (Array.isArray(issues) && issues.length > 0) {
83
129
  throw new Error('deriveSuggestionStatus: CWV multi-issue bubble-up is not yet implemented (deferred to SITES-47285)');
84
130
  }
85
131
 
86
- if (!Array.isArray(fixes) || fixes.length === 0) {
132
+ if (!Array.isArray(outcomes)) {
87
133
  return currentStatus;
88
134
  }
89
135
 
90
- // Drop null/undefined entries (e.g. a sparse junction result) so one malformed
91
- // entry cannot nullify an otherwise well-defined derivation; degrades to the
92
- // same "no fixes" behavior as an empty array.
93
- const statuses = fixes.map(toStatus).filter((s) => s != null);
94
- if (statuses.length === 0) {
136
+ // Classify each signal; drop unknown/null so one malformed entry cannot
137
+ // nullify an otherwise well-defined derivation.
138
+ const classes = outcomes.map(toStatus).map(classifyStatus).filter((c) => c != null);
139
+ if (classes.length === 0) {
95
140
  return currentStatus;
96
141
  }
97
142
 
98
- if (statuses.includes(FIX.FAILED)) {
143
+ if (classes.includes(CLASS.ERROR)) {
99
144
  return SUGGESTION.ERROR;
100
145
  }
101
- if (statuses.includes(FIX.PENDING)) {
146
+ if (classes.includes(CLASS.IN_PROGRESS)) {
102
147
  return SUGGESTION.IN_PROGRESS;
103
148
  }
104
- if (statuses.includes(FIX.DEPLOYED) || statuses.includes(FIX.PUBLISHED)) {
149
+ if (classes.includes(CLASS.FIXED)) {
105
150
  return SUGGESTION.FIXED;
106
151
  }
107
- if (statuses.every((s) => s === FIX.ROLLED_BACK)) {
152
+ if (classes.includes(CLASS.SKIPPED)) {
108
153
  return SUGGESTION.SKIPPED;
109
154
  }
110
155
 
111
- return currentStatus;
156
+ // signals present but all NEUTRAL (e.g. only NEW/OUTDATED)
157
+ return SUGGESTION.NEW;
112
158
  };
@@ -45,6 +45,10 @@ export declare function isAllowedSuggestionTransition(
45
45
  to: string,
46
46
  ): boolean;
47
47
  export declare function deriveSuggestionStatus(
48
- fixes: Array<FixEntity | { status?: string } | string>,
48
+ outcomes: Array<FixEntity | { status?: string } | string>,
49
49
  issues?: Array<object>,
50
+ currentStatus?: string | null,
50
51
  ): string | null;
52
+ export declare function classifyStatus(
53
+ token: string | null | undefined,
54
+ ): 'ERROR' | 'IN_PROGRESS' | 'FIXED' | 'SKIPPED' | 'NEUTRAL' | null;
@@ -36,4 +36,4 @@ export {
36
36
  SUGGESTION_CREATE,
37
37
  isAllowedSuggestionTransition,
38
38
  } from './suggestion.transitions.js';
39
- export { deriveSuggestionStatus } from './derive-status.js';
39
+ export { deriveSuggestionStatus, classifyStatus } from './derive-status.js';