@adobe/spacecat-shared-data-access 2.109.0 → 3.0.1

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.
@@ -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
- entityNameToAllPKValue,
28
- removeElectroProperties,
29
- } from '../../util/util.js';
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
- * The datastore type for this collection. Defaults to DYNAMO.
61
- * Override in subclasses to use a different datastore (e.g., S3).
62
- * @type {string}
63
- */
64
- static DATASTORE_TYPE = DATASTORE_TYPE.DYNAMO;
65
-
66
- /**
67
- * Constructs an instance of BaseCollection.
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.entity = electroService.entities[this.entityName];
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.electroService,
129
+ this.postgrestService,
133
130
  this.entityRegistry,
134
131
  this.schema,
135
- record,
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.map((record) => this.#createInstance(record));
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
- // no-op
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
- // no-op
219
- }
220
-
221
- /**
222
- * General method to query entities by index keys. This method is used by other
223
- * query methods to perform the actual query operation. It will use the index keys
224
- * to find the appropriate index and query the entities. The query result will be
225
- * transformed into model instances.
226
- *
227
- * @private
228
- * @async
229
- * @param {Object} keys - The index keys to use for the query.
230
- * @param {Object} options - Additional options for the query.
231
- * @returns {Promise<BaseModel|Array<BaseModel>|null>} - The query result.
232
- * @throws {DataAccessError} - Throws an error if the keys are not provided,
233
- * if options are invalid or if the query operation fails.
234
- */
235
- async #queryByIndexKeys(keys, options = {}) {
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
- if (!isObject(options)) {
241
- return this.#logAndThrowError(`Failed to query [${this.entityName}]: options must be an object`);
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 indexName = options.index || this.schema.findIndexNameByKeys(keys);
245
- const index = this.entity.query[indexName];
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
- if (!index) {
248
- this.#logAndThrowError(`Failed to query [${this.entityName}]: query proxy [${indexName}] not found`);
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
- try {
252
- const queryOptions = {
253
- order: options.order || 'desc',
254
- ...options.limit && { limit: options.limit },
255
- ...options.attributes && { attributes: options.attributes },
256
- /* c8 ignore next */
257
- ...options.cursor && { cursor: options.cursor },
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
- let query = index(keys);
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
- if (isObject(options.between)) {
263
- query = query.between(
264
- { [options.between.attribute]: options.between.start },
265
- { [options.between.attribute]: options.between.end },
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
- // Apply where clause (FilterExpression) if provided
270
- if (typeof options.where === 'function') {
271
- query = query.where(options.where);
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
- // execute the initial query
275
- let result = await query.go(queryOptions);
276
- let allData = result.data;
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
- while (result.cursor) {
287
- queryOptions.cursor = result.cursor;
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
- result = await query.go(queryOptions);
290
- allData = allData.concat(result.data);
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 allData.length ? this.#createInstance(allData[0]) : null;
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 = { pk: entityNameToAllPKValue(this.entityName), ...sortKeys };
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
- const keys = { pk: entityNameToAllPKValue(this.entityName), ...sortKeys };
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
- const record = await this.entity.get({ [this.idName]: id }).go();
373
-
374
- return this.#createInstance(record?.data);
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
- const goOptions = {};
414
-
415
- // Add attributes if specified
416
- if (options.attributes !== undefined) {
417
- goOptions.attributes = options.attributes;
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 result = await this.entity.get(
421
- keys,
422
- ).go(goOptions);
423
-
424
- // Process found entities
425
- const data = result.data
426
- .map((record) => this.#createInstance(record))
427
- .filter((entity) => entity !== null);
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
- // Extract unprocessed keys
430
- /* c8 ignore next 3 */
431
- const unprocessed = result.unprocessed
432
- ? result.unprocessed.map((item) => item)
433
- : [];
632
+ if (!this.#isInvalidInputError(error)) {
633
+ throw error;
634
+ }
635
+ }
434
636
 
435
- return { data, unprocessed };
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
- const record = upsert
475
- ? await this.entity.put(item).go()
476
- : await this.entity.create(item).go();
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 instance = this.#createInstance(record.data);
681
+ const prepared = this.#prepareItem(item);
682
+ const payload = this.#toDbRecord(prepared);
683
+ const conflictKey = this.#toDbField(this.idName);
479
684
 
480
- this.#invalidateCache();
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
- await this.#onCreate(instance);
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
- const { validatedItems, errorItems } = this.#validateItems(newItems);
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 response = await this.entity.put(validatedItems).go();
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(response?.unprocessed)) {
541
- this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`);
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
- * Updates a collection of entities in the database using a batch write (put) operation.
570
- *
571
- * @async
572
- * @param {Array<BaseModel>} items - An array of model instances to be updated.
573
- * @return {Promise<void>} - A promise that resolves when the update operation is complete.
574
- * @throws {DataAccessError} - Throws an error if the items are not provided or if the
575
- * update operation fails.
576
- *
577
- * @protected
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
- const updates = items.map((item) => item.record);
588
- const response = await this.entity.put(updates).go();
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 now = new Date().toISOString();
591
- items.forEach((item) => {
592
- const { record } = item;
593
- record.updatedAt = now;
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
- if (isNonEmptyArray(response.unprocessed)) {
597
- this.log.error(`Failed to process all items in batch write for [${this.entityName}]: ${JSON.stringify(response.unprocessed)}`);
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
- return this.#invalidateCache();
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
- // todo: consider removing dependent records
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
- await this.entity.delete(ids.map((id) => ({ [this.idName]: id }))).go();
912
+ const { error } = await this.postgrestService
913
+ .from(this.tableName)
914
+ .delete()
915
+ .in(this.#toDbField(this.idName), ids);
625
916
 
626
- return this.#invalidateCache();
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
- await this.entity.delete(keys).go();
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
- return this.#invalidateCache();
974
+ this.#invalidateCache();
975
+ return undefined;
672
976
  } catch (error) {
673
977
  return this.#logAndThrowError('Failed to remove by index keys', error);
674
978
  }