@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +260 -245
  3. package/docker-compose.test.yml +39 -0
  4. package/package.json +5 -6
  5. package/src/index.js +16 -5
  6. package/src/models/audit/audit.collection.js +6 -20
  7. package/src/models/base/base.collection.js +699 -395
  8. package/src/models/base/base.model.js +13 -6
  9. package/src/models/base/entity.registry.js +25 -5
  10. package/src/models/base/schema.builder.js +2 -0
  11. package/src/models/base/schema.js +15 -4
  12. package/src/models/consumer/consumer.collection.js +149 -0
  13. package/src/models/consumer/consumer.model.js +61 -0
  14. package/src/models/consumer/consumer.schema.js +65 -0
  15. package/src/models/consumer/index.d.ts +39 -0
  16. package/src/models/consumer/index.js +19 -0
  17. package/src/models/fix-entity-suggestion/fix-entity-suggestion.model.js +15 -0
  18. package/src/models/fix-entity-suggestion/fix-entity-suggestion.schema.js +12 -0
  19. package/src/models/index.d.ts +1 -0
  20. package/src/models/index.js +1 -0
  21. package/src/models/key-event/key-event.collection.js +23 -1
  22. package/src/models/latest-audit/latest-audit.collection.js +77 -3
  23. package/src/models/scrape-job/scrape-job.collection.js +38 -0
  24. package/src/models/scrape-job/scrape-job.schema.js +4 -2
  25. package/src/models/site/site.schema.js +1 -0
  26. package/src/models/site-enrollment/site-enrollment.collection.js +11 -1
  27. package/src/models/trial-user/trial-user.collection.js +0 -2
  28. package/src/models/trial-user/trial-user.schema.js +5 -1
  29. package/src/service/index.d.ts +7 -1
  30. package/src/service/index.js +33 -52
  31. package/src/util/index.js +2 -1
  32. package/src/util/logger-registry.js +12 -0
  33. package/src/util/patcher.js +64 -112
  34. package/src/util/postgrest.utils.js +203 -0
  35. package/tsconfig.json +24 -0
@@ -10,8 +10,10 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import BaseCollection from '../base/base.collection.js';
13
+ import { isNonEmptyArray } from '@adobe/spacecat-shared-utils';
14
+ import DataAccessError from '../../errors/data-access.error.js';
14
15
  import { guardId, guardString } from '../../util/index.js';
16
+ import BaseCollection from '../base/base.collection.js';
15
17
 
