@adobe/spacecat-shared-data-access 2.108.0 → 3.0.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 +21 -0
- package/README.md +260 -245
- package/docker-compose.test.yml +39 -0
- package/package.json +5 -6
- package/src/index.js +16 -5
- package/src/models/audit/audit.collection.js +6 -20
- package/src/models/base/base.collection.js +699 -395
- package/src/models/base/base.model.js +13 -6
- package/src/models/base/entity.registry.js +25 -5
- package/src/models/base/schema.builder.js +2 -0
- package/src/models/base/schema.js +15 -4
- package/src/models/consumer/consumer.collection.js +149 -0
- package/src/models/consumer/consumer.model.js +61 -0
- package/src/models/consumer/consumer.schema.js +65 -0
- package/src/models/consumer/index.d.ts +39 -0
- package/src/models/consumer/index.js +19 -0
- package/src/models/fix-entity-suggestion/fix-entity-suggestion.model.js +15 -0
- package/src/models/fix-entity-suggestion/fix-entity-suggestion.schema.js +12 -0
- package/src/models/index.d.ts +1 -0
- package/src/models/index.js +1 -0
- package/src/models/key-event/key-event.collection.js +23 -1
- package/src/models/latest-audit/latest-audit.collection.js +77 -3
- package/src/models/scrape-job/scrape-job.collection.js +38 -0
- package/src/models/scrape-job/scrape-job.schema.js +4 -2
- package/src/models/site/site.schema.js +1 -0
- package/src/models/site-enrollment/site-enrollment.collection.js +11 -1
- package/src/models/trial-user/trial-user.collection.js +0 -2
- package/src/models/trial-user/trial-user.schema.js +5 -1
- package/src/service/index.d.ts +7 -1
- package/src/service/index.js +33 -52
- package/src/util/index.js +2 -1
- package/src/util/logger-registry.js +12 -0
- package/src/util/patcher.js +64 -112
- package/src/util/postgrest.utils.js +203 -0
- package/tsconfig.json +24 -0
|
@@ -17,17 +17,26 @@ import {
|
|
|
17
17
|
isObject,
|
|
18
18
|
} from '@adobe/spacecat-shared-utils';
|
|
19
19
|
|
|
20
|
-
import { ElectroValidationError } from 'electrodb';
|
|
21
|
-
|
|
22
20
|
import DataAccessError from '../../errors/data-access.error.js';
|
|
23
21
|
import ValidationError from '../../errors/validation.error.js';
|
|
24
22
|
import { createAccessors } from '../../util/accessor.utils.js';
|
|
25
23
|
import { guardId, guardArray } from '../../util/guards.js';
|
|
26
24
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
applyWhere,
|
|
26
|
+
createFieldMaps,
|
|
27
|
+
decodeCursor,
|
|
28
|
+
DEFAULT_PAGE_SIZE,
|
|
29
|
+
encodeCursor,
|
|
30
|
+
entityToTableName,
|
|
31
|
+
fromDbRecord,
|
|
32
|
+
toDbField,
|
|
33
|
+
toDbRecord,
|
|
34
|
+
} from '../../util/postgrest.utils.js';
|
|
30
35
|
import { DATASTORE_TYPE } from '../../util/index.js';
|
|
36
|
+
import { entityNameToAllPKValue, removeElectroProperties } from '../../util/util.js';
|
|
37
|
+
|
|
38
|
+
const isLegacyValidationError = (error) => error?.name === 'ElectroValidationError'
|
|
39
|
+
|| isNonEmptyArray(error?.fields);
|
|
31
40
|
|
|
32
41
|
function isValidParent(parent, child) {
|
|
33
42
|
if (!hasText(parent.entityName)) {
|
|
@@ -35,44 +44,21 @@ function isValidParent(parent, child) {
|
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
const foreignKey = `${parent.entityName}Id`;
|
|
38
|
-
|
|
39
47
|
return child.record?.[foreignKey] === parent.record?.[foreignKey];
|
|
40
48
|
}
|
|
41
49
|
|
|
42
|
-
/**
|
|
43
|
-
* BaseCollection - A base class for managing collections of entities in the application.
|
|
44
|
-
* This class uses ElectroDB to interact with entities and provides common functionality
|
|
45
|
-
* for data operations.
|
|
46
|
-
*
|
|
47
|
-
* @class BaseCollection
|
|
48
|
-
* @abstract
|
|
49
|
-
*/
|
|
50
50
|
class BaseCollection {
|
|
51
|
-
/**
|
|
52
|
-
* The collection name for this collection. Must be overridden by subclasses.
|
|
53
|
-
* This ensures the collection name is explicit and not dependent on class names
|
|
54
|
-
* which can be mangled by bundlers.
|
|
55
|
-
* @type {string}
|
|
56
|
-
*/
|
|
57
51
|
static COLLECTION_NAME = undefined;
|
|
58
52
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
* @constructor
|
|
69
|
-
* @param {Object} electroService - The ElectroDB service used for managing entities.
|
|
70
|
-
* @param {Object} entityRegistry - The registry holding entities, their schema and collection.
|
|
71
|
-
* @param {Object} schema - The schema for the entity.
|
|
72
|
-
* @param {Object} log - A log for capturing logging information.
|
|
73
|
-
*/
|
|
74
|
-
constructor(electroService, entityRegistry, schema, log) {
|
|
75
|
-
this.electroService = electroService;
|
|
53
|
+
static DATASTORE_TYPE = DATASTORE_TYPE.POSTGREST;
|
|
54
|
+
|
|
55
|
+
constructor(postgrestService, entityRegistry, schema, log) {
|
|
56
|
+
if (!postgrestService) {
|
|
57
|
+
throw new DataAccessError('postgrestService is required');
|
|
58
|
+
}
|
|
59
|
+
this.postgrestService = postgrestService;
|
|
60
|
+
// legacy alias for existing tests and callers
|
|
61
|
+
this.electroService = postgrestService;
|
|
76
62
|
this.entityRegistry = entityRegistry;
|
|
77
63
|
this.schema = schema;
|
|
78
64
|
this.log = log;
|
|
@@ -80,11 +66,44 @@ class BaseCollection {
|
|
|
80
66
|
this.clazz = this.schema.getModelClass();
|
|
81
67
|
this.entityName = this.schema.getEntityName();
|
|
82
68
|
this.idName = this.schema.getIdName();
|
|
83
|
-
this.
|
|
69
|
+
this.tableName = entityToTableName(this.schema.getModelName());
|
|
70
|
+
this.fieldMaps = createFieldMaps(this.schema);
|
|
71
|
+
this.entity = postgrestService?.entities?.[this.entityName];
|
|
84
72
|
|
|
85
73
|
this.#initializeCollectionMethods();
|
|
86
74
|
}
|
|
87
75
|
|
|
76
|
+
// eslint-disable-next-line class-methods-use-this
|
|
77
|
+
#resolveBulkKeyField(keys) {
|
|
78
|
+
if (!isNonEmptyArray(keys)) return null;
|
|
79
|
+
|
|
80
|
+
const [firstKey] = keys;
|
|
81
|
+
const fields = Object.keys(firstKey);
|
|
82
|
+
if (fields.length !== 1) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const [field] = fields;
|
|
87
|
+
const isSingleFieldAcrossAll = keys.every((key) => {
|
|
88
|
+
const keyFields = Object.keys(key);
|
|
89
|
+
return keyFields.length === 1 && keyFields[0] === field && key[field] !== undefined;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return isSingleFieldAcrossAll ? field : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// eslint-disable-next-line class-methods-use-this
|
|
96
|
+
#isInvalidInputError(error) {
|
|
97
|
+
let current = error;
|
|
98
|
+
while (current) {
|
|
99
|
+
if (current?.code === '22P02') {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
current = current.cause;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
88
107
|
#logAndThrowError(message, cause) {
|
|
89
108
|
const error = new DataAccessError(message, this, cause);
|
|
90
109
|
this.log.error(`Base Collection Error [${this.entityName}]`, error);
|
|
@@ -94,77 +113,37 @@ class BaseCollection {
|
|
|
94
113
|
throw error;
|
|
95
114
|
}
|
|
96
115
|
|
|
97
|
-
/**
|
|
98
|
-
* Initialize collection methods for each "by..." index defined in the entity schema.
|
|
99
|
-
* For each index that starts with "by", we:
|
|
100
|
-
* 1. Retrieve its composite pk and sk arrays from the schema.
|
|
101
|
-
* 2. Generate convenience methods for every prefix of the composite keys.
|
|
102
|
-
* For example, if the index keys are ['opportunityId', 'status', 'createdAt'],
|
|
103
|
-
* we create methods:
|
|
104
|
-
* - allByOpportunityId(...) / findByOpportunityId(...)
|
|
105
|
-
* - allByOpportunityIdAndStatus(...) / findByOpportunityIdAndStatus(...)
|
|
106
|
-
* - allByOpportunityIdAndStatusAndCreatedAt(...) /
|
|
107
|
-
* findByOpportunityIdAndStatusAndCreatedAt(...)
|
|
108
|
-
*
|
|
109
|
-
* Each generated method calls allByIndexKeys() or findByIndexKeys() with the appropriate keys.
|
|
110
|
-
*
|
|
111
|
-
* @private
|
|
112
|
-
*/
|
|
113
116
|
#initializeCollectionMethods() {
|
|
114
117
|
const accessorConfigs = this.schema.toAccessorConfigs(this, this.log);
|
|
115
118
|
createAccessors(accessorConfigs, this.log);
|
|
116
119
|
}
|
|
117
120
|
|
|
118
|
-
/**
|
|
119
|
-
* Creates an instance of a model from a record.
|
|
120
|
-
* @private
|
|
121
|
-
* @param {Object} record - The record containing data to create the model instance.
|
|
122
|
-
* @returns {BaseModel|null} - Returns an instance of the model class if the data is valid,
|
|
123
|
-
* otherwise null.
|
|
124
|
-
*/
|
|
125
121
|
#createInstance(record) {
|
|
126
122
|
if (!isNonEmptyObject(record)) {
|
|
127
123
|
this.log.warn(`Failed to create instance of [${this.entityName}]: record is empty`);
|
|
128
124
|
return null;
|
|
129
125
|
}
|
|
126
|
+
const hydratedRecord = this.#applyGetters(this.#applyReadDefaults(record));
|
|
130
127
|
// eslint-disable-next-line new-cap
|
|
131
128
|
return new this.clazz(
|
|
132
|
-
this.
|
|
129
|
+
this.postgrestService,
|
|
133
130
|
this.entityRegistry,
|
|
134
131
|
this.schema,
|
|
135
|
-
|
|
132
|
+
hydratedRecord,
|
|
136
133
|
this.log,
|
|
137
134
|
);
|
|
138
135
|
}
|
|
139
136
|
|
|
140
|
-
/**
|
|
141
|
-
* Creates instances of models from a set of records.
|
|
142
|
-
* @private
|
|
143
|
-
* @param {Object} records - The records containing data to create the model instances.
|
|
144
|
-
* @returns {Array<BaseModel>} - An array of instances of the model class.
|
|
145
|
-
*/
|
|
146
137
|
#createInstances(records) {
|
|
147
|
-
return records
|
|
138
|
+
return records
|
|
139
|
+
.map((record) => this.#createInstance(record))
|
|
140
|
+
.filter((instance) => instance !== null);
|
|
148
141
|
}
|
|
149
142
|
|
|
150
|
-
/**
|
|
151
|
-
* Clears the accessor cache for the entity. This method is called when the entity is
|
|
152
|
-
* updated or removed to ensure that the cache is invalidated.
|
|
153
|
-
* @private
|
|
154
|
-
*/
|
|
155
143
|
#invalidateCache() {
|
|
156
144
|
this._accessorCache = {};
|
|
157
145
|
}
|
|
158
146
|
|
|
159
|
-
/**
|
|
160
|
-
* Internal on-create handler. This method is called after the create method has successfully
|
|
161
|
-
* created an entity. It will call the on-create handler defined in the subclass and handles
|
|
162
|
-
* any errors that occur.
|
|
163
|
-
* @param {BaseModel} item - The created entity.
|
|
164
|
-
* @return {Promise<void>}
|
|
165
|
-
* @async
|
|
166
|
-
* @private
|
|
167
|
-
*/
|
|
168
147
|
async #onCreate(item) {
|
|
169
148
|
try {
|
|
170
149
|
await this._onCreate(item);
|
|
@@ -173,16 +152,6 @@ class BaseCollection {
|
|
|
173
152
|
}
|
|
174
153
|
}
|
|
175
154
|
|
|
176
|
-
/**
|
|
177
|
-
* Internal on-create-many handler. This method is called after the createMany method has
|
|
178
|
-
* successfully created entities. It will call the on-create-many handler defined in the
|
|
179
|
-
* subclass and handles any errors that occur.
|
|
180
|
-
* @param {Array<BaseModel>} createdItems - The created entities.
|
|
181
|
-
* @param {{ item: Object, error: ValidationError }[]} errorItems - Items that failed validation.
|
|
182
|
-
* @return {Promise<void>}
|
|
183
|
-
* @async
|
|
184
|
-
* @private
|
|
185
|
-
*/
|
|
186
155
|
async #onCreateMany({ createdItems, errorItems }) {
|
|
187
156
|
try {
|
|
188
157
|
await this._onCreateMany({ createdItems, errorItems });
|
|
@@ -191,278 +160,506 @@ class BaseCollection {
|
|
|
191
160
|
}
|
|
192
161
|
}
|
|
193
162
|
|
|
194
|
-
/**
|
|
195
|
-
* Handler for the create method. This method is
|
|
196
|
-
* called after the create method has successfully created an entity.
|
|
197
|
-
* @param {BaseModel} item - The created entity.
|
|
198
|
-
* @return {Promise<void>}
|
|
199
|
-
* @async
|
|
200
|
-
* @protected
|
|
201
|
-
*/
|
|
202
163
|
// eslint-disable-next-line class-methods-use-this,no-unused-vars
|
|
203
164
|
async _onCreate(item) {
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Handler for the createMany method. This method is
|
|
209
|
-
* called after the createMany method has successfully created entities.
|
|
210
|
-
* @param {Array<BaseModel>} createdItems - The created entities.
|
|
211
|
-
* @param {{ item: Object, error: ValidationError }[]} errorItems - Items that failed validation.
|
|
212
|
-
* @return {Promise<void>}
|
|
213
|
-
* @async
|
|
214
|
-
* @protected
|
|
215
|
-
*/
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
216
168
|
// eslint-disable-next-line class-methods-use-this,no-unused-vars
|
|
217
169
|
async _onCreateMany({ createdItems, errorItems }) {
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (!isNonEmptyObject(keys)) {
|
|
237
|
-
return this.#logAndThrowError(`Failed to query [${this.entityName}]: keys are required`);
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#toDbField(field) {
|
|
174
|
+
return toDbField(field, this.fieldMaps.toDbMap);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#toDbRecord(record) {
|
|
178
|
+
return toDbRecord(record, this.fieldMaps.toDbMap);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#toModelRecord(record) {
|
|
182
|
+
return fromDbRecord(record, this.fieldMaps.toModelMap);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#buildSelect(attributes) {
|
|
186
|
+
if (!isNonEmptyArray(attributes)) {
|
|
187
|
+
return '*';
|
|
238
188
|
}
|
|
189
|
+
return attributes.map((field) => this.#toDbField(field)).join(',');
|
|
190
|
+
}
|
|
239
191
|
|
|
240
|
-
|
|
241
|
-
|
|
192
|
+
#getOrderFields(indexName, keys) {
|
|
193
|
+
if (hasText(indexName)) {
|
|
194
|
+
const indexKeys = this.schema.getIndexKeys(indexName);
|
|
195
|
+
if (isNonEmptyArray(indexKeys)) {
|
|
196
|
+
return indexKeys.map((key) => this.#toDbField(key));
|
|
197
|
+
}
|
|
242
198
|
}
|
|
243
199
|
|
|
244
|
-
const
|
|
245
|
-
const
|
|
200
|
+
const keyNames = Object.keys(keys);
|
|
201
|
+
const defaultSortField = isNonEmptyArray(keyNames)
|
|
202
|
+
? keyNames[keyNames.length - 1]
|
|
203
|
+
: 'updatedAt';
|
|
204
|
+
return [this.#toDbField(defaultSortField)];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#applyDefaults(record) {
|
|
208
|
+
const nextRecord = { ...record };
|
|
209
|
+
const attributes = this.schema.getAttributes();
|
|
210
|
+
Object.entries(attributes).forEach(([name, attribute]) => {
|
|
211
|
+
if (nextRecord[name] !== undefined || attribute.default === undefined) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
nextRecord[name] = typeof attribute.default === 'function'
|
|
216
|
+
? attribute.default()
|
|
217
|
+
: attribute.default;
|
|
218
|
+
});
|
|
219
|
+
return nextRecord;
|
|
220
|
+
}
|
|
246
221
|
|
|
247
|
-
|
|
248
|
-
|
|
222
|
+
applyUpdateWatchers(record, updates) {
|
|
223
|
+
const nextRecord = { ...record };
|
|
224
|
+
const nextUpdates = { ...updates };
|
|
225
|
+
const changedKeys = Object.keys(updates);
|
|
226
|
+
if (changedKeys.length === 0) {
|
|
227
|
+
return { record: nextRecord, updates: nextUpdates };
|
|
249
228
|
}
|
|
250
229
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
230
|
+
const attributes = this.schema.getAttributes();
|
|
231
|
+
Object.entries(attributes).forEach(([name, attribute]) => {
|
|
232
|
+
if (typeof attribute.set !== 'function') {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const { watch } = attribute;
|
|
237
|
+
const shouldApply = watch === '*'
|
|
238
|
+
|| (Array.isArray(watch) && watch.some((key) => changedKeys.includes(key)));
|
|
259
239
|
|
|
260
|
-
|
|
240
|
+
if (!shouldApply) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const value = attribute.set(nextRecord[name], nextRecord);
|
|
245
|
+
let resolvedValue = value;
|
|
246
|
+
if (name === 'updatedAt' && typeof nextRecord[name] === 'string') {
|
|
247
|
+
const previous = new Date(nextRecord[name]);
|
|
248
|
+
const candidate = new Date(resolvedValue);
|
|
249
|
+
if (!Number.isNaN(previous.getTime())
|
|
250
|
+
&& !Number.isNaN(candidate.getTime())
|
|
251
|
+
&& candidate.getTime() <= previous.getTime()) {
|
|
252
|
+
resolvedValue = new Date(previous.getTime() + 1000).toISOString();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
nextRecord[name] = resolvedValue;
|
|
256
|
+
nextUpdates[name] = resolvedValue;
|
|
257
|
+
});
|
|
258
|
+
return { record: nextRecord, updates: nextUpdates };
|
|
259
|
+
}
|
|
261
260
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
261
|
+
#applySetters(record) {
|
|
262
|
+
const nextRecord = { ...record };
|
|
263
|
+
const attributes = this.schema.getAttributes();
|
|
264
|
+
Object.entries(attributes).forEach(([name, attribute]) => {
|
|
265
|
+
if (typeof attribute.set !== 'function') {
|
|
266
|
+
return;
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
269
|
+
const value = attribute.set(nextRecord[name], nextRecord);
|
|
270
|
+
if (value !== undefined) {
|
|
271
|
+
nextRecord[name] = value;
|
|
272
272
|
}
|
|
273
|
+
});
|
|
274
|
+
return nextRecord;
|
|
275
|
+
}
|
|
273
276
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
+
#applyReadDefaults(record) {
|
|
278
|
+
const nextRecord = { ...record };
|
|
279
|
+
const attributes = this.schema.getAttributes();
|
|
280
|
+
Object.entries(attributes).forEach(([name, attribute]) => {
|
|
281
|
+
if (nextRecord[name] !== undefined || attribute.default === undefined) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Only hydrate defaults for fields intentionally excluded from PostgREST writes.
|
|
286
|
+
// This preserves projection behavior for normal selected attributes.
|
|
287
|
+
if (!attribute.postgrestIgnore) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
nextRecord[name] = typeof attribute.default === 'function'
|
|
292
|
+
? attribute.default()
|
|
293
|
+
: attribute.default;
|
|
294
|
+
});
|
|
295
|
+
return nextRecord;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#applyGetters(record) {
|
|
299
|
+
const nextRecord = { ...record };
|
|
300
|
+
const attributes = this.schema.getAttributes();
|
|
301
|
+
Object.entries(attributes).forEach(([name, attribute]) => {
|
|
302
|
+
if (typeof attribute.get !== 'function') {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (nextRecord[name] === undefined) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
nextRecord[name] = attribute.get(nextRecord[name], nextRecord);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
this.log.warn(`Failed to apply getter for ${name} on [${this.entityName}]`, error);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
return nextRecord;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#validateItem(item) {
|
|
320
|
+
const attributes = this.schema.getAttributes();
|
|
321
|
+
const errors = [];
|
|
322
|
+
|
|
323
|
+
Object.entries(attributes).forEach(([name, attribute]) => {
|
|
324
|
+
const value = item[name];
|
|
325
|
+
|
|
326
|
+
if (attribute.required && (value === undefined || value === null)) {
|
|
327
|
+
errors.push(`${name} is required`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (value === undefined || value === null) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (Array.isArray(attribute.type) && !attribute.type.includes(value)) {
|
|
336
|
+
errors.push(`${name} is invalid`);
|
|
337
|
+
} else if (attribute.type === 'string' && typeof value !== 'string') {
|
|
338
|
+
errors.push(`${name} must be a string`);
|
|
339
|
+
} else if (attribute.type === 'number' && typeof value !== 'number') {
|
|
340
|
+
errors.push(`${name} must be a number`);
|
|
341
|
+
} else if (attribute.type === 'boolean' && typeof value !== 'boolean') {
|
|
342
|
+
errors.push(`${name} must be a boolean`);
|
|
343
|
+
} else if (attribute.type === 'list' && !Array.isArray(value)) {
|
|
344
|
+
errors.push(`${name} must be a list`);
|
|
345
|
+
} else if (attribute.type === 'map' && !isObject(value)) {
|
|
346
|
+
errors.push(`${name} must be a map`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (typeof attribute.validate === 'function') {
|
|
350
|
+
try {
|
|
351
|
+
const result = attribute.validate(value, item);
|
|
352
|
+
if (result === false) {
|
|
353
|
+
errors.push(`${name} failed validation`);
|
|
354
|
+
}
|
|
355
|
+
} catch (e) {
|
|
356
|
+
errors.push(e?.message || `${name} failed validation`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (errors.length > 0) {
|
|
362
|
+
throw new ValidationError(errors.join(', '), this);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
#prepareItem(item) {
|
|
367
|
+
let prepared = { ...item };
|
|
368
|
+
prepared = this.#applyDefaults(prepared);
|
|
369
|
+
prepared = this.#applySetters(prepared);
|
|
370
|
+
this.#validateItem(prepared);
|
|
371
|
+
return prepared;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#applyKeyFilters(query, keys) {
|
|
375
|
+
if (!isNonEmptyObject(keys)) {
|
|
376
|
+
return query;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let filtered = query;
|
|
380
|
+
Object.entries(keys).forEach(([key, value]) => {
|
|
381
|
+
filtered = filtered.eq(this.#toDbField(key), value);
|
|
382
|
+
});
|
|
383
|
+
return filtered;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async #queryPage({
|
|
387
|
+
keys,
|
|
388
|
+
options,
|
|
389
|
+
offset,
|
|
390
|
+
limit,
|
|
391
|
+
}) {
|
|
392
|
+
const select = this.#buildSelect(options.attributes);
|
|
393
|
+
const indexName = options.index || this.schema.findIndexNameByKeys(keys);
|
|
394
|
+
const index = this.schema.getIndexByName(indexName);
|
|
395
|
+
if (options.index && !index) {
|
|
396
|
+
this.#logAndThrowError(`Failed to query [${this.entityName}]: query proxy [${options.index}] not found`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const orderFields = this.#getOrderFields(indexName, keys);
|
|
400
|
+
const ascending = options.order === 'asc';
|
|
401
|
+
let query = this.postgrestService
|
|
402
|
+
.from(this.tableName)
|
|
403
|
+
.select(select);
|
|
404
|
+
|
|
405
|
+
orderFields.forEach((field) => {
|
|
406
|
+
query = query.order(field, { ascending });
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const mappedIdField = this.fieldMaps?.toDbMap?.[this.idName];
|
|
410
|
+
if (hasText(mappedIdField) && !orderFields.includes(mappedIdField)) {
|
|
411
|
+
query = query.order(mappedIdField, { ascending });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
query = this.#applyKeyFilters(query, keys);
|
|
415
|
+
if (isObject(options.between)) {
|
|
416
|
+
const betweenField = this.#toDbField(options.between.attribute);
|
|
417
|
+
query = query.gte(betweenField, options.between.start).lte(betweenField, options.between.end);
|
|
418
|
+
}
|
|
419
|
+
query = applyWhere(query, options.where, this.fieldMaps.toDbMap);
|
|
420
|
+
|
|
421
|
+
if (Number.isInteger(limit)) {
|
|
422
|
+
query = query.range(offset, offset + limit - 1);
|
|
423
|
+
} else {
|
|
424
|
+
query = query.range(offset, offset + DEFAULT_PAGE_SIZE - 1);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const { data, error } = await query;
|
|
428
|
+
if (error) {
|
|
429
|
+
this.#logAndThrowError('Failed to query', error);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return (data || []).map((record) => this.#toModelRecord(record));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async #queryByIndexKeys(keys, options = {}) {
|
|
436
|
+
if (this.entity && !isNonEmptyObject(keys)) {
|
|
437
|
+
return this.#logAndThrowError(`Failed to query [${this.entityName}]: keys are required`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!isObject(options)) {
|
|
441
|
+
return this.#logAndThrowError(`Failed to query [${this.entityName}]: options must be an object`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
if (this.entity) {
|
|
446
|
+
const indexName = options.index || this.schema.findIndexNameByKeys(keys);
|
|
447
|
+
const index = this.entity.query[indexName];
|
|
448
|
+
if (!index) {
|
|
449
|
+
this.#logAndThrowError(`Failed to query [${this.entityName}]: query proxy [${indexName}] not found`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const queryOptions = {
|
|
453
|
+
order: options.order || 'desc',
|
|
454
|
+
...options.limit && { limit: options.limit },
|
|
455
|
+
...options.attributes && { attributes: options.attributes },
|
|
456
|
+
...options.cursor && { cursor: options.cursor },
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
let query = index(keys);
|
|
460
|
+
if (isObject(options.between)) {
|
|
461
|
+
query = query.between(
|
|
462
|
+
{ [options.between.attribute]: options.between.start },
|
|
463
|
+
{ [options.between.attribute]: options.between.end },
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
if (typeof options.where === 'function') {
|
|
467
|
+
query = query.where(options.where);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let result = await query.go(queryOptions);
|
|
471
|
+
let allData = result.data;
|
|
472
|
+
const shouldFetchAllPages = options.fetchAllPages === true
|
|
473
|
+
|| (options.fetchAllPages !== false && !options.limit);
|
|
474
|
+
if (shouldFetchAllPages) {
|
|
475
|
+
while (result.cursor) {
|
|
476
|
+
queryOptions.cursor = result.cursor;
|
|
477
|
+
// eslint-disable-next-line no-await-in-loop
|
|
478
|
+
result = await query.go(queryOptions);
|
|
479
|
+
allData = allData.concat(result.data);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (options.limit === 1) {
|
|
484
|
+
return allData.length ? this.#createInstance(allData[0]) : null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const instances = this.#createInstances(allData);
|
|
488
|
+
return options.returnCursor
|
|
489
|
+
? { data: instances, cursor: result.cursor || null }
|
|
490
|
+
: instances;
|
|
491
|
+
}
|
|
277
492
|
|
|
278
|
-
// Smart pagination behavior:
|
|
279
|
-
// - fetchAllPages: true → Always paginate through all results
|
|
280
|
-
// - fetchAllPages: false → Only fetch first page
|
|
281
|
-
// - undefined → Auto-paginate when no limit specified, respect limits otherwise
|
|
282
493
|
const shouldFetchAllPages = options.fetchAllPages === true
|
|
283
494
|
|| (options.fetchAllPages !== false && !options.limit);
|
|
495
|
+
const shouldReturnCursor = options.returnCursor === true;
|
|
496
|
+
const limit = Number.isInteger(options.limit) ? options.limit : undefined;
|
|
497
|
+
|
|
498
|
+
let offset = decodeCursor(options.cursor);
|
|
499
|
+
let allRows = [];
|
|
500
|
+
let cursor = null;
|
|
284
501
|
|
|
285
502
|
if (shouldFetchAllPages) {
|
|
286
|
-
|
|
287
|
-
|
|
503
|
+
const pageSize = limit || DEFAULT_PAGE_SIZE;
|
|
504
|
+
let keepGoing = true;
|
|
505
|
+
|
|
506
|
+
while (keepGoing) {
|
|
288
507
|
// eslint-disable-next-line no-await-in-loop
|
|
289
|
-
|
|
290
|
-
|
|
508
|
+
const pageRows = await this.#queryPage({
|
|
509
|
+
keys,
|
|
510
|
+
options,
|
|
511
|
+
offset,
|
|
512
|
+
limit: pageSize,
|
|
513
|
+
});
|
|
514
|
+
allRows = allRows.concat(pageRows);
|
|
515
|
+
if (pageRows.length < pageSize) {
|
|
516
|
+
keepGoing = false;
|
|
517
|
+
cursor = null;
|
|
518
|
+
} else {
|
|
519
|
+
offset += pageSize;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
} else {
|
|
523
|
+
const pageRows = await this.#queryPage({
|
|
524
|
+
keys,
|
|
525
|
+
options,
|
|
526
|
+
offset,
|
|
527
|
+
limit,
|
|
528
|
+
});
|
|
529
|
+
allRows = pageRows;
|
|
530
|
+
if (limit && pageRows.length === limit) {
|
|
531
|
+
cursor = encodeCursor(offset + limit);
|
|
291
532
|
}
|
|
292
533
|
}
|
|
293
534
|
|
|
294
|
-
// Return cursor when explicitly requested via returnCursor option
|
|
295
|
-
const shouldReturnCursor = options.returnCursor === true;
|
|
296
|
-
|
|
297
535
|
if (options.limit === 1) {
|
|
298
|
-
return
|
|
299
|
-
} else {
|
|
300
|
-
const instances = this.#createInstances(allData);
|
|
301
|
-
/* c8 ignore next 2 */
|
|
302
|
-
return shouldReturnCursor
|
|
303
|
-
? { data: instances, cursor: result.cursor || null }
|
|
304
|
-
: instances;
|
|
536
|
+
return allRows.length ? this.#createInstance(allRows[0]) : null;
|
|
305
537
|
}
|
|
538
|
+
|
|
539
|
+
const instances = this.#createInstances(allRows);
|
|
540
|
+
return shouldReturnCursor ? { data: instances, cursor } : instances;
|
|
306
541
|
} catch (error) {
|
|
542
|
+
if (error instanceof DataAccessError) {
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
307
545
|
return this.#logAndThrowError('Failed to query', error);
|
|
308
546
|
}
|
|
309
547
|
}
|
|
310
548
|
|
|
311
|
-
/**
|
|
312
|
-
* Finds all entities in the collection. Requires an index named "all" with a partition key
|
|
313
|
-
* named "pk" with a static value of "ALL_<ENTITYNAME>".
|
|
314
|
-
* @async
|
|
315
|
-
* @param {Object} [sortKeys] - The sort keys to use for the query.
|
|
316
|
-
* @param {Object} [options] - Additional options for the query.
|
|
317
|
-
* @return {Promise<BaseModel|Array<BaseModel>|null>}
|
|
318
|
-
*/
|
|
319
549
|
async all(sortKeys = {}, options = {}) {
|
|
320
|
-
const keys =
|
|
550
|
+
const keys = this.entity
|
|
551
|
+
? { pk: entityNameToAllPKValue(this.entityName), ...sortKeys }
|
|
552
|
+
: sortKeys;
|
|
321
553
|
return this.#queryByIndexKeys(keys, options);
|
|
322
554
|
}
|
|
323
555
|
|
|
324
|
-
/**
|
|
325
|
-
* Finds entities by a set of index keys. Index keys are used to query entities by
|
|
326
|
-
* a specific index defined in the entity schema. The index keys must match the
|
|
327
|
-
* fields defined in the index.
|
|
328
|
-
* @param {Object} keys - The index keys to use for the query.
|
|
329
|
-
* @param {{index?: string, attributes?: string[]}} [options] - Additional options for the query.
|
|
330
|
-
* @return {Promise<Array<BaseModel>>} - A promise that resolves to an array of model instances.
|
|
331
|
-
* @throws {Error} - Throws an error if the index keys are not provided or if the index
|
|
332
|
-
* is not found.
|
|
333
|
-
* @async
|
|
334
|
-
*/
|
|
335
556
|
async allByIndexKeys(keys, options = {}) {
|
|
336
557
|
return this.#queryByIndexKeys(keys, options);
|
|
337
558
|
}
|
|
338
559
|
|
|
339
|
-
/**
|
|
340
|
-
* Finds a single entity from the "all" index. Requires an "all" index to be added to the
|
|
341
|
-
* entity schema via the schema builder.
|
|
342
|
-
* @async
|
|
343
|
-
* @param {Object} [sortKeys] - The sort keys to use for the query.
|
|
344
|
-
* @param {QueryOptions} [options] - Additional options for the query.
|
|
345
|
-
* Additional options for the query.
|
|
346
|
-
* @return {Promise<BaseModel|null>}
|
|
347
|
-
* @throws {DataAccessError} - Throws an error if the sort keys are not provided.
|
|
348
|
-
*/
|
|
349
560
|
async findByAll(sortKeys = {}, options = {}) {
|
|
350
561
|
if (!isObject(sortKeys)) {
|
|
351
562
|
const message = `Failed to find by all [${this.entityName}]: sort keys must be an object`;
|
|
352
563
|
this.log.error(message);
|
|
353
564
|
throw new DataAccessError(message);
|
|
354
565
|
}
|
|
355
|
-
|
|
356
|
-
|
|
566
|
+
const keys = this.entity
|
|
567
|
+
? { pk: entityNameToAllPKValue(this.entityName), ...sortKeys }
|
|
568
|
+
: sortKeys;
|
|
357
569
|
return this.#queryByIndexKeys(keys, { ...options, limit: 1 });
|
|
358
570
|
}
|
|
359
571
|
|
|
360
|
-
/**
|
|
361
|
-
* Finds an entity by its ID. This will only work if the entity's schema
|
|
362
|
-
* did not override the main table primary key via schema builder.
|
|
363
|
-
* @async
|
|
364
|
-
* @param {string} id - The unique identifier of the entity to be found.
|
|
365
|
-
* @returns {Promise<BaseModel|null>} - A promise that resolves to an instance of
|
|
366
|
-
* the model if found, otherwise null.
|
|
367
|
-
* @throws {ValidationError} - Throws an error if the ID is not provided.
|
|
368
|
-
*/
|
|
369
572
|
async findById(id) {
|
|
370
573
|
guardId(this.idName, id, this.entityName);
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
574
|
+
if (this.entity) {
|
|
575
|
+
const record = await this.entity.get({ [this.idName]: id }).go();
|
|
576
|
+
return this.#createInstance(record?.data);
|
|
577
|
+
}
|
|
578
|
+
return this.findByIndexKeys({ [this.idName]: id });
|
|
375
579
|
}
|
|
376
580
|
|
|
377
|
-
/**
|
|
378
|
-
* Checks if an entity exists by its ID.
|
|
379
|
-
* @param {string} id - The UUID of the entity to check.
|
|
380
|
-
* @return {Promise<boolean>} - A promise that resolves to true if the entity exists,
|
|
381
|
-
* otherwise false.
|
|
382
|
-
* @throws {ValidationError} - Throws an error if the ID is not provided.
|
|
383
|
-
*/
|
|
384
581
|
async existsById(id) {
|
|
385
582
|
guardId(this.idName, id, this.entityName);
|
|
583
|
+
if (this.entity) {
|
|
584
|
+
const record = await this.entity.get({ [this.idName]: id }).go({
|
|
585
|
+
attributes: [this.idName],
|
|
586
|
+
});
|
|
587
|
+
return isNonEmptyObject(record?.data);
|
|
588
|
+
}
|
|
589
|
+
const item = await this.findByIndexKeys(
|
|
590
|
+
{ [this.idName]: id },
|
|
591
|
+
{ attributes: [this.idName] },
|
|
592
|
+
);
|
|
593
|
+
return isNonEmptyObject(item);
|
|
594
|
+
}
|
|
386
595
|
|
|
387
|
-
const record = await this.entity.get({ [this.idName]: id }).go({
|
|
388
|
-
attributes: [this.idName],
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
return isNonEmptyObject(record?.data);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Retrieves multiple entities by their IDs in a single batch operation.
|
|
396
|
-
* This method is more efficient than calling findById multiple times.
|
|
397
|
-
*
|
|
398
|
-
* @async
|
|
399
|
-
* @param {Array<string>} ids - An array of entity IDs to retrieve.
|
|
400
|
-
* @param {{attributes?: string[]}} [options] - Additional options for the query.
|
|
401
|
-
* @returns {Promise<{data: Array<BaseModel>, unprocessed: Array<string>}>} - A promise that
|
|
402
|
-
* resolves
|
|
403
|
-
* to an object containing:
|
|
404
|
-
* - data: Array of found model instances
|
|
405
|
-
* - unprocessed: Array of IDs that couldn't be processed (due to throttling, etc.)
|
|
406
|
-
* @throws {DataAccessError} - Throws an error if the IDs are not provided or if the batch
|
|
407
|
-
* operation fails.
|
|
408
|
-
*/
|
|
409
596
|
async batchGetByKeys(keys, options = {}) {
|
|
410
597
|
guardArray('keys', keys, this.entityName, 'any');
|
|
411
598
|
|
|
412
599
|
try {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
600
|
+
if (this.entity) {
|
|
601
|
+
const goOptions = {};
|
|
602
|
+
if (options.attributes !== undefined) {
|
|
603
|
+
goOptions.attributes = options.attributes;
|
|
604
|
+
}
|
|
605
|
+
const result = await this.entity.get(keys).go(goOptions);
|
|
606
|
+
const data = result.data
|
|
607
|
+
.map((record) => this.#createInstance(record))
|
|
608
|
+
.filter((entity) => entity !== null);
|
|
609
|
+
const unprocessed = result.unprocessed
|
|
610
|
+
? result.unprocessed.map((item) => item)
|
|
611
|
+
: [];
|
|
612
|
+
return { data, unprocessed };
|
|
418
613
|
}
|
|
419
614
|
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
615
|
+
const bulkKeyField = this.#resolveBulkKeyField(keys);
|
|
616
|
+
if (bulkKeyField) {
|
|
617
|
+
const dbField = this.#toDbField(bulkKeyField);
|
|
618
|
+
const values = keys.map((key) => key[bulkKeyField]);
|
|
619
|
+
const select = this.#buildSelect(options.attributes);
|
|
620
|
+
const { data, error } = await this.postgrestService
|
|
621
|
+
.from(this.tableName)
|
|
622
|
+
.select(select)
|
|
623
|
+
.in(dbField, values);
|
|
624
|
+
|
|
625
|
+
if (!error) {
|
|
626
|
+
return {
|
|
627
|
+
data: this.#createInstances((data || []).map((record) => this.#toModelRecord(record))),
|
|
628
|
+
unprocessed: [],
|
|
629
|
+
};
|
|
630
|
+
}
|
|
428
631
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
: [];
|
|
632
|
+
if (!this.#isInvalidInputError(error)) {
|
|
633
|
+
throw error;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
434
636
|
|
|
435
|
-
|
|
637
|
+
const records = await Promise.all(
|
|
638
|
+
keys.map(async (key) => {
|
|
639
|
+
try {
|
|
640
|
+
return await this.findByIndexKeys(key, options);
|
|
641
|
+
} catch (error) {
|
|
642
|
+
if (this.#isInvalidInputError(error)) {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
throw error;
|
|
646
|
+
}
|
|
647
|
+
}),
|
|
648
|
+
);
|
|
649
|
+
return {
|
|
650
|
+
data: records.filter((record) => record !== null),
|
|
651
|
+
unprocessed: [],
|
|
652
|
+
};
|
|
436
653
|
} catch (error) {
|
|
437
654
|
this.log.error(`Failed to batch get by keys [${this.entityName}]`, error);
|
|
438
655
|
throw new DataAccessError('Failed to batch get by keys', this, error);
|
|
439
656
|
}
|
|
440
657
|
}
|
|
441
658
|
|
|
442
|
-
/**
|
|
443
|
-
* Finds a single entity by index keys.
|
|
444
|
-
* @param {Object} keys - The index keys to use for the query.
|
|
445
|
-
* @param {{index?: string, attributes?: string[]}} [options] - Additional options for the query.
|
|
446
|
-
* @returns {Promise<BaseModel|null>} - A promise that resolves to the model instance or null.
|
|
447
|
-
* @throws {DataAccessError} - Throws an error if retrieving the entity fails.
|
|
448
|
-
* @async
|
|
449
|
-
*/
|
|
450
659
|
async findByIndexKeys(keys, options = {}) {
|
|
451
660
|
return this.#queryByIndexKeys(keys, { ...options, limit: 1 });
|
|
452
661
|
}
|
|
453
662
|
|
|
454
|
-
/**
|
|
455
|
-
* Creates a new entity in the collection and directly persists it to the database.
|
|
456
|
-
* There is no need to call the save method (which is for updates only) after creating
|
|
457
|
-
* the entity.
|
|
458
|
-
* @async
|
|
459
|
-
* @param {Object} item - The data for the entity to be created.
|
|
460
|
-
* @param {Object} [options] - Additional options for the creation process.
|
|
461
|
-
* @param {boolean} [options.upsert=false] - Whether to perform an upsert operation.
|
|
462
|
-
* @returns {Promise<BaseModel>} - A promise that resolves to the created model instance.
|
|
463
|
-
* @throws {DataAccessError} - Throws an error if the data is invalid or if the
|
|
464
|
-
* creation process fails.
|
|
465
|
-
*/
|
|
466
663
|
async create(item, { upsert = false } = {}) {
|
|
467
664
|
if (!isNonEmptyObject(item)) {
|
|
468
665
|
const message = `Failed to create [${this.entityName}]: data is required`;
|
|
@@ -471,59 +668,37 @@ class BaseCollection {
|
|
|
471
668
|
}
|
|
472
669
|
|
|
473
670
|
try {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
671
|
+
if (this.entity) {
|
|
672
|
+
const record = upsert
|
|
673
|
+
? await this.entity.put(item).go()
|
|
674
|
+
: await this.entity.create(item).go();
|
|
675
|
+
const instance = this.#createInstance(record.data);
|
|
676
|
+
this.#invalidateCache();
|
|
677
|
+
await this.#onCreate(instance);
|
|
678
|
+
return instance;
|
|
679
|
+
}
|
|
477
680
|
|
|
478
|
-
const
|
|
681
|
+
const prepared = this.#prepareItem(item);
|
|
682
|
+
const payload = this.#toDbRecord(prepared);
|
|
683
|
+
const conflictKey = this.#toDbField(this.idName);
|
|
479
684
|
|
|
480
|
-
this
|
|
685
|
+
let query = this.postgrestService.from(this.tableName);
|
|
686
|
+
query = upsert ? query.upsert(payload, { onConflict: conflictKey }) : query.insert(payload);
|
|
687
|
+
const { data, error } = await query.select().maybeSingle();
|
|
481
688
|
|
|
482
|
-
|
|
689
|
+
if (error) {
|
|
690
|
+
return this.#logAndThrowError('Failed to create', error);
|
|
691
|
+
}
|
|
483
692
|
|
|
693
|
+
const instance = this.#createInstance(this.#toModelRecord(data));
|
|
694
|
+
this.#invalidateCache();
|
|
695
|
+
await this.#onCreate(instance);
|
|
484
696
|
return instance;
|
|
485
697
|
} catch (error) {
|
|
486
698
|
return this.#logAndThrowError('Failed to create', error);
|
|
487
699
|
}
|
|
488
700
|
}
|
|
489
701
|
|
|
490
|
-
/**
|
|
491
|
-
* Validates and batches items for batch operations.
|
|
492
|
-
* @private
|
|
493
|
-
* @param {Array<Object>} items - Items to be validated.
|
|
494
|
-
* @returns {Object} - An object containing validated items and error items.
|
|
495
|
-
*/
|
|
496
|
-
#validateItems(items) {
|
|
497
|
-
const validatedItems = [];
|
|
498
|
-
const errorItems = [];
|
|
499
|
-
|
|
500
|
-
items.forEach((item) => {
|
|
501
|
-
try {
|
|
502
|
-
const { Item } = this.entity.put(item).params();
|
|
503
|
-
validatedItems.push({ ...removeElectroProperties(Item), ...item });
|
|
504
|
-
} catch (error) {
|
|
505
|
-
if (error instanceof ElectroValidationError) {
|
|
506
|
-
errorItems.push({ item, error: new ValidationError('Validation error', this, error) });
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
return { validatedItems, errorItems };
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/**
|
|
515
|
-
* Creates multiple entities in the collection and directly persists them to the database in
|
|
516
|
-
* a batch write operation. Batches are written in parallel and are limited to 25 items per batch.
|
|
517
|
-
*
|
|
518
|
-
* @async
|
|
519
|
-
* @param {Array<Object>} newItems - An array of data for the entities to be created.
|
|
520
|
-
* @param {BaseModel} [parent] - Optional parent entity that these items are associated with.
|
|
521
|
-
* @return {Promise<{ createdItems: BaseModel[],
|
|
522
|
-
* errorItems: { item: Object, error: ValidationError }[] }>} - A promise that resolves to
|
|
523
|
-
* an object containing the created items and any items that failed validation.
|
|
524
|
-
* @throws {DataAccessError} - Throws an error if the items are not provided or if the
|
|
525
|
-
* creation process fails.
|
|
526
|
-
*/
|
|
527
702
|
async createMany(newItems, parent = null) {
|
|
528
703
|
if (!isNonEmptyArray(newItems)) {
|
|
529
704
|
const message = `Failed to create many [${this.entityName}]: items must be a non-empty array`;
|
|
@@ -532,18 +707,91 @@ class BaseCollection {
|
|
|
532
707
|
}
|
|
533
708
|
|
|
534
709
|
try {
|
|
535
|
-
|
|
710
|
+
if (this.entity) {
|
|
711
|
+
const validatedItems = [];
|
|
712
|
+
const errorItems = [];
|
|
713
|
+
newItems.forEach((item) => {
|
|
714
|
+
try {
|
|
715
|
+
const { Item } = this.entity.put(item).params();
|
|
716
|
+
validatedItems.push({ ...removeElectroProperties(Item), ...item });
|
|
717
|
+
} catch (error) {
|
|
718
|
+
if (isLegacyValidationError(error)) {
|
|
719
|
+
errorItems.push({ item, error: new ValidationError('Validation error', this, error) });
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
if (validatedItems.length > 0) {
|
|
725
|
+
const response = await this.entity.put(validatedItems).go();
|
|
726
|
+
if (isNonEmptyArray(response?.unprocessed)) {
|
|
727
|
+
this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const createdItems = this.#createInstances(validatedItems);
|
|
732
|
+
if (isNonEmptyObject(parent)) {
|
|
733
|
+
createdItems.forEach((record) => {
|
|
734
|
+
if (!isValidParent(parent, record)) {
|
|
735
|
+
this.log.warn(`Failed to associate parent with child [${this.entityName}]: parent is invalid`);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
// eslint-disable-next-line no-underscore-dangle,no-param-reassign
|
|
739
|
+
record._accessorCache[`get${parent.schema.getModelName()}`] = parent;
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
this.#invalidateCache();
|
|
743
|
+
await this.#onCreateMany({ createdItems, errorItems });
|
|
744
|
+
return { createdItems, errorItems };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const validatedItems = [];
|
|
748
|
+
const errorItems = [];
|
|
749
|
+
|
|
750
|
+
newItems.forEach((item) => {
|
|
751
|
+
try {
|
|
752
|
+
validatedItems.push(this.#prepareItem(item));
|
|
753
|
+
} catch (error) {
|
|
754
|
+
if (error instanceof ValidationError) {
|
|
755
|
+
errorItems.push({ item, error });
|
|
756
|
+
} else {
|
|
757
|
+
throw error;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
});
|
|
536
761
|
|
|
537
762
|
if (validatedItems.length > 0) {
|
|
538
|
-
const
|
|
763
|
+
const payload = validatedItems.map((item) => this.#toDbRecord(item));
|
|
764
|
+
const { data, error } = await this.postgrestService
|
|
765
|
+
.from(this.tableName)
|
|
766
|
+
.insert(payload)
|
|
767
|
+
.select();
|
|
768
|
+
|
|
769
|
+
if (error) {
|
|
770
|
+
return this.#logAndThrowError('Failed to create many', error);
|
|
771
|
+
}
|
|
539
772
|
|
|
540
|
-
if (isNonEmptyArray(
|
|
541
|
-
|
|
773
|
+
if (isNonEmptyArray(data)) {
|
|
774
|
+
const createdItems = this.#createInstances(
|
|
775
|
+
data.map((record) => this.#toModelRecord(record)),
|
|
776
|
+
);
|
|
777
|
+
if (isNonEmptyObject(parent)) {
|
|
778
|
+
createdItems.forEach((record) => {
|
|
779
|
+
if (!isValidParent(parent, record)) {
|
|
780
|
+
this.log.warn(`Failed to associate parent with child [${this.entityName}]: parent is invalid`);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
// eslint-disable-next-line no-underscore-dangle,no-param-reassign
|
|
784
|
+
record._accessorCache[`get${parent.schema.getModelName()}`] = parent;
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
this.#invalidateCache();
|
|
789
|
+
await this.#onCreateMany({ createdItems, errorItems });
|
|
790
|
+
return { createdItems, errorItems };
|
|
542
791
|
}
|
|
543
792
|
}
|
|
544
793
|
|
|
545
794
|
const createdItems = this.#createInstances(validatedItems);
|
|
546
|
-
|
|
547
795
|
if (isNonEmptyObject(parent)) {
|
|
548
796
|
createdItems.forEach((record) => {
|
|
549
797
|
if (!isValidParent(parent, record)) {
|
|
@@ -556,26 +804,36 @@ class BaseCollection {
|
|
|
556
804
|
}
|
|
557
805
|
|
|
558
806
|
this.#invalidateCache();
|
|
559
|
-
|
|
560
807
|
await this.#onCreateMany({ createdItems, errorItems });
|
|
561
|
-
|
|
562
808
|
return { createdItems, errorItems };
|
|
563
809
|
} catch (error) {
|
|
564
810
|
return this.#logAndThrowError('Failed to create many', error);
|
|
565
811
|
}
|
|
566
812
|
}
|
|
567
813
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
814
|
+
async updateByKeys(keys, updates) {
|
|
815
|
+
if (!isNonEmptyObject(keys) || !isNonEmptyObject(updates)) {
|
|
816
|
+
throw new DataAccessError(`Failed to update [${this.entityName}]: keys and updates are required`);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (this.entity) {
|
|
820
|
+
const patch = this.entity.patch(keys);
|
|
821
|
+
Object.entries(updates).forEach(([key, value]) => {
|
|
822
|
+
patch.set({ [key]: value });
|
|
823
|
+
});
|
|
824
|
+
await patch.go();
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
let query = this.postgrestService.from(this.tableName).update(this.#toDbRecord(updates));
|
|
829
|
+
query = this.#applyKeyFilters(query, keys);
|
|
830
|
+
|
|
831
|
+
const { error } = await query.select().maybeSingle();
|
|
832
|
+
if (error) {
|
|
833
|
+
throw new DataAccessError('Failed to update entity', this, error);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
579
837
|
async _saveMany(items) {
|
|
580
838
|
if (!isNonEmptyArray(items)) {
|
|
581
839
|
const message = `Failed to save many [${this.entityName}]: items must be a non-empty array`;
|
|
@@ -584,33 +842,59 @@ class BaseCollection {
|
|
|
584
842
|
}
|
|
585
843
|
|
|
586
844
|
try {
|
|
587
|
-
|
|
588
|
-
|
|
845
|
+
if (this.entity) {
|
|
846
|
+
const updates = items.map((item) => item.record);
|
|
847
|
+
const response = await this.entity.put(updates).go();
|
|
848
|
+
const now = new Date().toISOString();
|
|
849
|
+
items.forEach((item) => {
|
|
850
|
+
const { record } = item;
|
|
851
|
+
record.updatedAt = now;
|
|
852
|
+
});
|
|
853
|
+
if (isNonEmptyArray(response.unprocessed)) {
|
|
854
|
+
this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`);
|
|
855
|
+
}
|
|
856
|
+
this.#invalidateCache();
|
|
857
|
+
return undefined;
|
|
858
|
+
}
|
|
589
859
|
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
860
|
+
const primaryKeyFields = this.schema.getIndexKeys('primary');
|
|
861
|
+
const conflictFields = (isNonEmptyArray(primaryKeyFields) ? primaryKeyFields : [this.idName])
|
|
862
|
+
.map((field) => this.#toDbField(field))
|
|
863
|
+
.join(',');
|
|
864
|
+
const preparedItems = items.map((item) => {
|
|
865
|
+
const keys = item.generateCompositeKeys
|
|
866
|
+
? item.generateCompositeKeys()
|
|
867
|
+
: { [this.idName]: item.getId() };
|
|
868
|
+
const { record, updates } = this.applyUpdateWatchers(item.record, item.record);
|
|
869
|
+
return {
|
|
870
|
+
model: item,
|
|
871
|
+
record,
|
|
872
|
+
updates,
|
|
873
|
+
keys,
|
|
874
|
+
};
|
|
594
875
|
});
|
|
876
|
+
for (const preparedItem of preparedItems) {
|
|
877
|
+
// Keep in-memory model state in sync with persisted values (e.g. watched updatedAt).
|
|
878
|
+
preparedItem.model.record = preparedItem.record;
|
|
879
|
+
}
|
|
880
|
+
const payload = preparedItems
|
|
881
|
+
.map((preparedItem) => this.#toDbRecord({ ...preparedItem.updates, ...preparedItem.keys }));
|
|
595
882
|
|
|
596
|
-
|
|
597
|
-
|
|
883
|
+
const { error } = await this.postgrestService
|
|
884
|
+
.from(this.tableName)
|
|
885
|
+
.upsert(payload, { onConflict: conflictFields });
|
|
886
|
+
|
|
887
|
+
if (error) {
|
|
888
|
+
return this.#logAndThrowError('Failed to save many', error);
|
|
598
889
|
}
|
|
599
890
|
|
|
600
|
-
|
|
891
|
+
this.#invalidateCache();
|
|
892
|
+
return undefined;
|
|
601
893
|
} catch (error) {
|
|
602
894
|
return this.#logAndThrowError('Failed to save many', error);
|
|
603
895
|
}
|
|
604
896
|
}
|
|
605
897
|
|
|
606
|
-
/**
|
|
607
|
-
* Removes all records of this entity based on the provided IDs. This will perform a batch
|
|
608
|
-
* delete operation. This operation does not remove dependent records.
|
|
609
|
-
* @param {Array<string>} ids - An array of IDs to remove.
|
|
610
|
-
* @return {Promise<void>} - A promise that resolves when the removal operation is complete.
|
|
611
|
-
* @throws {DataAccessError} - Throws an error if the IDs are not provided or if the
|
|
612
|
-
* removal operation fails.
|
|
613
|
-
*/
|
|
614
898
|
async removeByIds(ids) {
|
|
615
899
|
if (!isNonEmptyArray(ids)) {
|
|
616
900
|
const message = `Failed to remove [${this.entityName}]: ids must be a non-empty array`;
|
|
@@ -619,37 +903,28 @@ class BaseCollection {
|
|
|
619
903
|
}
|
|
620
904
|
|
|
621
905
|
try {
|
|
622
|
-
|
|
906
|
+
if (this.entity) {
|
|
907
|
+
await this.entity.delete(ids.map((id) => ({ [this.idName]: id }))).go();
|
|
908
|
+
this.#invalidateCache();
|
|
909
|
+
return undefined;
|
|
910
|
+
}
|
|
623
911
|
|
|
624
|
-
|
|
912
|
+
const { error } = await this.postgrestService
|
|
913
|
+
.from(this.tableName)
|
|
914
|
+
.delete()
|
|
915
|
+
.in(this.#toDbField(this.idName), ids);
|
|
625
916
|
|
|
626
|
-
|
|
917
|
+
if (error) {
|
|
918
|
+
return this.#logAndThrowError('Failed to remove by IDs', error);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
this.#invalidateCache();
|
|
922
|
+
return undefined;
|
|
627
923
|
} catch (error) {
|
|
628
924
|
return this.#logAndThrowError('Failed to remove by IDs', error);
|
|
629
925
|
}
|
|
630
926
|
}
|
|
631
927
|
|
|
632
|
-
/**
|
|
633
|
-
* Removes records from the collection using an array of key objects for batch deletion.
|
|
634
|
-
* This method is particularly useful for junction tables in many-to-many relationships
|
|
635
|
-
* where you need to remove multiple records based on their composite keys.
|
|
636
|
-
*
|
|
637
|
-
* Each key object in the array represents a record to be deleted, identified by its
|
|
638
|
-
* key attributes (typically partition key + sort key combinations).
|
|
639
|
-
*
|
|
640
|
-
* @async
|
|
641
|
-
* @param {Array<Object>} keys - Array of key objects to match for deletion.
|
|
642
|
-
* Each object should contain the key attributes that uniquely identify a record.
|
|
643
|
-
* @returns {Promise<void>} A promise that resolves when the deletion is complete.
|
|
644
|
-
* The method also invalidates the cache after successful deletion.
|
|
645
|
-
* @throws {DataAccessError} Throws an error if:
|
|
646
|
-
* - The keys parameter is not a non-empty array
|
|
647
|
-
* - Any key object in the array is empty or invalid
|
|
648
|
-
* - The database operation fails
|
|
649
|
-
*
|
|
650
|
-
* @since 2.64.1
|
|
651
|
-
* @memberof BaseCollection
|
|
652
|
-
*/
|
|
653
928
|
async removeByIndexKeys(keys) {
|
|
654
929
|
if (!isNonEmptyArray(keys)) {
|
|
655
930
|
const message = `Failed to remove by index keys [${this.entityName}]: keys must be a non-empty array`;
|
|
@@ -666,9 +941,38 @@ class BaseCollection {
|
|
|
666
941
|
});
|
|
667
942
|
|
|
668
943
|
try {
|
|
669
|
-
|
|
944
|
+
if (this.entity) {
|
|
945
|
+
await this.entity.delete(keys).go();
|
|
946
|
+
this.log.info(`Removed ${keys.length} items for [${this.entityName}]`);
|
|
947
|
+
this.#invalidateCache();
|
|
948
|
+
return undefined;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const bulkKeyField = this.#resolveBulkKeyField(keys);
|
|
952
|
+
if (bulkKeyField) {
|
|
953
|
+
const dbField = this.#toDbField(bulkKeyField);
|
|
954
|
+
const values = keys.map((key) => key[bulkKeyField]);
|
|
955
|
+
const { error } = await this.postgrestService
|
|
956
|
+
.from(this.tableName)
|
|
957
|
+
.delete()
|
|
958
|
+
.in(dbField, values);
|
|
959
|
+
if (error) {
|
|
960
|
+
throw error;
|
|
961
|
+
}
|
|
962
|
+
} else {
|
|
963
|
+
await Promise.all(keys.map(async (key) => {
|
|
964
|
+
let query = this.postgrestService.from(this.tableName).delete();
|
|
965
|
+
query = this.#applyKeyFilters(query, key);
|
|
966
|
+
const { error } = await query;
|
|
967
|
+
if (error) {
|
|
968
|
+
throw error;
|
|
969
|
+
}
|
|
970
|
+
}));
|
|
971
|
+
}
|
|
972
|
+
|
|
670
973
|
this.log.info(`Removed ${keys.length} items for [${this.entityName}]`);
|
|
671
|
-
|
|
974
|
+
this.#invalidateCache();
|
|
975
|
+
return undefined;
|
|
672
976
|
} catch (error) {
|
|
673
977
|
return this.#logAndThrowError('Failed to remove by index keys', error);
|
|
674
978
|
}
|