@adobe/spacecat-shared-data-access 3.81.0 → 4.1.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,19 @@
1
+ ## [@adobe/spacecat-shared-data-access-v4.1.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v4.0.0...@adobe/spacecat-shared-data-access-v4.1.0) (2026-07-06)
2
+
3
+ ### Features
4
+
5
+ * **data-access:** mapping-table site_id/deletedAt + Brand workspace-id rename ([#1763](https://github.com/adobe/spacecat-shared/issues/1763)) ([c91faf0](https://github.com/adobe/spacecat-shared/commit/c91faf009b2643c5451a9912a627426ce97be0d1)), closes [#765](https://github.com/adobe/spacecat-shared/issues/765) [adobe/mysticat-data-service#765](https://github.com/adobe/mysticat-data-service/issues/765) [adobe/spacecat-api-service#2739](https://github.com/adobe/spacecat-api-service/issues/2739) [#765](https://github.com/adobe/spacecat-shared/issues/765) [mysticat-data-service#765](https://github.com/adobe/mysticat-data-service/issues/765)
6
+
7
+ ## [@adobe/spacecat-shared-data-access-v4.0.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v3.81.0...@adobe/spacecat-shared-data-access-v4.0.0) (2026-07-03)
8
+
9
+ ### ⚠ BREAKING CHANGES
10
+
11
+ * **data-access:** drop startedAt/result/error from Preflight schema (SITES-47254) (#1740)
12
+
13
+ ### Features
14
+
15
+ * **data-access:** drop startedAt/result/error from Preflight schema (SITES-47254) ([#1740](https://github.com/adobe/spacecat-shared/issues/1740)) ([5b728bb](https://github.com/adobe/spacecat-shared/commit/5b728bb623060289221e744088b9762a8acfe519)), closes [#2713](https://github.com/adobe/spacecat-shared/issues/2713) [#2713](https://github.com/adobe/spacecat-shared/issues/2713) [post-#2713](https://github.com/adobe/post-/issues/2713)
16
+
1
17
  ## [@adobe/spacecat-shared-data-access-v3.81.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-data-access-v3.80.0...@adobe/spacecat-shared-data-access-v3.81.0) (2026-07-02)
2
18
 
3
19
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-data-access",
3
- "version": "3.81.0",
3
+ "version": "4.1.0",
4
4
  "description": "Shared modules of the Spacecat Services - Data Access",
5
5
  "type": "module",
6
6
  "engines": {
@@ -12,6 +12,13 @@
12
12
 
13
13
  import type { BaseCollection, BaseModel } from '../base';
14
14
 
15
+ // Note on nullable return types: `postgrest.utils.js::normalizeModelValue()`
16
+ // maps DB NULL → JS `undefined` (the key is skipped on `this.record`
17
+ // entirely), so auto-generated getters return `T | undefined` — never
18
+ // `T | null` — for nullable columns. Callers checking `=== null` on these
19
+ // will silently miss the unfinished-job branch. Existing declarations
20
+ // that read `... | null` predate this PR; the lifecycle additions below
21
+ // match the runtime contract.
15
22
  export interface AsyncJob extends BaseModel {
16
23
  getStatus(): string;
17
24
  getResultLocation(): string;
@@ -20,6 +27,8 @@ export interface AsyncJob extends BaseModel {
20
27
  getError(): { code: string; message: string; details?: object } | null;
21
28
  getMetadata(): object | null;
22
29
  getRecordExpiressAt(): number;
30
+ getStartedAt(): string | undefined;
31
+ getEndedAt(): string | undefined;
23
32
  setStatus(status: string): void;
24
33
  setResultLocation(location: string): void;
25
34
  setResultType(type: string): void;
@@ -27,6 +36,8 @@ export interface AsyncJob extends BaseModel {
27
36
  setError(error: { code: string; message: string; details?: object }): void;
28
37
  setMetadata(metadata: object): void;
29
38
  setExpiresAt(expiresAt: number): void;
39
+ setStartedAt(startedAt: string): void;
40
+ setEndedAt(endedAt: string): void;
30
41
  }
31
42
 
32
43
  export interface AsyncJobCollection extends BaseCollection<AsyncJob> {
@@ -39,6 +39,23 @@ class Brand extends BaseModel {
39
39
  * `pending`; customer offboard writes `deleted`.
40
40
  */
41
41
  static STATUSES = Object.freeze(['pending', 'active', 'deleted', 'ignored']);
42
+
43
+ /**
44
+ * Deprecated BC-compat setter. `semrushWorkspaceId` is `readOnly: true` in
45
+ * the schema (mirrored by the mysticat-data-service sync trigger), so no
46
+ * setter is auto-generated for it — this manual method exists purely so an
47
+ * existing external caller of `setSemrushWorkspaceId` does not get a
48
+ * semver-breaking runtime error on upgrade. Delegates to the real
49
+ * write-of-record attribute. Remove once every direct caller has migrated
50
+ * to `setSemrushSubWorkspaceId` (see brand.schema.js).
51
+ *
52
+ * @deprecated Use setSemrushSubWorkspaceId instead.
53
+ * @param {string|null} value
54
+ * @returns {Brand}
55
+ */
56
+ setSemrushWorkspaceId(value) {
57
+ return this.setSemrushSubWorkspaceId(value);
58
+ }
42
59
  }
43
60
 
44
61
  export default Brand;
@@ -35,14 +35,31 @@ const schema = new SchemaBuilder(Brand, BrandCollection)
35
35
  type: Brand.STATUSES,
36
36
  validate: (value) => value == null || Brand.STATUSES.includes(value),
37
37
  })
38
+ // DEPRECATED (serenity-docs brand-semrush-mapping-maintenance.md §10
39
+ // rename, write-of-record cutover): read-only mirror of
40
+ // semrushSubWorkspaceId below, maintained entirely by the
41
+ // mysticat-data-service brands_sync_semrush_workspace_id trigger
42
+ // (migration 20260702094229). No schema-generated setter — app code must
43
+ // write semrushSubWorkspaceId instead. brand.model.js still defines a
44
+ // manual, deprecated setSemrushWorkspaceId() that delegates to
45
+ // setSemrushSubWorkspaceId(), so an existing external caller of the old
46
+ // setter is not broken (a bare readOnly flip here would be a semver-breaking
47
+ // removal for any @adobe/spacecat-shared-data-access consumer). Will be
48
+ // retired (attribute, column, and trigger) once every direct external
49
+ // reader has migrated.
50
+ .addAttribute('semrushWorkspaceId', {
51
+ type: 'string',
52
+ readOnly: true,
53
+ })
38
54
  // Brand → Semrush sub-workspace. Nullable (NULL = no sub-workspace
39
55
  // connected). Same minimum guard as organizations.semrushWorkspaceId: the
40
56
  // shared `hasText` rejects the empty string (and non-strings) while letting
41
57
  // null/undefined short-circuit. Note hasText does NOT trim, so a
42
58
  // whitespace-only value would pass — acceptable here because this column is
43
59
  // only ever written by the activate flow with a real Semrush workspace UUID,
44
- // never user input.
45
- .addAttribute('semrushWorkspaceId', {
60
+ // never user input. This is now the write-of-record (see semrushWorkspaceId
61
+ // above for the deprecated BC mirror).
62
+ .addAttribute('semrushSubWorkspaceId', {
46
63
  type: 'string',
47
64
  validate: (value) => value == null || hasText(value),
48
65
  })
@@ -55,9 +72,15 @@ const schema = new SchemaBuilder(Brand, BrandCollection)
55
72
  type: 'any',
56
73
  validate: (value) => value == null || (typeof value === 'object' && !Array.isArray(value)),
57
74
  })
58
- // Uniqueness is enforced at the DB level via the UNIQUE constraint on
59
- // brands.semrush_workspace_id (mysticat-data-service migration
75
+ // Uniqueness is enforced at the DB level via the UNIQUE constraint on the
76
+ // deprecated brands.semrush_workspace_id (mysticat-data-service migration
60
77
  // 20260615102123), so findBySemrushWorkspaceId returns at most one row.
61
- .addAllIndex(['semrushWorkspaceId']);
78
+ // Kept for BC lookups against the mirrored column; new code should prefer
79
+ // findBySemrushSubWorkspaceId below.
80
+ .addAllIndex(['semrushWorkspaceId'])
81
+ // Same uniqueness guarantee on the write-of-record column
82
+ // (brands.semrush_sub_workspace_id, mysticat-data-service migration
83
+ // 20260702091920).
84
+ .addAllIndex(['semrushSubWorkspaceId']);
62
85
 
63
86
  export default schema.build();
@@ -17,13 +17,24 @@ import type {
17
17
  export interface Brand extends BaseModel {
18
18
  getName(): string;
19
19
  getStatus(): string;
20
+ // Deprecated BC mirror (brands.semrush_workspace_id), maintained by the
21
+ // mysticat-data-service sync trigger. No schema-generated setter; the
22
+ // deprecated setSemrushWorkspaceId below is a manual delegate defined in
23
+ // brand.model.js, kept only for backward compatibility. Use
24
+ // getSemrushSubWorkspaceId/setSemrushSubWorkspaceId instead. See
25
+ // brand.schema.js.
20
26
  getSemrushWorkspaceId(): string | null;
27
+ getSemrushSubWorkspaceId(): string | null;
21
28
  setName(value: string): Brand;
22
29
  setStatus(value: string): Brand;
30
+ setSemrushSubWorkspaceId(value: string | null): Brand;
31
+ /** @deprecated Use setSemrushSubWorkspaceId instead. */
23
32
  setSemrushWorkspaceId(value: string | null): Brand;
24
33
  }
25
34
 
26
35
  export interface BrandCollection extends BaseCollection<Brand> {
27
36
  allBySemrushWorkspaceId(semrushWorkspaceId: string): Promise<Brand[]>;
28
37
  findBySemrushWorkspaceId(semrushWorkspaceId: string): Promise<Brand | null>;
38
+ allBySemrushSubWorkspaceId(semrushSubWorkspaceId: string): Promise<Brand[]>;
39
+ findBySemrushSubWorkspaceId(semrushSubWorkspaceId: string): Promise<Brand | null>;
29
40
  }
@@ -10,11 +10,31 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import { isValidUUID } from '@adobe/spacecat-shared-utils';
14
+
13
15
  import BaseCollection from '../base/base.collection.js';
16
+ import DataAccessError from '../../errors/data-access.error.js';
17
+ import { DEFAULT_PAGE_SIZE } from '../../util/postgrest.utils.js';
18
+
19
+ const BRAND_FK = 'brand_to_semrush_projects_brand_id_fkey';
20
+
21
+ // Safety cutoff for #fetchOrgRows' pagination loop. The per-FK base-collection
22
+ // queries this package generates elsewhere are naturally bounded (one brand's
23
+ // rows); this org-level fan-out is not, so an unexpectedly large org (or a
24
+ // runaway loop from an accessor bug) is capped rather than growing memory
25
+ // unbounded. Not expected to be hit in practice — see the truncation log.
26
+ const MAX_ORG_ROWS = 50_000;
14
27
 
15
28
  /**
16
29
  * BrandSemrushProjectCollection - collection of BrandSemrushProject rows.
17
30
  *
31
+ * Tombstone contract: only `allByOrganizationId` filters `deletedAt` by
32
+ * default. Every other accessor here (including the auto-generated
33
+ * `allByBrandId` / `findBySemrushProjectId`) returns tombstoned rows
34
+ * alongside live ones — there is no package-level soft-delete scope. Callers
35
+ * that need only live rows must filter explicitly, or use the sanctioned
36
+ * accessor. See serenity-docs brand-semrush-mapping-maintenance.md §7.2.
37
+ *
18
38
  * @class BrandSemrushProjectCollection
19
39
  * @extends BaseCollection
20
40
  */
@@ -26,6 +46,10 @@ class BrandSemrushProjectCollection extends BaseCollection {
26
46
  * null. Used by spacecat-api-service POST /v2/orgs/.../serenity/markets to
27
47
  * 409 on a duplicate slice before calling the upstream.
28
48
  *
49
+ * Returns tombstoned rows too (see class doc) — flat mode never tombstones,
50
+ * so this only matters for sub-workspace callers, which do not use this
51
+ * method today.
52
+ *
29
53
  * @param {string} brandId
30
54
  * @param {number} geoTargetId Google Ads Geo Target ID.
31
55
  * @param {string} languageCode BCP-47 primary subtag.
@@ -34,6 +58,122 @@ class BrandSemrushProjectCollection extends BaseCollection {
34
58
  async findBySlice(brandId, geoTargetId, languageCode) {
35
59
  return this.findByIndexKeys({ brandId, geoTargetId, languageCode });
36
60
  }
61
+
62
+ /**
63
+ * Returns identity rows (brand/project/slice/site, plus the embedded
64
+ * sub-workspace id) for every mapping row under the given organization, via
65
+ * a single PostgREST embedded-join query (INNER JOIN on brand_id FK) — one
66
+ * round trip, no per-brand fan-out. This is the sanctioned read path for
67
+ * cross-team consumers (spec §7.2): it deliberately returns plain frozen
68
+ * identity DTOs, not model instances, so a consumer cannot update/remove
69
+ * through it or reach beyond the projected columns.
70
+ *
71
+ * Tombstones are filtered by default (`deletedAt IS NULL`) — pass
72
+ * `{ includeDeleted: true }` to see them (e.g. for history/debugging).
73
+ *
74
+ * @param {string} organizationId - UUID of the organization.
75
+ * @param {object} [options]
76
+ * @param {boolean} [options.includeDeleted=false]
77
+ * @returns {Promise<Array<{
78
+ * brandId: string,
79
+ * semrushProjectId: string,
80
+ * geoTargetId: number,
81
+ * languageCode: string,
82
+ * siteId: string|null,
83
+ * organizationId: string|null,
84
+ * semrushSubWorkspaceId: string|null,
85
+ * }>>}
86
+ */
87
+ async allByOrganizationId(organizationId, { includeDeleted = false } = {}) {
88
+ if (!organizationId || !isValidUUID(organizationId)) {
89
+ throw new DataAccessError(
90
+ 'organizationId is required and must be a valid UUID',
91
+ { entityName: this.entityName, tableName: this.tableName },
92
+ );
93
+ }
94
+
95
+ // `!inner` is explicit rather than relying on the implicit INNER-JOIN-on-
96
+ // embedded-filter behavior added in PostgREST v11 — on an older server the
97
+ // same `.eq('brands.organization_id', ...)` filter alone is a LEFT JOIN,
98
+ // returning every mapping row with `brands: null` for non-matching
99
+ // organizations instead of excluding them.
100
+ // eslint-disable-next-line max-len
101
+ const select = `brand_id, semrush_project_id, semrush_location_id, language, site_id, brands!${BRAND_FK}!inner(organization_id, semrush_sub_workspace_id)`;
102
+ let query = this.postgrestService.from(this.tableName).select(select)
103
+ .eq('brands.organization_id', organizationId);
104
+ if (!includeDeleted) {
105
+ query = query.is('deleted_at', null);
106
+ }
107
+
108
+ return this.#fetchOrgRows(query);
109
+ }
110
+
111
+ /**
112
+ * Offset-paginates `query` and returns plain frozen identity DTOs.
113
+ *
114
+ * This deliberately rolls its own offset loop instead of reusing the
115
+ * cursor-based helper in `postgrest.utils.js`: that helper hydrates model
116
+ * instances through the base collection, whereas this accessor's contract
117
+ * (spec §7.2) is to return frozen, read-only identity DTOs from an
118
+ * embedded-join projection. Offset pagination over a fully-ordered query is
119
+ * sufficient here and keeps the projected columns / freeze in one place.
120
+ *
121
+ * @param {object} query - PostgREST query builder
122
+ * @returns {Promise<Array<object>>}
123
+ * @private
124
+ */
125
+ async #fetchOrgRows(query) {
126
+ const allResults = [];
127
+ let offset = 0;
128
+ let keepGoing = true;
129
+ // Secondary sort on semrush_project_id: brand_id alone does not give a
130
+ // stable page boundary when one brand has many projects (rows within a
131
+ // brand could otherwise straddle a page split in a different order on
132
+ // each call). semrush_project_id is unique per row, so this fully
133
+ // determines row order.
134
+ const orderedQuery = query.order('brand_id').order('semrush_project_id');
135
+
136
+ while (keepGoing) {
137
+ // `.range()` returns a NEW builder rather than mutating in place (true
138
+ // for the current supabase-js / postgrest-js lineage), so re-deriving
139
+ // each page from the shared `orderedQuery` is safe. A client fork that
140
+ // mutated in place would break this loop.
141
+ // eslint-disable-next-line no-await-in-loop
142
+ const { data, error } = await orderedQuery.range(offset, offset + DEFAULT_PAGE_SIZE - 1);
143
+
144
+ if (error) {
145
+ this.log.error(`[${this.entityName}] Failed to query mapping rows by organization - ${error.message}`, error);
146
+ throw new DataAccessError(
147
+ 'Failed to query mapping rows by organization',
148
+ { entityName: this.entityName, tableName: this.tableName },
149
+ error,
150
+ );
151
+ }
152
+
153
+ if (!data || data.length === 0) {
154
+ keepGoing = false;
155
+ } else {
156
+ allResults.push(...data);
157
+ offset += DEFAULT_PAGE_SIZE;
158
+ if (allResults.length >= MAX_ORG_ROWS) {
159
+ this.log.warn(`[${this.entityName}] allByOrganizationId truncated at ${MAX_ORG_ROWS} rows`);
160
+ keepGoing = false;
161
+ } else {
162
+ keepGoing = data.length >= DEFAULT_PAGE_SIZE;
163
+ }
164
+ }
165
+ }
166
+
167
+ return allResults.map((row) => Object.freeze({
168
+ brandId: row.brand_id,
169
+ semrushProjectId: row.semrush_project_id,
170
+ geoTargetId: row.semrush_location_id,
171
+ languageCode: row.language,
172
+ siteId: row.site_id ?? null,
173
+ organizationId: row.brands?.organization_id ?? null,
174
+ semrushSubWorkspaceId: row.brands?.semrush_sub_workspace_id ?? null,
175
+ }));
176
+ }
37
177
  }
38
178
 
39
179
  export default BrandSemrushProjectCollection;
@@ -10,7 +10,9 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import { hasText, isValidUUID } from '@adobe/spacecat-shared-utils';
13
+ import {
14
+ hasText, isIsoDate, isValidUUID,
15
+ } from '@adobe/spacecat-shared-utils';
14
16
 
15
17
  import SchemaBuilder from '../base/schema.builder.js';
16
18
  import BrandSemrushProject from './brand-semrush-project.model.js';
@@ -68,6 +70,25 @@ const schema = new SchemaBuilder(BrandSemrushProject, BrandSemrushProjectCollect
68
70
  validate: (value) => hasText(value) && LANGUAGE_TAG_REGEX.test(value),
69
71
  postgrestField: 'language',
70
72
  })
71
- .addAllIndex(['semrushProjectId']);
73
+ .addAllIndex(['semrushProjectId'])
74
+ // Market-mirror Site for this project. Nullable — the mirror is created
75
+ // best-effort and may lag the row. Not unique: mirror Sites are per
76
+ // (brand, domain), so many projects of a brand sharing a domain share one
77
+ // Site. See serenity-docs brand-semrush-mapping-maintenance.md §5.1.
78
+ .addAttribute('siteId', {
79
+ type: 'string',
80
+ validate: (value) => !value || isValidUUID(value),
81
+ })
82
+ // Soft-delete tombstone. NULL = live (project exists in the child
83
+ // workspace, draft or live). Set = the project was deleted upstream —
84
+ // Semrush data does not survive deletion, so this is the only remaining
85
+ // record it existed. Mirrors the ApiKey.deletedAt precedent
86
+ // (api-key.schema.js) — no package-level soft-delete scope exists, so
87
+ // every accessor except `allByOrganizationId` (collection.js) returns
88
+ // tombstones; callers that need only live rows must filter explicitly.
89
+ .addAttribute('deletedAt', {
90
+ type: 'string',
91
+ validate: (value) => !value || isIsoDate(value),
92
+ });
72
93
 
73
94
  export default schema.build();
@@ -25,9 +25,27 @@ export interface BrandSemrushProject extends BaseModel {
25
25
  getSemrushProjectId(): string;
26
26
  getGeoTargetId(): number;
27
27
  getLanguageCode(): string;
28
+ getSiteId(): string | undefined;
29
+ getDeletedAt(): string | undefined;
28
30
  setSemrushProjectId(value: string): BrandSemrushProject;
29
31
  setGeoTargetId(value: number): BrandSemrushProject;
30
32
  setLanguageCode(value: string): BrandSemrushProject;
33
+ setSiteId(value: string): BrandSemrushProject;
34
+ setDeletedAt(value: string): BrandSemrushProject;
35
+ }
36
+
37
+ export interface BrandSemrushProjectOrgRow {
38
+ brandId: string;
39
+ semrushProjectId: string;
40
+ geoTargetId: number;
41
+ languageCode: string;
42
+ siteId: string | null;
43
+ // Expected non-null under the select's `!inner` join (which excludes
44
+ // non-matching parents), but kept nullable to match the defensive runtime
45
+ // mapping (`row.brands?.organization_id ?? null`) — see
46
+ // brand-semrush-project.collection.js's #fetchOrgRows.
47
+ organizationId: string | null;
48
+ semrushSubWorkspaceId: string | null;
31
49
  }
32
50
 
33
51
  export interface BrandSemrushProjectCollection extends
@@ -41,4 +59,8 @@ export interface BrandSemrushProjectCollection extends
41
59
  geoTargetId: number,
42
60
  languageCode: string,
43
61
  ): Promise<BrandSemrushProject | null>;
62
+ allByOrganizationId(
63
+ organizationId: string,
64
+ options?: { includeDeleted?: boolean },
65
+ ): Promise<BrandSemrushProjectOrgRow[]>;
44
66
  }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import type { AsyncJob, BaseCollection, BaseModel, Site } from '../index.js';
14
14
 
15
+ // SITES-47254: startedAt/result/error live on AsyncJob — fetch via getAsyncJob().
15
16
  export interface Preflight extends BaseModel {
16
17
  getSiteId(): string;
17
18
  getSite(): Promise<Site>;
@@ -20,18 +21,14 @@ export interface Preflight extends BaseModel {
20
21
  getUrl(): string;
21
22
  getStatus(): string;
22
23
  getCreatedBy(): { email: string; displayName?: string };
23
- getStartedAt(): string | null;
24
- getEndedAt(): string | null;
25
- getResult(): object | null;
26
- getError(): { code: string; message: string } | null;
24
+ // `string | undefined` (not `| null`) because normalizeModelValue maps
25
+ // DB NULL → undefined on read — see AsyncJob/index.d.ts header.
26
+ getEndedAt(): string | undefined;
27
27
 
28
28
  setUrl(url: string): Preflight;
29
29
  setStatus(status: 'IN_PROGRESS' | 'COMPLETED' | 'FAILED' | 'CANCELLED'): Preflight;
30
30
  setCreatedBy(createdBy: { email: string; displayName?: string }): Preflight;
31
- setStartedAt(startedAt: string): Preflight;
32
31
  setEndedAt(endedAt: string): Preflight;
33
- setResult(result: object): Preflight;
34
- setError(error: { code: string; message: string }): Preflight;
35
32
  }
36
33
 
37
34
  export interface PreflightCollection extends BaseCollection<Preflight> {
@@ -17,6 +17,11 @@ import SchemaBuilder from '../base/schema.builder.js';
17
17
  import Preflight from './preflight.model.js';
18
18
  import PreflightCollection from './preflight.collection.js';
19
19
 
20
+ // SITES-47254: `startedAt`, `result`, and `error` live only on AsyncJob now —
21
+ // the underlying preflights table no longer carries them. Consumers fetch the
22
+ // joined AsyncJob (e.g., `await preflight.getAsyncJob()`) for lifecycle
23
+ // internals; `status` and `endedAt` remain here as a denormalized cache the
24
+ // projector keeps in sync.
20
25
  const schema = new SchemaBuilder(Preflight, PreflightCollection)
21
26
  .addReference('belongs_to', 'Site', [], { required: true })
22
27
  .addReference('belongs_to', 'AsyncJob', [], { required: true })
@@ -30,34 +35,22 @@ const schema = new SchemaBuilder(Preflight, PreflightCollection)
30
35
  required: true,
31
36
  default: Preflight.Status.IN_PROGRESS,
32
37
  })
33
- // `createdBy` and `error` use type 'any' (matching neighbor `result`) because
34
- // ElectroDB's `map` type requires a `properties` schema for every sub-key,
35
- // and the validate function below already enforces the precise shape — a
36
- // duplicate `properties` declaration adds nothing. Declaring `type: 'map'`
37
- // here without `properties` was the original definition and throws
38
- // `InvalidAttributeDefinition` at Service construction, blocking any
39
- // downstream consumer that builds a v1 `new Service(EntityRegistry.getEntities())`
40
- // (e.g. spacecat-api-service `fixes.test.js`).
38
+ // `createdBy` uses type 'any' because ElectroDB's `map` type requires a
39
+ // `properties` schema for every sub-key, and the validate function below
40
+ // already enforces the precise shape — a duplicate `properties` declaration
41
+ // adds nothing. Declaring `type: 'map'` here without `properties` was the
42
+ // original definition and throws `InvalidAttributeDefinition` at Service
43
+ // construction, blocking any downstream consumer that builds a v1
44
+ // `new Service(EntityRegistry.getEntities())` (e.g. spacecat-api-service
45
+ // `fixes.test.js`).
41
46
  .addAttribute('createdBy', {
42
47
  type: 'any',
43
48
  required: true,
44
49
  validate: (value) => isObject(value) && typeof value.email === 'string' && value.email.length > 0,
45
50
  })
46
- .addAttribute('startedAt', {
47
- type: 'string',
48
- validate: (value) => !value || isIsoDate(value),
49
- })
50
51
  .addAttribute('endedAt', {
51
52
  type: 'string',
52
53
  validate: (value) => !value || isIsoDate(value),
53
- })
54
- .addAttribute('result', {
55
- type: 'any',
56
- validate: (value) => !value || isObject(value),
57
- })
58
- .addAttribute('error', {
59
- type: 'any',
60
- validate: (value) => !value || (isObject(value) && typeof value.code === 'string' && value.code.length > 0 && typeof value.message === 'string' && value.message.length > 0),
61
54
  });
62
55
 
63
56
  export default schema.build();