16
18
  /**
17
19
  * LatestAuditCollection - A collection class responsible for managing LatestAudit entities.
@@ -23,8 +25,80 @@ import { guardId, guardString } from '../../util/index.js';
23
25
  class LatestAuditCollection extends BaseCollection {
24
26
  static COLLECTION_NAME = 'LatestAuditCollection';
25
27
 
26
- async create(item) {
27
- return super.create(item, { upsert: true });
28
+ // LatestAudit is a virtual view in v3; writes are not supported.
29
+ // eslint-disable-next-line class-methods-use-this
30
+ async create() {
31
+ throw new DataAccessError('LatestAudit is derived from Audit in v3 and cannot be created directly', this);
32
+ }
33
+
34
+ // eslint-disable-next-line class-methods-use-this
35
+ async createMany() {
36
+ throw new DataAccessError('LatestAudit is derived from Audit in v3 and cannot be created directly', this);
37
+ }
38
+
39
+ static #groupLatest(items, groupFields) {
40
+ const grouped = new Map();
41
+ items.forEach((item) => {
42
+ const key = groupFields.map((field) => item.record[field]).join('#');
43
+ const existing = grouped.get(key);
44
+ if (!existing || existing.getAuditedAt() < item.getAuditedAt()) {
45
+ grouped.set(key, item);
46
+ }
47
+ });
48
+ return [...grouped.values()];
49
+ }
50
+
51
+ async #allAuditsByKeys(keys, options = {}) {
52
+ const auditCollection = this.entityRegistry.getCollection('AuditCollection');
53
+ return auditCollection.allByIndexKeys(keys, {
54
+ ...options,
55
+ fetchAllPages: true,
56
+ order: 'desc',
57
+ returnCursor: false,
58
+ });
59
+ }
60
+
61
+ async all(sortKeys = {}, options = {}) {
62
+ return this.allByIndexKeys(sortKeys, options);
63
+ }
64
+
65
+ async findByAll(sortKeys = {}, options = {}) {
66
+ return this.findByIndexKeys(sortKeys, options);
67
+ }
68
+
69
+ async findByIndexKeys(keys, options = {}) {
70
+ const auditCollection = this.entityRegistry.getCollection('AuditCollection');
71
+
72
+ if (keys.siteId && keys.auditType) {
73
+ return auditCollection.findByIndexKeys(keys, { ...options, order: 'desc' });
74
+ }
75
+
76
+ // For single-key lookups, we only need the latest row, not all pages.
77
+ return auditCollection.findByIndexKeys(keys, { ...options, order: 'desc' });
78
+ }
79
+
80
+ async allByIndexKeys(keys, options = {}) {
81
+ const audits = await this.#allAuditsByKeys(keys, options);
82
+ if (!isNonEmptyArray(audits)) {
83
+ return options.returnCursor ? { data: [], cursor: null } : [];
84
+ }
85
+
86
+ let groupFields = ['siteId', 'auditType'];
87
+ if (keys.siteId && !keys.auditType) {
88
+ groupFields = ['auditType'];
89
+ } else if (!keys.siteId && keys.auditType) {
90
+ groupFields = ['siteId'];
91
+ }
92
+
93
+ const latest = LatestAuditCollection.#groupLatest(audits, groupFields);
94
+ // Preserve v2 behavior: default order is descending (most recent first).
95
+ const ascending = options.order === 'asc';
96
+ latest.sort((a, b) => (ascending
97
+ ? a.getAuditedAt().localeCompare(b.getAuditedAt())
98
+ : b.getAuditedAt().localeCompare(a.getAuditedAt())));
99
+ const limited = Number.isInteger(options.limit) ? latest.slice(0, options.limit) : latest;
100
+
101
+ return options.returnCursor ? { data: limited, cursor: null } : limited;
28
102
  }
29
103
 
30
104
  async allByAuditType(auditType) {
@@ -42,6 +42,44 @@ class ScrapeJobCollection extends BaseCollection {
42
42
  },
43
43
  });
44
44
  }
45
+
46
+ async allByBaseURLAndProcessingTypeAndOptEnableJavascriptAndOptHideConsentBanner(
47
+ baseURL,
48
+ processingType,
49
+ optEnableJavascript,
50
+ optHideConsentBanner,
51
+ options = {},
52
+ ) {
53
+ const keys = {
54
+ baseURL,
55
+ processingType,
56
+ optEnableJavascript,
57
+ optHideConsentBanner,
58
+ };
59
+
60
+ return this.allByIndexKeys(keys, options);
61
+ }
62
+
63
+ async findByBaseURLAndProcessingTypeAndOptEnableJavascriptAndOptHideConsentBanner(
64
+ baseURL,
65
+ processingType,
66
+ optEnableJavascript,
67
+ optHideConsentBanner,
68
+ options = {},
69
+ ) {
70
+ const jobs = await this
71
+ .allByBaseURLAndProcessingTypeAndOptEnableJavascriptAndOptHideConsentBanner(
72
+ baseURL,
73
+ processingType,
74
+ optEnableJavascript,
75
+ optHideConsentBanner,
76
+ { ...options, limit: options.limit || 1 },
77
+ );
78
+ if (Array.isArray(jobs)) {
79
+ return jobs[0] || null;
80
+ }
81
+ return jobs || null;
82
+ }
45
83
  }
46
84
 
47
85
  export default ScrapeJobCollection;
@@ -130,17 +130,19 @@ const schema = new SchemaBuilder(ScrapeJob, ScrapeJobCollection)
130
130
  })
131
131
  .addAttribute('optEnableJavascript', {
132
132
  type: 'string',
133
+ postgrestIgnore: true,
133
134
  hidden: true,
134
135
  readOnly: true,
135
136
  watch: ['options'],
136
- set: (_, { options }) => (options[ScrapeJob.ScrapeOptions.ENABLE_JAVASCRIPT] ? 'T' : 'F'),
137
+ set: (_, { options }) => (options?.[ScrapeJob.ScrapeOptions.ENABLE_JAVASCRIPT] ? 'T' : 'F'),
137
138
  })
138
139
  .addAttribute('optHideConsentBanner', {
139
140
  type: 'string',
141
+ postgrestIgnore: true,
140
142
  hidden: true,
141
143
  readOnly: true,
142
144
  watch: ['options'],
143
- set: (_, { options }) => (options[ScrapeJob.ScrapeOptions.HIDE_CONSENT_BANNER] ? 'T' : 'F'),
145
+ set: (_, { options }) => (options?.[ScrapeJob.ScrapeOptions.HIDE_CONSENT_BANNER] ? 'T' : 'F'),
144
146
  })
145
147
  // access pattern: get all jobs sorted by startedAt
146
148
  .addAllIndex(['startedAt'])
@@ -102,6 +102,7 @@ const schema = new SchemaBuilder(Site, SiteCollection)
102
102
  })
103
103
  .addAttribute('gitHubURL', {
104
104
  type: 'string',
105
+ postgrestField: 'github_url',
105
106
  validate: (value) => !value || isValidUrl(value),
106
107
  })
107
108
  .addAttribute('deliveryConfig', {
@@ -22,7 +22,17 @@ import BaseCollection from '../base/base.collection.js';
22
22
  class SiteEnrollmentCollection extends BaseCollection {
23
23
  static COLLECTION_NAME = 'SiteEnrollmentCollection';
24
24
 
25
- // add your custom collection methods here
25
+ async create(item, options = {}) {
26
+ if (item?.siteId && item?.entitlementId) {
27
+ const existing = await this.findByIndexKeys({
28
+ siteId: item.siteId,
29
+ entitlementId: item.entitlementId,
30
+ });
31
+ if (existing) return existing;
32
+ }
33
+
34
+ return super.create(item, options);
35
+ }
26
36
  }
27
37
 
28
38
  export default SiteEnrollmentCollection;
@@ -21,8 +21,6 @@ import BaseCollection from '../base/base.collection.js';
21
21
  */
