@adobe/spacecat-shared-data-access 1.59.2 → 1.60.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.
Files changed (102) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +2 -2
  3. package/src/models/site/config.js +1 -1
  4. package/src/service/audits/accessPatterns.js +7 -7
  5. package/src/service/experiments/accessPatterns.js +2 -2
  6. package/src/service/import-job/accessPatterns.js +1 -1
  7. package/src/service/import-url/accessPatterns.js +2 -2
  8. package/src/service/index.js +10 -18
  9. package/src/service/key-events/accessPatterns.js +3 -3
  10. package/src/service/organizations/accessPatterns.js +3 -3
  11. package/src/service/site-candidates/accessPatterns.js +1 -1
  12. package/src/service/sites/accessPatterns.js +11 -11
  13. package/src/v2/models/api-key/api-key.collection.js +26 -0
  14. package/src/v2/models/api-key/api-key.model.js +59 -0
  15. package/src/v2/models/api-key/api-key.schema.js +82 -0
  16. package/src/v2/models/api-key/index.d.ts +37 -0
  17. package/src/v2/models/api-key/index.js +19 -0
  18. package/src/v2/models/audit/audit.collection.js +26 -0
  19. package/src/v2/models/audit/audit.model.js +89 -0
  20. package/src/v2/models/audit/audit.schema.js +66 -0
  21. package/src/v2/models/audit/index.d.ts +40 -0
  22. package/src/v2/models/audit/index.js +19 -0
  23. package/src/v2/models/base/base.collection.js +450 -0
  24. package/src/v2/models/{base.model.js → base/base.model.js} +109 -89
  25. package/src/v2/models/base/constants.js +17 -0
  26. package/src/v2/models/base/entity.registry.js +137 -0
  27. package/src/v2/models/base/index.d.ts +83 -0
  28. package/src/v2/models/base/index.js +27 -0
  29. package/src/v2/models/base/reference.js +159 -0
  30. package/src/v2/models/base/schema.builder.js +420 -0
  31. package/src/v2/models/base/schema.js +283 -0
  32. package/src/v2/models/configuration/configuration.collection.js +39 -0
  33. package/src/v2/models/configuration/configuration.model.js +160 -0
  34. package/src/v2/models/configuration/configuration.schema.js +103 -0
  35. package/src/v2/models/configuration/index.d.ts +111 -0
  36. package/src/v2/models/configuration/index.js +19 -0
  37. package/src/v2/models/experiment/experiment.collection.js +26 -0
  38. package/src/v2/models/experiment/experiment.model.js +28 -0
  39. package/src/v2/models/experiment/experiment.schema.js +70 -0
  40. package/src/v2/models/experiment/index.d.ts +49 -0
  41. package/src/v2/models/experiment/index.js +19 -0
  42. package/src/v2/models/import-job/import-job.collection.js +45 -0
  43. package/src/v2/models/import-job/import-job.model.js +55 -0
  44. package/src/v2/models/import-job/import-job.schema.js +152 -0
  45. package/src/v2/models/import-job/index.d.ts +51 -0
  46. package/src/v2/models/import-job/index.js +19 -0
  47. package/src/v2/models/import-url/import-url.collection.js +26 -0
  48. package/src/v2/models/import-url/import-url.model.js +28 -0
  49. package/src/v2/models/import-url/import-url.schema.js +59 -0
  50. package/src/v2/models/import-url/index.d.ts +35 -0
  51. package/src/v2/models/import-url/index.js +19 -0
  52. package/src/v2/models/index.d.ts +11 -99
  53. package/src/v2/models/index.js +14 -15
  54. package/src/v2/models/key-event/index.d.ts +28 -0
  55. package/src/v2/models/key-event/index.js +19 -0
  56. package/src/v2/models/key-event/key-event.collection.js +26 -0
  57. package/src/v2/models/key-event/key-event.model.js +37 -0
  58. package/src/v2/models/key-event/key-event.schema.js +45 -0
  59. package/src/v2/models/opportunity/index.d.ts +46 -0
  60. package/src/v2/models/opportunity/index.js +19 -0
  61. package/src/v2/models/opportunity/opportunity.collection.js +26 -0
  62. package/src/v2/models/{opportunity.model.js → opportunity/opportunity.model.js} +15 -2
  63. package/src/v2/models/opportunity/opportunity.schema.js +69 -0
  64. package/src/v2/models/organization/index.d.ts +28 -0
  65. package/src/v2/models/organization/index.js +19 -0
  66. package/src/v2/models/organization/organization.collection.js +26 -0
  67. package/src/v2/models/organization/organization.model.js +31 -0
  68. package/src/v2/models/organization/organization.schema.js +51 -0
  69. package/src/v2/models/site/index.d.ts +43 -0
  70. package/src/v2/models/site/index.js +20 -0
  71. package/src/v2/models/site/site.collection.js +28 -0
  72. package/src/v2/models/site/site.model.js +47 -0
  73. package/src/v2/models/site/site.schema.js +91 -0
  74. package/src/v2/models/site-candidate/index.d.ts +38 -0
  75. package/src/v2/models/site-candidate/index.js +19 -0
  76. package/src/v2/models/site-candidate/site-candidate.collection.js +27 -0
  77. package/src/v2/models/site-candidate/site-candidate.model.js +41 -0
  78. package/src/v2/models/site-candidate/site-candidate.schema.js +59 -0
  79. package/src/v2/models/site-top-page/index.d.ts +35 -0
  80. package/src/v2/models/site-top-page/index.js +19 -0
  81. package/src/v2/models/site-top-page/site-top-page.collection.js +44 -0
  82. package/src/v2/models/site-top-page/site-top-page.model.js +28 -0
  83. package/src/v2/models/site-top-page/site-top-page.schema.js +65 -0
  84. package/src/v2/models/suggestion/index.d.ts +34 -0
  85. package/src/v2/models/suggestion/index.js +19 -0
  86. package/src/v2/models/suggestion/suggestion.collection.js +55 -0
  87. package/src/v2/models/{suggestion.model.js → suggestion/suggestion.model.js} +16 -1
  88. package/src/v2/models/suggestion/suggestion.schema.js +53 -0
  89. package/src/v2/readme.md +201 -256
  90. package/src/v2/util/accessor.utils.js +158 -0
  91. package/src/v2/util/guards.d.ts +7 -0
  92. package/src/v2/util/guards.js +21 -4
  93. package/src/v2/util/index.js +1 -0
  94. package/src/v2/util/patcher.js +54 -25
  95. package/src/v2/util/util.js +84 -0
  96. package/src/v2/models/base.collection.js +0 -275
  97. package/src/v2/models/model.factory.js +0 -74
  98. package/src/v2/models/opportunity.collection.js +0 -74
  99. package/src/v2/models/suggestion.collection.js +0 -104
  100. package/src/v2/schema/opportunity.schema.js +0 -159
  101. package/src/v2/schema/suggestion.schema.js +0 -132
  102. package/src/v2/util/reference.js +0 -41
