@adobe/spacecat-shared-data-access 2.109.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.
@@ -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() {
@@ -0,0 +1,203 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import pluralize from 'pluralize';
14
+
15
+ const DEFAULT_PAGE_SIZE = 1000;
16
+
17
+ const ENTITY_TABLE_OVERRIDES = {
18
+ LatestAudit: 'audits',
19
+ };
20
+
21
+ const camelToSnake = (value) => value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
22
+
23
+ const snakeToCamel = (value) => value.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
24
+
25
+ const entityToTableName = (entityName) => {
26
+ const override = ENTITY_TABLE_OVERRIDES[entityName];
27
+ if (override) {
28
+ return override;
29
+ }
30
+ return camelToSnake(pluralize.plural(entityName));
31
+ };
32
+
33
+ const encodeCursor = (offset) => Buffer.from(JSON.stringify({ offset }), 'utf-8').toString('base64');
34
+
35
+ const decodeCursor = (cursor) => {
36
+ if (!cursor) {
37
+ return 0;
38
+ }
39
+
40
+ try {
41
+ const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8'));
42
+ return Number.isInteger(decoded.offset) && decoded.offset >= 0 ? decoded.offset : 0;
43
+ } catch (e) {
44
+ return 0;
45
+ }
46
+ };
47
+
48
+ const createFieldMaps = (schema) => {
49
+ const toDbMap = {};
50
+ const toModelMap = {};
51
+ const attributes = schema.getAttributes();
52
+ const idName = typeof schema.getIdName === 'function' ? schema.getIdName() : undefined;
53
+ Object.keys(attributes).forEach((modelField) => {
54
+ const attribute = attributes[modelField] || {};
55
+ if (attribute.postgrestIgnore) {
56
+ return;
57
+ }
58
+ const dbField = attribute.postgrestField
59
+ || (modelField === idName && modelField !== 'id' ? 'id' : camelToSnake(modelField));
60
+ toDbMap[modelField] = dbField;
61
+ toModelMap[dbField] = modelField;
62
+ });
63
+
64
+ const idAttribute = idName ? attributes[idName] : undefined;
65
+ if (idName
66
+ && idName !== 'id'
67
+ && idAttribute
68
+ && !idAttribute.postgrestIgnore) {
69
+ toDbMap[idName] = 'id';
70
+ toModelMap.id = idName;
71
+ }
72
+
73
+ return { toDbMap, toModelMap };
74
+ };
75
+
76
+ const toDbField = (field, map) => map[field] || camelToSnake(field);
77
+
78
+ const toModelField = (field, map) => map[field] || snakeToCamel(field);
79
+
80
+ const toDbRecord = (record, toDbMap, options = {}) => {
81
+ const { includeUnknown = false } = options;
82
+ return Object.entries(record).reduce((acc, [key, value]) => {
83
+ if (Object.prototype.hasOwnProperty.call(toDbMap, key)) {
84
+ acc[toDbMap[key]] = value;
85
+ return acc;
86
+ }
87
+
88
+ if (includeUnknown) {
89
+ acc[toDbField(key, toDbMap)] = value;
90
+ }
91
+
92
+ return acc;
93
+ }, {});
94
+ };
95
+
96
+ const looksLikeIsoDateTime = (value) => typeof value === 'string'
97
+ && /^\d{4}-\d{2}-\d{2}T/.test(value)
98
+ && /(?:Z|[+-]\d{2}:\d{2})$/.test(value);
99
+
100
+ const normalizeModelValue = (value) => {
101
+ if (value === null) {
102
+ return undefined;
103
+ }
104
+
105
+ // PostgREST can return jsonb null scalars as [null] for some projections/aggregations.
106
+ // We normalize this artifact to "missing" so model payloads match v2 behavior.
107
+ if (Array.isArray(value) && value.length === 1 && value[0] === null) {
108
+ return undefined;
109
+ }
110
+
111
+ if (looksLikeIsoDateTime(value)) {
112
+ const date = new Date(value);
113
+ if (!Number.isNaN(date.getTime())) {
114
+ return date.toISOString();
115
+ }
116
+ }
117
+
118
+ return value;
119
+ };
120
+
121
+ const fromDbRecord = (record, toModelMap) => Object.entries(record).reduce((acc, [key, value]) => {
122
+ const normalized = normalizeModelValue(value);
123
+ if (normalized !== undefined) {
124
+ acc[toModelField(key, toModelMap)] = normalized;
125
+ }
126
+ return acc;
127
+ }, {});
128
+
129
+ const applyWhere = (query, whereFn, toDbMap) => {
130
+ if (typeof whereFn !== 'function') {
131
+ return query;
132
+ }
133
+
134
+ const attrs = new Proxy({}, {
135
+ get: (_, prop) => toDbField(String(prop), toDbMap),
136
+ });
137
+
138
+ const op = {
139
+ eq: (field, value) => ({ type: 'eq', field, value }),
140
+ ne: (field, value) => ({ type: 'ne', field, value }),
141
+ gt: (field, value) => ({ type: 'gt', field, value }),
142
+ gte: (field, value) => ({ type: 'gte', field, value }),
143
+ lt: (field, value) => ({ type: 'lt', field, value }),
144
+ lte: (field, value) => ({ type: 'lte', field, value }),
145
+ in: (field, value) => ({ type: 'in', field, value }),
146
+ is: (field, value) => ({ type: 'is', field, value }),
147
+ like: (field, value) => ({ type: 'like', field, value }),
148
+ ilike: (field, value) => ({ type: 'ilike', field, value }),
149
+ contains: (field, value) => ({ type: 'contains', field, value }),
150
+ };
151
+
152
+ const expression = whereFn(attrs, op);
153
+ if (!expression || typeof expression !== 'object') {
154
+ return query;
155
+ }
156
+
157
+ switch (expression.type) {
158
+ case 'eq':
159
+ return query.eq(expression.field, expression.value);
160
+ case 'ne':
161
+ return query.neq(expression.field, expression.value);
162
+ case 'gt':
163
+ return query.gt(expression.field, expression.value);
164
+ case 'gte':
165
+ return query.gte(expression.field, expression.value);
166
+ case 'lt':
167
+ return query.lt(expression.field, expression.value);
168
+ case 'lte':
169
+ return query.lte(expression.field, expression.value);
170
+ case 'in':
171
+ return query.in(
172
+ expression.field,
173
+ Array.isArray(expression.value) ? expression.value : [expression.value],
174
+ );
175
+ case 'is':
176
+ return query.is(expression.field, expression.value);
177
+ case 'like':
178
+ return query.like(expression.field, expression.value);
179
+ case 'ilike':
180
+ return query.ilike(expression.field, expression.value);
181
+ case 'contains': {
182
+ const value = Array.isArray(expression.value) ? expression.value : [expression.value];
183
+ return query.contains(expression.field, value);
184
+ }
185
+ default:
186
+ throw new Error(`Unsupported where operator: ${expression.type}`);
187
+ }
188
+ };
189
+
190
+ export {
191
+ DEFAULT_PAGE_SIZE,
192
+ applyWhere,
193
+ camelToSnake,
194
+ createFieldMaps,
195
+ decodeCursor,
196
+ encodeCursor,
197
+ entityToTableName,
198
+ fromDbRecord,
199
+ snakeToCamel,
200
+ toDbField,
201
+ toDbRecord,
202
+ toModelField,
203
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "declaration": true,
9
+ "declarationMap": true,
10
+ "sourceMap": true,
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true,
17
+ "noUnusedLocals": true,
18
+ "noUnusedParameters": true,
19
+ "noImplicitReturns": true,
20
+ "noFallthroughCasesInSwitch": true
21
+ },
22
+ "include": ["src/**/*.ts"],
23
+ "exclude": ["node_modules", "dist", "test"]
24
+ }