22
22
  class TrialUserCollection extends BaseCollection {
23
23
  static COLLECTION_NAME = 'TrialUserCollection';
24
-
25
- // add custom methods here
26
24
  }
27
25
 
28
26
  export default TrialUserCollection;
@@ -54,6 +54,10 @@ const schema = new SchemaBuilder(TrialUser, TrialUserCollection)
54
54
  type: 'any',
55
55
  validate: (value) => !value || isObject(value),
56
56
  })
57
- .addAllIndex(['emailId']);
57
+ .addAllIndex(['emailId'])
58
+ .addIndex(
59
+ { composite: ['organizationId'] },
60
+ { composite: ['updatedAt'] },
61
+ );
58
62
 
59
63
  export default schema.build();
@@ -11,10 +11,16 @@
11
11
  */
12
12
 
13
13
  interface DataAccessConfig {
14
- tableNameData: string;
14
+ postgrestUrl: string;
15
+ postgrestSchema?: string;
16
+ postgrestApiKey?: string;
17
+ postgrestHeaders?: object;
18
+ s3Bucket?: string;
19
+ region?: string;
15
20
  }
16
21
 
17
22
  export function createDataAccess(
18
23
  config: DataAccessConfig,
19
24
  logger: object,
25
+ client?: object,
20
26
  ): object;
@@ -10,10 +10,8 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import { DynamoDB } from '@aws-sdk/client-dynamodb';
14
- import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb';
15
13
  import { S3Client } from '@aws-sdk/client-s3';
16
- import { Service } from 'electrodb';
14
+ import { PostgrestClient } from '@supabase/postgrest-js';
17
15
 