@@ -0,0 +1,89 @@
1
+ /*
2
+ * Copyright 2024 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 { isObject } from '@adobe/spacecat-shared-utils';
14
+
15
+ import { ValidationError } from '../../errors/index.js';
16
+ import BaseModel from '../base/base.model.js';
17
+
18
+ const AUDIT_TYPES = {
19
+ 404: '404',
20
+ BROKEN_BACKLINKS: 'broken-backlinks',
21
+ EXPERIMENTATION: 'experimentation',
22
+ ORGANIC_KEYWORDS: 'organic-keywords',
23
+ ORGANIC_TRAFFIC: 'organic-traffic',
24
+ CWV: 'cwv',
25
+ LHS_DESKTOP: 'lhs-desktop',
26
+ LHS_MOBILE: 'lhs-mobile',
27
+ EXPERIMENTATION_ESS_MONTHLY: 'experimentation-ess-monthly',
28
+ EXPERIMENTATION_ESS_DAILY: 'experimentation-ess-daily',
29
+ };
30
+
31
+ const AUDIT_TYPE_PROPERTIES = {
32
+ [AUDIT_TYPES.LHS_DESKTOP]: ['performance', 'seo', 'accessibility', 'best-practices'],
33
+ [AUDIT_TYPES.LHS_MOBILE]: ['performance', 'seo', 'accessibility', 'best-practices'],
34
+ };
35
+
36
+ export const AUDIT_CONFIG = {
37
+ TYPES: AUDIT_TYPES,
38
+ PROPERTIES: AUDIT_TYPE_PROPERTIES,
39
+ };
40
+
41
+ /**
42
+ * Validates if the auditResult contains the required properties for the given audit type.
43
+ * @param {object} auditResult - The audit result to validate.
44
+ * @param {string} auditType - The type of the audit.
45
+ * @returns {boolean} - True if valid, false otherwise.
46
+ */
47
+ export const validateAuditResult = (auditResult, auditType) => {
48
+ if (!isObject(auditResult) && !Array.isArray(auditResult)) {
49
+ throw new ValidationError('Audit result must be an object or array');
50
+ }
51
+
52
+ if (isObject(auditResult.runtimeError)) {
53
+ return true;
54
+ }
55
+
56
+ if ((auditType === AUDIT_CONFIG.TYPES.LHS_MOBILE || auditType === AUDIT_CONFIG.TYPES.LHS_DESKTOP)
57
+ && !isObject(auditResult.scores)) {
58
+ throw new ValidationError(`Missing scores property for audit type '${auditType}'`);
59
+ }
60
+
61
+ const expectedProperties = AUDIT_CONFIG.PROPERTIES[auditType];
62
+
63
+ if (expectedProperties) {
64
+ for (const prop of expectedProperties) {
65
+ if (!(prop in auditResult.scores)) {
66
+ throw new ValidationError(`Missing expected property '${prop}' for audit type '${auditType}'`);
67
+ }
68
+ }
69
+ }
70
+
71
+ return true;
72
+ };
73
+
74
+ /**
75
+ * Audit - A class representing an Audit entity.
76
+ * Provides methods to access and manipulate Audit-specific data.
77
+ *
78
+ * @class Audit
79
+ * @extends BaseModel
80
+ */
81
+ class Audit extends BaseModel {
82
+ // add your custom methods or overrides here
83
+
84
+ getScores() {
85
+ return this.getAuditResult()?.scores;
86
+ }
87
+ }
88
+
89
+ export default Audit;
@@ -0,0 +1,66 @@
1
+ /*
2
+ * Copyright 2024 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
+ /* c8 ignore start */
14
+
15
+ import { isIsoDate, isNonEmptyObject } from '@adobe/spacecat-shared-utils';
16
+
17
+ import SchemaBuilder from '../base/schema.builder.js';
18
+ import Audit, { validateAuditResult } from './audit.model.js';
19
+ import AuditCollection from './audit.collection.js';
20
+
21
+ /*
22
+ Schema Doc: https://electrodb.dev/en/modeling/schema/
23
+ Attribute Doc: https://electrodb.dev/en/modeling/attributes/
24
+ Indexes Doc: https://electrodb.dev/en/modeling/indexes/
25
+ */
26
+
27
+ const schema = new SchemaBuilder(Audit, AuditCollection)
28
+ .addReference('belongs_to', 'Site', ['auditType', 'auditedAt'])
29
+ .addReference('has_many', 'Opportunities')
30
+ .addAttribute('auditResult', {
31
+ type: 'any',
32
+ required: true,
33
+ validate: (value) => isNonEmptyObject(value),
34
+ set: (value, attributes) => {
35
+ // as the electroDb validate function does not provide access to the model instance
36
+ // we need to call the validate function from the model on setting the value
37
+ validateAuditResult(value, attributes.auditType);
38
+ return value;
39
+ },
40
+ })
41
+ .addAttribute('auditType', {
42
+ type: 'string',
43
+ required: true,
44
+ })
45
+ .addAttribute('fullAuditRef', {
46
+ type: 'string',
47
+ required: true,
48
+ })
49
+ .addAttribute('isLive', {
50
+ type: 'boolean',
51
+ required: true,
52
+ default: false,
53
+ })
54
+ .addAttribute('isError', {
55
+ type: 'boolean',
56
+ required: true,
57
+ default: false,
58
+ })
59
+ .addAttribute('auditedAt', {
60
+ type: 'string',
61
+ required: true,
62
+ default: () => new Date().toISOString(),
63
+ validate: (value) => isIsoDate(value),
64
+ });
65
+
66
+ export default schema.build();
@@ -0,0 +1,40 @@
1
+ /*
2
+ * Copyright 2024 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 type {
14
+ BaseCollection, BaseModel, Opportunity, Site,
15
+ } from '../index';
16
+
17
+ export interface Audit extends BaseModel {
18
+ getAuditResult(): object;
19
+ getAuditType(): string;
20
+ getAuditedAt(): number;
21
+ getFullAuditRef(): string;
22
+ getIsError(): boolean;
23
+ getIsLive(): boolean;
24
+ getOpportunities(): Promise<Opportunity[]>;
25
+ getSite(): Promise<Site>;
26
+ getSiteId(): string;
27
+ setAuditResult(auditResult: object): Audit;
28
+ setAuditType(auditType: string): Audit;
29
+ setAuditedAt(auditedAt: number): Audit;
30
+ setFullAuditRef(fullAuditRef: string): Audit;
31
+ setIsError(isError: boolean): Audit;
32
+ setIsLive(isLive: boolean): Audit;
33
+ setSiteId(siteId: string): Audit;
34
+ toggleLive(): Audit;
35
+ }
36
+
37
+ export interface AuditCollection extends BaseCollection<Audit> {
38
+ allBySiteId(siteId: string): Promise<Audit[]>;
39
+ allBySiteAndType(siteId: string, auditType: string): Promise<Audit[]>;
40
+ }
@@ -0,0 +1,19 @@
1
+ /*
2
+ * Copyright 2024 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 Audit from './audit.model.js';
14
+ import AuditCollection from './audit.collection.js';
15
+
16
+ export {
17
+ Audit,
18
+ AuditCollection,
19
+ };
@@ -0,0 +1,450 @@
1
+ /*
2
+ * Copyright 2024 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 {
14
+ hasText,
15
+ isNonEmptyObject,
16
+ isObject,
17
+ } from '@adobe/spacecat-shared-utils';
18
+
19
+ import { ElectroValidationError } from 'electrodb';
20
+
21
+ import { removeElectroProperties } from '../../../../test/it/util/util.js';
22
+ import { createAccessors } from '../../util/accessor.utils.js';
23
+ import ValidationError from '../../errors/validation.error.js';
24
+ import { guardId } from '../../util/guards.js';
25
+ import {
26
+ entityNameToAllPKValue,
27
+ isNonEmptyArray,
28
+ keyNamesToIndexName,
29
+ } from '../../util/util.js';
30
+ import { INDEX_TYPES } from './constants.js';
31
+
32
+ function isValidParent(parent, child) {
33
+ if (!hasText(parent.entityName)) {
34
+ return false;
35
+ }
36
+
37
+ const foreignKey = `${parent.entityName}Id`;
38
+
39
+ return child.record?.[foreignKey] === parent.record?.[foreignKey];
40
+ }
41
+
42
+ /**
43
+ * Attempts to find an index name matching a generated name from the given keyNames.
44
+ * If no exact match is found, it progressively shortens the keyNames by removing the last one
45
+ * and tries again. If still no match, it tries the "all" index, and then "primary".
46
+ *
47
+ * @param {object} indexes - The available indexes, keyed by their names.
48
+ * @param {object} keys - The keys to find an index name for.
49
+ * @returns {object} The found index.
50
+ */
51
+ function findIndexNameByKeys(indexes, keys) {
52
+ const keyNames = Object.keys(keys);
53
+ for (let { length } = keyNames; length > 0; length -= 1) {
54
+ const subKeyNames = keyNames.slice(0, length);
55
+ const candidateName = keyNamesToIndexName(subKeyNames);
56
+ if (indexes[candidateName]) {
57
+ return candidateName;
58
+ }
59
+ }
60
+
61
+ if (indexes.all) {
62
+ return INDEX_TYPES.ALL;
63
+ }
64
+
65
+ return INDEX_TYPES.PRIMARY;
66
+ }
67
+
68
+ /**
69
+ * BaseCollection - A base class for managing collections of entities in the application.
70
+ * This class uses ElectroDB to interact with entities and provides common functionality
71
+ * for data operations.
72
+ *
73
+ * @class BaseCollection
74
+ * @abstract
75
+ */
76
+ class BaseCollection {
77
+ /**
78
+ * Constructs an instance of BaseCollection.
79
+ * @constructor
80
+ * @param {Object} electroService - The ElectroDB service used for managing entities.
81
+ * @param {Object} entityRegistry - The registry holding entities, their schema and collection.
82
+ * @param {Object} schema - The schema for the entity.
83
+ * @param {Object} log - A log for capturing logging information.
84
+ */
85
+ constructor(electroService, entityRegistry, schema, log) {
86
+ this.electroService = electroService;
87
+ this.entityRegistry = entityRegistry;
88
+ this.schema = schema;
89
+ this.log = log;
90
+
91
+ this.clazz = this.schema.getModelClass();
92
+ this.entityName = this.schema.getEntityName();
93
+ this.idName = this.schema.getIdName();
94
+ this.entity = electroService.entities[this.entityName];
95
+
96
+ this.#initializeCollectionMethods();
97
+ }
98
+
99
+ /**
100
+ * Initialize collection methods for each "by..." index defined in the entity schema.
101
+ * For each index that starts with "by", we:
102
+ * 1. Retrieve its composite pk and sk arrays from the schema.
103
+ * 2. Generate convenience methods for every prefix of the composite keys.
104
+ * For example, if the index keys are ['opportunityId', 'status', 'createdAt'],
105
+ * we create methods:
106
+ * - allByOpportunityId(...) / findByOpportunityId(...)
107
+ * - allByOpportunityIdAndStatus(...) / findByOpportunityIdAndStatus(...)
108
+ * - allByOpportunityIdAndStatusAndCreatedAt(...) /
109
+ * findByOpportunityIdAndStatusAndCreatedAt(...)
110
+ *
111
+ * Each generated method calls allByIndexKeys() or findByIndexKeys() with the appropriate keys.
112
+ *
113
+ * @private
114
+ */
115
+ #initializeCollectionMethods() {
116
+ const accessorConfigs = this.schema.toAccessorConfigs(this, this.log);
117
+ createAccessors(accessorConfigs, this.log);
118
+ }
119
+
120
+ /**
121
+ * Creates an instance of a model from a record.
122
+ * @private
123
+ * @param {Object} record - The record containing data to create the model instance.
124
+ * @returns {BaseModel|null} - Returns an instance of the model class if the data is valid,
125
+ * otherwise null.
126
+ */
127
+ #createInstance(record) {
128
+ if (!isNonEmptyObject(record)) {
129
+ this.log.warn(`Failed to create instance of [${this.entityName}]: record is empty`);
130
+ return null;
131
+ }
132
+ // eslint-disable-next-line new-cap
133
+ return new this.clazz(
134
+ this.electroService,
135
+ this.entityRegistry,
136
+ this.schema,
137
+ record,
138
+ this.log,
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Creates instances of models from a set of records.
144
+ * @private
145
+ * @param {Object} records - The records containing data to create the model instances.
146
+ * @returns {Array<BaseModel>} - An array of instances of the model class.
147
+ */
148
+ #createInstances(records) {
149
+ return records.map((record) => this.#createInstance(record));
150
+ }
151
+
152
+ #invalidateCache() {
153
+ this._accessorCache = {};
154
+ }
155
+
156
+ /**
157
+ * General method to query entities by index keys. This method is used by other
158
+ * query methods to perform the actual query operation. It will use the index keys
159
+ * to find the appropriate index and query the entities. The query result will be
160
+ * transformed into model instances.
161
+ * @private
162
+ * @param {Object} keys - The index keys to use for the query.
163
+ * @param {Object} options - Additional options for the query.
164
+ * @returns {Promise<BaseModel|Array<BaseModel>|null>} - The query result.
165
+ */
166
+ async #queryByIndexKeys(keys, options = {}) {
167
+ if (!isNonEmptyObject(keys)) {
168
+ const message = `Failed to query [${this.entityName}]: keys are required`;
169
+ this.log.error(message);
170
+ throw new Error(message);
171
+ }
172
+
173
+ if (!isObject(options)) {
174
+ const message = `Failed to query [${this.entityName}]: options must be an object`;
175
+ this.log.error(message);
176
+ throw new Error(message);
177
+ }
178
+
179
+ const indexName = options.index || findIndexNameByKeys(this.entity.query, keys);
180
+ const index = this.entity.query[indexName];
181
+
182
+ if (!index) {
183
+ const message = `Failed to query [${this.entityName}]: index [${indexName}] not found`;
184
+ this.log.error(message);
185
+ throw new Error(message);
186
+ }
187
+
188
+ const queryOptions = {
189
+ order: options.order || 'desc',
190
+ ...options.limit && { limit: options.limit },
191
+ ...options.attributes && { attributes: options.attributes },
192
+ };
193
+
194
+ let query = index(keys);
195
+
196
+ if (isObject(options.between)) {
197
+ query = query.between(
198
+ { [options.between.attribute]: options.between.start },
199
+ { [options.between.attribute]: options.between.end },
200
+ );
201
+ }
202
+
203
+ const records = await query.go(queryOptions);
204
+
205
+ if (options.limit === 1) {
206
+ if (records.data?.length === 0) {
207
+ return null;
208
+ }
209
+ return this.#createInstance(records.data[0]);
210
+ } else {
211
+ return this.#createInstances(records.data);
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Finds all entities in the collection. Requires an index named "all" with a partition key
217
+ * named "pk" with a static value of "ALL_<ENTITYNAME>".
218
+ * @param {Object} [sortKeys] - The sort keys to use for the query.
219
+ * @param {Object} [options] - Additional options for the query.
220
+ * @return {Promise<BaseModel|Array<BaseModel>|null>}
221
+ */
222
+ async all(sortKeys = {}, options = {}) {
223
+ const keys = { pk: entityNameToAllPKValue(this.entityName), ...sortKeys };
224
+ return this.#queryByIndexKeys(keys, options);
225
+ }
226
+
227
+ /**
228
+ * Finds entities by a set of index keys. Index keys are used to query entities by
229
+ * a specific index defined in the entity schema. The index keys must match the
230
+ * fields defined in the index.
231
+ * @param {Object} keys - The index keys to use for the query.
232
+ * @param {{index?: string, attributes?: string[]}} [options] - Additional options for the query.
233
+ * @return {Promise<Array<BaseModel>>} - A promise that resolves to an array of model instances.
234
+ * @throws {Error} - Throws an error if the index keys are not provided or if the index
235
+ * is not found.
236
+ * @async
237
+ */
238
+ async allByIndexKeys(keys, options = {}) {
239
+ return this.#queryByIndexKeys(keys, options);
240
+ }
241
+
242
+ /**
243
+ * Finds a single entity from the "all" index. Requires an index named "all" with a partition key
244
+ * named "pk" with a static value of "ALL_<ENTITYNAME>".
245
+ * @param {Object} [sortKeys] - The sort keys to use for the query.
246
+ * @param {{index?: string, attributes?: string[]}} [options] - Additional options for the query.
247
+ * @return {Promise<BaseModel|Array<BaseModel>|null>}
248
+ */
249
+ async findByAll(sortKeys = {}, options = {}) {
250
+ if (!isObject(sortKeys)) {
251
+ const message = `Failed to find by all [${this.entityName}]: sort keys must be an object`;
252
+ this.log.error(message);
253
+ throw new Error(message);
254
+ }
255
+
256
+ const keys = { pk: entityNameToAllPKValue(this.entityName), ...sortKeys };
257
+ return this.#queryByIndexKeys(keys, { ...options, index: INDEX_TYPES.ALL, limit: 1 });
258
+ }
259
+
260
+ /**
261
+ * Finds an entity by its ID.
262
+ * @async
263
+ * @param {string} id - The unique identifier of the entity to be found.
264
+ * @returns {Promise<BaseModel|null>} - A promise that resolves to an instance of
265
+ * the model if found, otherwise null.
266
+ * @throws {Error} - Throws an error if the ID is not provided.
267
+ */
268
+ async findById(id) {
269
+ guardId(this.idName, id, this.entityName);
270
+
271
+ const record = await this.entity.get({ [this.idName]: id }).go();
272
+
273
+ return this.#createInstance(record?.data);
274
+ }
275
+
276
+ /**
277
+ * Finds a single entity by index keys.
278
+ * @param {Object} keys - The index keys to use for the query.
279
+ * @param {{index?: string, attributes?: string[]}} [options] - Additional options for the query.
280
+ * @returns {Promise<BaseModel|null>} - A promise that resolves to the model instance or null.
281
+ * @async
282
+ */
283
+ async findByIndexKeys(keys, options = {}) {
284
+ return this.#queryByIndexKeys(keys, { ...options, limit: 1 });
285
+ }
286
+
287
+ /**
288
+ * Creates a new entity in the collection and directly persists it to the database.
289
+ * There is no need to call the save method (which is for updates only) after creating
290
+ * the entity.
291
+ * @async
292
+ * @param {Object} item - The data for the entity to be created.
293
+ * @returns {Promise<BaseModel>} - A promise that resolves to the created model instance.
294
+ * @throws {Error} - Throws an error if the data is invalid or if the creation process fails.
295
+ */
296
+ async create(item) {
297
+ if (!isNonEmptyObject(item)) {
298
+ const message = `Failed to create [${this.entityName}]: data is required`;
299
+ this.log.error(message);
300
+ throw new Error(message);
301
+ }
302
+
303
+ try {
304
+ const record = await this.entity.create(item).go();
305
+ const instance = this.#createInstance(record.data);
306
+
307
+ this.#invalidateCache();
308
+
309
+ return instance;
310
+ } catch (error) {
311
+ this.log.error(`Failed to create [${this.entityName}]`, error);
312
+ throw error;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Validates and batches items for batch operations.
318
+ * @private
319
+ * @param {Array<Object>} items - Items to be validated.
320
+ * @returns {Object} - An object containing validated items and error items.
321
+ */
322
+ #validateItems(items) {
323
+ const validatedItems = [];
324
+ const errorItems = [];
325
+
326
+ items.forEach((item) => {
327
+ try {
328
+ const { Item } = this.entity.put(item).params();
329
+ validatedItems.push({ ...removeElectroProperties(Item), ...item });
330
+ } catch (error) {
331
+ if (error instanceof ElectroValidationError) {
332
+ errorItems.push({ item, error: new ValidationError(error) });
333
+ }
334
+ }
335
+ });
336
+
337
+ return { validatedItems, errorItems };
338
+ }
339
+
340
+ /**
341
+ * Creates multiple entities in the collection and directly persists them to the database in
342
+ * a batch write operation. Batches are written in parallel and are limited to 25 items per batch.
343
+ *
344
+ * @async
345
+ * @param {Array<Object>} newItems - An array of data for the entities to be created.
346
+ * @param {BaseModel} [parent] - Optional parent entity that these items are associated with.
347
+ * @return {Promise<{ createdItems: BaseModel[],
348
+ * errorItems: { item: Object, error: ValidationError }[] }>} - A promise that resolves to
349
+ * an object containing the created items and any items that failed validation.
350
+ * @throws {ValidationError} - Throws a validation error if any of the items has validation
351
+ * failures.
352
+ */
353
+ async createMany(newItems, parent = null) {
354
+ if (!isNonEmptyArray(newItems)) {
355
+ const message = `Failed to create many [${this.entityName}]: items must be a non-empty array`;
356
+ this.log.error(message);
357
+ throw new Error(message);
358
+ }
359
+
360
+ try {
361
+ const { validatedItems, errorItems } = this.#validateItems(newItems);
362
+
363
+ if (validatedItems.length > 0) {
364
+ const response = await this.entity.put(validatedItems).go();
365
+
366
+ if (isNonEmptyArray(response?.unprocessed)) {
367
+ this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`);
368
+ }
369
+ }
370
+
371
+ const createdItems = this.#createInstances(validatedItems);
372
+
373
+ if (isNonEmptyObject(parent)) {
374
+ createdItems.forEach((record) => {
375
+ if (!isValidParent(parent, record)) {
376
+ this.log.warn(`Failed to associate parent with child [${this.entityName}]: parent is invalid`);
377
+ return;
378
+ }
379
+ // eslint-disable-next-line no-underscore-dangle,no-param-reassign
380
+ record._accessorCache[`get${parent.schema.getModelName()}`] = parent;
381
+ });
382
+ }
383
+
384
+ this.#invalidateCache();
385
+
386
+ this.log.info(`Created ${createdItems.length} items for [${this.entityName}]`);
387
+
388
+ return { createdItems, errorItems };
389
+ } catch (error) {
390
+ this.log.error(`Failed to create many [${this.entityName}]`, error);
391
+ throw error;
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Updates a collection of entities in the database using a batch write (put) operation.
397
+ *
398
+ * @async
399
+ * @param {Array<BaseModel>} items - An array of model instances to be updated.
400
+ * @return {Promise<void>} - A promise that resolves when the update operation is complete.
401
+ * @throws {Error} - Throws an error if the update operation fails.
402
+ * @protected
403
+ */
404
+ async _saveMany(items) {
405
+ if (!isNonEmptyArray(items)) {
406
+ const message = `Failed to save many [${this.entityName}]: items must be a non-empty array`;
407
+ this.log.error(message);
408
+ throw new Error(message);
409
+ }
410
+
411
+ try {
412
+ const updates = items.map((item) => item.record);
413
+ const response = await this.entity.put(updates).go();
414
+
415
+ this.#invalidateCache();
416
+
417
+ if (response.unprocessed) {
418
+ this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`);
419
+ }
420
+ } catch (error) {
421
+ this.log.error(`Failed to save many [${this.entityName}]`, error);
422
+ throw error;
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Removes all records of this entity based on the provided IDs. This will perform a batch
428
+ * delete operation. This operation does not remove dependent records.
429
+ * @param {Array<string>} ids - An array of IDs to remove.
430
+ * @return {Promise<void>} - A promise that resolves when the removal operation is complete.
431
+ * @throws {Error} - Throws an error if the IDs are not provided or if the
432
+ * removal operation fails.
433
+ */
434
+ async removeByIds(ids) {
435
+ if (!isNonEmptyArray(ids)) {
436
+ const message = `Failed to remove [${this.entityName}]: ids must be a non-empty array`;
437
+ this.log.error(message);
438
+ throw new Error(message);
439
+ }
440
+
441
+ this.log.info(`Removing ${ids.length} items for [${this.entityName}]`);
442
+ // todo: consider removing dependent records
443
+
444
+ await this.entity.delete(ids.map((id) => ({ [this.idName]: id }))).go();
445
+
446
+ this.#invalidateCache();
447
+ }
448
+ }
449
+
450
+ export default BaseCollection;