18
16
  import { instrumentAWSClient } from '@adobe/spacecat-shared-utils';
19
17
  import { EntityRegistry } from '../models/index.js';
@@ -23,47 +21,31 @@ export * from '../errors/index.js';
23
21
  export * from '../models/index.js';
24
22
  export * from '../util/index.js';
25
23
 
26
- let defaultDynamoDBClient;
27
- const documentClientCache = new WeakMap();
28
-
29
- const createRawClient = (client = undefined) => {
30
- const rawClient = client || (() => {
31
- if (!defaultDynamoDBClient) {
32
- defaultDynamoDBClient = new DynamoDB();
33
- }
34
- return defaultDynamoDBClient;
35
- })();
36
-
37
- let documentClient = documentClientCache.get(rawClient);
38
- if (!documentClient) {
39
- documentClient = DynamoDBDocument.from(instrumentAWSClient(rawClient), {
40
- marshallOptions: {
41
- convertEmptyValues: true,
42
- removeUndefinedValues: true,
43
- },
44
- });
45
- documentClientCache.set(rawClient, documentClient);
24
+ const createPostgrestService = (config, client = undefined) => {
25
+ if (client) {
26
+ return client;
46
27
  }
47
28
 
48
- return documentClient;
49
- };
29
+ const {
30
+ postgrestUrl,
31
+ postgrestSchema = 'public',
32
+ postgrestApiKey,
33
+ postgrestHeaders = {},
34
+ } = config;
35
+
36
+ if (!postgrestUrl) {
37
+ throw new Error('postgrestUrl is required to create data access');
38
+ }
50
39
 
51
- const createElectroService = (client, config, log) => {
52
- const { tableNameData: table } = config;
53
- /* c8 ignore start */
54
- const logger = (event) => {
55
- log.debug(JSON.stringify(event, null, 4));
40
+ const headers = {
41
+ ...postgrestHeaders,
42
+ ...postgrestApiKey ? { apikey: postgrestApiKey, Authorization: `Bearer ${postgrestApiKey}` } : {},
56
43
  };
57
- /* c8 ignore end */
58
-
59
- return new Service(
60
- EntityRegistry.getEntities(),
61
- {
62
- client,
63
- table,
64
- logger,
65
- },
66
- );
44
+
45
+ return new PostgrestClient(postgrestUrl, {
46
+ schema: postgrestSchema,
47
+ headers,
48
+ });
67
49
  };
68
50
 
69
51
  /**
@@ -91,31 +73,30 @@ const createS3Service = (config) => {
91
73
  * Creates a services dictionary containing all datastore services.
92
74
  * Each collection can declare which service it needs via its DATASTORE_TYPE.
93
75
  *
94
- * @param {object} electroService - The ElectroDB service for DynamoDB operations
76
+ * @param {PostgrestClient} postgrestService - PostgREST client
95
77
  * @param {object} config - Configuration object
96
- * @returns {object} Services dictionary with dynamo and s3 services
78
+ * @returns {object} Services dictionary with postgrest and s3 services
97
79
  */
98
- const createServices = (electroService, config) => ({
99
- dynamo: electroService,
80
+ const createServices = (postgrestService, config) => ({
81
+ postgrest: postgrestService,
100
82
  s3: createS3Service(config),
101
83
  });
102
84
 
103
85
  /**
104
- * Creates a data access layer for interacting with DynamoDB using ElectroDB.
86
+ * Creates a data access layer for interacting with Postgres via PostgREST.
105
87
  *
106
- * @param {{tableNameData: string, s3Bucket?: string, region?: string}} config - Configuration
107
- * object containing table name and optional S3 configuration
88
+ * @param {{postgrestUrl: string, postgrestSchema?: string, postgrestApiKey?: string,
89
+ * postgrestHeaders?: object, s3Bucket?: string, region?: string}} config - Configuration object
108
90
  * @param {object} log - Logger instance, defaults to console
109
- * @param {DynamoDB} [client] - Optional custom DynamoDB client instance
91
+ * @param {PostgrestClient} [client] - Optional custom Postgrest client instance
110
92
  * @returns {object} Data access collections for interacting with entities
111
93
  */
112
94
  export const createDataAccess = (config, log = console, client = undefined) => {
113
95
  registerLogger(log);
114
96
 
115
- const rawClient = createRawClient(client);
116
- const electroService = createElectroService(rawClient, config, log);
117
- const services = createServices(electroService, config);
118
- const entityRegistry = new EntityRegistry(services, log);
97
+ const postgrestService = createPostgrestService(config, client);
98
+ const services = createServices(postgrestService, config);
99
+ const entityRegistry = new EntityRegistry(services, config, log);
119
100
 
120
101
  return entityRegistry.getCollections();
121
102
  };
package/src/util/index.js CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  export {
26
26
  registerLogger,
27
27
  getLogger,
28
+ resetLoggerRegistry,
28
29
  } from './logger-registry.js';
29
30
 
30
31
  /**
@@ -33,6 +34,6 @@ export {
33
34
  * @enum {string}
34
35
  */
35
36
  export const DATASTORE_TYPE = Object.freeze({
36
- DYNAMO: 'dynamo',
37
+ POSTGREST: 'postgrest',
37
38
  S3: 's3',
38
39
  });
@@ -29,6 +29,10 @@ class LoggerRegistry {
29
29
  getLogger() {
30
30
  return this.#logger || console;
31
31
  }
32
+
33
+ reset() {
34
+ this.#logger = null;
35
+ }
32
36
  }
33
37
 
34
38
  /**
@@ -48,3 +52,11 @@ export function registerLogger(logger) {
48
52
  export function getLogger() {
49
53
  return LoggerRegistry.getInstance().getLogger();
50
54
  }
55
+
56
+ /**
57
+ * Resets the registered logger to default console logger.
58
+ * Primarily intended for test isolation.
59
+ */
60
+ export function resetLoggerRegistry() {
61
+ LoggerRegistry.getInstance().reset();
62
+ }
@@ -10,7 +10,7 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import { isNonEmptyArray, isObject } from '@adobe/spacecat-shared-utils';
13
+ import { isObject } from '@adobe/spacecat-shared-utils';
14
14
 
15
15
  import ValidationError from '../errors/validation.error.js';
16
16
 
@@ -26,13 +26,6 @@ import {
26
26
  guardString,
27
27
  } from './index.js';
28
28
 
29
- /**
30
- * Checks if a property is read-only and throws an error if it is.
31
- * @param {string} propertyName - The name of the property to check.
32
- * @param {Object} attribute - The attribute to check.
33
- * @throws {Error} - Throws an error if the property is read-only.
34
- * @private
35
- */
36
29
  const checkReadOnly = (propertyName, attribute) => {
37
30
  if (attribute.readOnly) {
38
31
  throw new ValidationError(`The property ${propertyName} is read-only and cannot be updated.`);
@@ -48,137 +41,68 @@ const checkUpdatesAllowed = (schema) => {
48
41
  class Patcher {
49
42
  /**
50
43
  * Creates a new Patcher instance for an entity.
51
- * @param {object} entity - The entity backing the record.
44
+ * @param {object} collection - The backing collection instance.
52
45
  * @param {Schema} schema - The schema for the entity.
53
46
  * @param {object} record - The record to patch.
54
47
  */
55
- constructor(entity, schema, record) {
56
- this.entity = entity;
48
+ constructor(collection, schema, record) {
49
+ this.collection = collection;
57
50
  this.schema = schema;
58
51
  this.record = record;
59
52
 
60
53
  this.entityName = schema.getEntityName();
61
- this.model = entity.model;
62
54
  this.idName = schema.getIdName();
63
55
 
64
- // holds the previous value of updated attributes
65
56
  this.previous = {};
66
-
67
- // holds the updates to the attributes
68
57
  this.updates = {};
69
58
 
59
+ this.legacyEntity = collection && typeof collection.patch === 'function'
60
+ ? collection
61
+ : null;
70
62
  this.patchRecord = null;
71
63
  }
72
64
 
73
- /**
74
- * Checks if a property is nullable.
75
- * @param {string} propertyName - The name of the property to check.
76
- * @return {boolean} True if the property is nullable, false otherwise.
77
- * @private
78
- */
79
65
  #isAttributeNullable(propertyName) {
80
- return !this.model.schema.attributes[propertyName]?.required;
81
- }
82
-
83
- /**
84
- * Composite keys have to be provided to ElectroDB in order to update a record across
85
- * multiple indexes. This method retrieves the composite values for the entity from
86
- * the schema indexes and filters out any values that are being updated.
87
- * @return {{}} - An object containing the composite values for the entity.
88
- * @private
89
- */
90
- #getCompositeValues() {
91
- const { indexes } = this.model;
92
- const result = {};
93
-
94
- const processComposite = (index, compositeType) => {
95
- const compositeArray = index[compositeType]?.facets;
96
- if (isNonEmptyArray(compositeArray)) {
97
- compositeArray.forEach((compositeKey) => {
98
- if (
99
- !Object.keys(this.updates).includes(compositeKey)
100
- && this.record[compositeKey] !== undefined
101
- ) {
102
- result[compositeKey] = this.record[compositeKey];
103
- }
104
- });
105
- }
106
- };
107
-
108
- Object.values(indexes).forEach((index) => {
109
- processComposite(index, 'pk');
110
- processComposite(index, 'sk');
111
- });
112
-
113
- return result;
66
+ return !this.schema.getAttribute(propertyName)?.required;
114
67
  }
115
68
 
116
- /**
117
- * Sets a property on the record and updates the patch record.
118
- * @param {string} attribute - The attribute to set.
119
- * @param {any} value - The value to set for the property.
120
- * @private
121
- */
122
- #set(attribute, value) {
123
- this.patchRecord = this.#getPatchRecord().set({ [attribute.name]: value });
124
-
125
- const transmutedValue = attribute.get(value, () => {});
69
+ #set(propertyName, attribute, value) {
126
70
  const update = {
127
- [attribute.name]: {
128
- previous: this.record[attribute.name],
129
- current: transmutedValue,
71
+ [propertyName]: {
72
+ previous: this.record[propertyName],
73
+ current: value,
130
74
  },
131
75
  };
132
76
 
133
- // update the record with the update value for later save
134
- this.record[attribute.name] = transmutedValue;
135
-
136
- // remember the update operation with the previous and current value
77
+ const hydratedValue = typeof attribute.get === 'function'
78
+ ? attribute.get(value)
79
+ : value;
80
+ this.record[propertyName] = hydratedValue;
137
81
  this.updates = { ...this.updates, ...update };
82
+
83
+ if (this.legacyEntity) {
84
+ if (!this.patchRecord) {
85
+ this.patchRecord = this.legacyEntity.patch(this.#getPrimaryKeyValues());
86
+ }
87
+ this.patchRecord = this.patchRecord.set({ [propertyName]: value });
88
+ }
138
89
  }
139
90
 
140
- /**
141
- * Gets the primary key values for the entity from the schema's primary index.
142
- * This supports composite primary keys (e.g., siteId + url).
143
- * @return {Object} - An object containing the primary key values.
144
- * @private
145
- */
146
91
  #getPrimaryKeyValues() {
147
92
  const primaryKeys = this.schema.getIndexKeys('primary');
148
- if (isNonEmptyArray(primaryKeys)) {
93
+ if (Array.isArray(primaryKeys) && primaryKeys.length > 0) {
149
94
  return primaryKeys.reduce((acc, key) => {
150
95
  acc[key] = this.record[key];
151
96
  return acc;
152
97
  }, {});
153
98
  }
154
- // Fallback to default id name
155
99
  return { [this.idName]: this.record[this.idName] };
156
100
  }
157
101
 
158
- /**
159
- * Gets the patch record for the entity. If it does not exist, it will be created.
160
- * @return {Object} - The patch record for the entity.
161
- * @private
162
- */
163
- #getPatchRecord() {
164
- if (!this.patchRecord) {
165
- this.patchRecord = this.entity.patch(this.#getPrimaryKeyValues());
166
- }
167
- return this.patchRecord;
168
- }
169
-
170
- /**
171
- * Patches a value for a given property on the entity. This method will validate the value
172
- * against the schema and throw an error if the value is invalid. If the value is declared as
173
- * a reference, it will validate the ID format.
174
- * @param {string} propertyName - The name of the property to patch.
175
- * @param {any} value - The value to patch.
176
- * @param {boolean} [isReference=false] - Whether the value is a reference to another entity.
177
- */
178
102
  patchValue(propertyName, value, isReference = false) {
179
103
  checkUpdatesAllowed(this.schema);
180
104
 
181
- const attribute = this.model.schema?.attributes[propertyName];
105
+ const attribute = this.schema.getAttribute(propertyName);
182
106
  if (!isObject(attribute)) {
183
107
  throw new ValidationError(`Property ${propertyName} does not exist on entity ${this.entityName}.`);
184
108
  }
@@ -189,6 +113,8 @@ class Patcher {
189
113
 
190
114
  if (isReference) {
191
115
  guardId(propertyName, value, this.entityName, nullable);
116
+ } else if (Array.isArray(attribute.type)) {
117
+ guardEnum(propertyName, value, attribute.type, this.entityName, nullable);
192
118
  } else {
193
119
  switch (attribute.type) {
194
120
  case 'any':
@@ -220,14 +146,9 @@ class Patcher {
220
146
  }
221
147
  }
222
148
 
223
- this.#set(attribute, value);
149
+ this.#set(propertyName, attribute, value);
224
150
  }
225
151
 
226
- /**
227
- * Saves the current state of the entity to the database.
228
- * @return {Promise<void>}
229
- * @throws {Error} - Throws an error if the save operation fails.
230
- */
231
152
  async save() {
232
153
  checkUpdatesAllowed(this.schema);
233
154
 
@@ -235,11 +156,42 @@ class Patcher {
235
156
  return;
236
157
  }
237
158
 
238
- const compositeValues = this.#getCompositeValues();
239
- await this.#getPatchRecord()
240
- .composite(compositeValues)
241
- .go();
242
- this.record.updatedAt = new Date().toISOString();
159
+ const previousUpdatedAt = this.record.updatedAt;
160
+ let nextUpdatedAt = new Date().toISOString();
161
+ if (typeof previousUpdatedAt === 'string' && previousUpdatedAt === nextUpdatedAt) {
162
+ const previousDate = new Date(previousUpdatedAt);
163
+ if (!Number.isNaN(previousDate.getTime())) {
164
+ nextUpdatedAt = new Date(previousDate.getTime() + 1000).toISOString();
165
+ }
166
+ }
167
+ this.record.updatedAt = nextUpdatedAt;
168
+ this.updates.updatedAt = {
169
+ previous: previousUpdatedAt,
170
+ current: nextUpdatedAt,
171
+ };
172
+
173
+ const keys = this.#getPrimaryKeyValues();
174
+ const updates = Object.keys(this.updates).reduce((acc, key) => {
175
+ acc[key] = this.updates[key].current;
176
+ return acc;
177
+ }, {});
178
+
179
+ if (this.collection
180
+ && typeof this.collection.applyUpdateWatchers === 'function'
181
+ && typeof this.collection.updateByKeys === 'function') {
182
+ const watched = this.collection.applyUpdateWatchers(this.record, updates);
183
+ this.record = watched.record;
184
+ await this.collection.updateByKeys(keys, watched.updates);
185
+ return;
186
+ }
187
+
188
+ if (this.patchRecord && typeof this.patchRecord.go === 'function') {
189
+ this.patchRecord = this.patchRecord.set({ updatedAt: nextUpdatedAt });
190
+ await this.patchRecord.go();
191
+ return;
192
+ }
193
+
194
+ throw new ValidationError(`No persistence strategy available for ${this.entityName}`);
243
195
  }
244
196
 
245
197
  getUpdates() {