@vida-global/core 2.4.5 → 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 (37) hide show
  1. package/README.md +1 -0
  2. package/index.js +6 -1
  3. package/lib/activeRecord/README.md +4 -2
  4. package/lib/activeRecord/baseRecord.js +2 -2
  5. package/lib/activeRecord/db/migrationErrors.js +6 -0
  6. package/lib/activeRecord/db/migrator.js +27 -10
  7. package/lib/http/README.md +18 -4
  8. package/lib/http/client.js +13 -9
  9. package/lib/jobQueue/README.md +4 -0
  10. package/lib/jobQueue/queue.js +7 -0
  11. package/lib/search/README.md +232 -0
  12. package/lib/search/abstractSearchClient.js +188 -0
  13. package/lib/search/errors.js +31 -0
  14. package/lib/search/index.js +16 -0
  15. package/lib/search/openSearchClient.js +319 -0
  16. package/lib/search/query.js +163 -0
  17. package/lib/server/authorizer.js +42 -0
  18. package/lib/server/controllerMixins/validations.js +1 -0
  19. package/lib/server/index.js +3 -2
  20. package/lib/server/serverController.js +37 -3
  21. package/package.json +2 -1
  22. package/test/activeRecord/baseRecord.test.js +22 -0
  23. package/test/activeRecord/db/migrator.test.js +17 -5
  24. package/test/activeRecord/db/schema.test.js +15 -0
  25. package/test/http/client.test.js +42 -0
  26. package/test/http/helpers/client.js +15 -1
  27. package/test/jobQueue/helpers/bullmqMock.js +1 -0
  28. package/test/jobQueue/queue.test.js +11 -0
  29. package/test/search/abstractSearchClient.test.js +354 -0
  30. package/test/search/errors.test.js +41 -0
  31. package/test/search/helpers/searchClient.js +137 -0
  32. package/test/search/openSearchClient.test.js +514 -0
  33. package/test/search/query.test.js +286 -0
  34. package/test/search/serializer.test.js +202 -0
  35. package/test/server/authorizer.test.js +105 -0
  36. package/test/server/helpers/authorizer.js +19 -0
  37. package/test/server/serverController.test.js +77 -0
@@ -0,0 +1,188 @@
1
+ const { Query } = require('./query');
2
+
3
+
4
+ class AbstractSearchClient {
5
+ #indexSuffix;
6
+
7
+
8
+ constructor(indexSuffix) {
9
+ if (this.constructor === AbstractSearchClient) {
10
+ throw new Error('SearchClients must be subclasses of AbstractSearchClient');
11
+ }
12
+ this.#indexSuffix = indexSuffix;
13
+ }
14
+
15
+
16
+ get indexSuffix() { return this.#indexSuffix; }
17
+ get deleteBatchSize() { return 500; }
18
+ get numRetries() { return 0; }
19
+ get retryBackoffDelay() { return 1000; }
20
+ get retryMaxDelay() { return 8000; }
21
+
22
+
23
+ query() {
24
+ return new Query(this);
25
+ }
26
+
27
+
28
+ /***********************************************************************************************
29
+ * READ
30
+ ***********************************************************************************************/
31
+ async fetch(conditions, options = {}) {
32
+ const serializedConditions = this._serializeConditions(conditions);
33
+ const response = await this._withRetry(() => this._execute(serializedConditions, options));
34
+ return this._processResponse(response);
35
+ }
36
+
37
+
38
+ _processResponse(response) {
39
+ const hits = this._hitsFromResponse(response)
40
+
41
+ return {
42
+ total: this._totalFromResponse(response),
43
+ results: hits.hits.map(hit => this._buildResult(hit))
44
+ };
45
+ }
46
+
47
+
48
+ _buildResult(hit) {
49
+ const id = this._idFromHit(hit);
50
+ const score = this._scoreFromHit(hit);
51
+ const document = this._documentFromHit(hit);
52
+ return { id, score, document };
53
+ }
54
+
55
+
56
+ _hitsFromResponse(response) { throw new Error('Not Implemented') }
57
+ _totalFromResponse(response) { throw new Error('Not Implemented') }
58
+ _idFromHit(hit) { throw new Error('Not Implemented') }
59
+ _scoreFromHit(hit) { throw new Error('Not Implemented') }
60
+ _documentFromHit(hit) { throw new Error('Not Implemented') }
61
+
62
+
63
+ /***********************************************************************************************
64
+ * WRITE
65
+ ***********************************************************************************************/
66
+ async upsert(id, document) {
67
+ return await this._withRetry(() => this._upsert(id, document));
68
+ }
69
+
70
+
71
+ async delete(id) {
72
+ return await this._withRetry(() => this._delete(id));
73
+ }
74
+
75
+
76
+ async append(document) {
77
+ return await this._append(document);
78
+ }
79
+
80
+
81
+ async bulkUpsert(documents) {
82
+ return await this._bulkUpsert(documents);
83
+ }
84
+
85
+
86
+ async deleteWhere(query) {
87
+ const deletedIds = new Set();
88
+ let batch = await this.#fetchDeletionBatch(query, deletedIds);
89
+
90
+ while (batch.length) {
91
+ await this.#deleteBatch(batch, deletedIds);
92
+ batch = await this.#fetchDeletionBatch(query, deletedIds);
93
+ }
94
+ return deletedIds.size;
95
+ }
96
+
97
+
98
+ async #fetchDeletionBatch(query, deletedIds) {
99
+ const { results } = await query.limit(this.deleteBatchSize).fetch();
100
+ return results.map(result => result.id).filter(id => !deletedIds.has(id));
101
+ }
102
+
103
+
104
+ async #deleteBatch(ids, deletedIds) {
105
+ for (const id of ids) {
106
+ await this.delete(id);
107
+ deletedIds.add(id);
108
+ }
109
+ }
110
+
111
+
112
+ /***********************************************************************************************
113
+ * ADMIN
114
+ ***********************************************************************************************/
115
+ static async syncIndex(indexSuffix) {
116
+ return await new this(indexSuffix).syncIndex();
117
+ }
118
+
119
+
120
+ async syncIndex() {
121
+ return await this._withRetry(() => this._syncIndex());
122
+ }
123
+
124
+
125
+ async _syncIndex() {
126
+ if (!await this._indexExists()) await this._createIndex();
127
+ return await this._putMapping();
128
+ }
129
+
130
+
131
+ /***********************************************************************************************
132
+ * RETRIES
133
+ ***********************************************************************************************/
134
+ shouldRetry(attemptCount, _error) {
135
+ return attemptCount - 1 < this.numRetries;
136
+ }
137
+
138
+
139
+ async _withRetry(operation) {
140
+ let attemptCount = 0;
141
+
142
+ while (true) {
143
+ attemptCount += 1;
144
+ try {
145
+ return await operation();
146
+ } catch (error) {
147
+ await this.#handleFailedAttempt(error, attemptCount);
148
+ }
149
+ }
150
+ }
151
+
152
+
153
+ async #handleFailedAttempt(error, attemptCount) {
154
+ if (!this.shouldRetry(attemptCount, error)) {
155
+ throw error;
156
+ }
157
+ await this.#waitBeforeRetry(attemptCount);
158
+ }
159
+
160
+
161
+ async #waitBeforeRetry(attemptCount) {
162
+ const backoff = 2 ** (attemptCount - 1) * this.retryBackoffDelay;
163
+ const delay = Math.min(backoff, this.retryMaxDelay);
164
+ await new Promise(resolve => setTimeout(resolve, delay));
165
+ }
166
+
167
+
168
+ /***********************************************************************************************
169
+ * BACKEND HOOKS
170
+ ***********************************************************************************************/
171
+ get index() { throw new Error('Not Implemented') }
172
+ get mapping() { throw new Error('Not Implemented') }
173
+
174
+ _serializeConditions(conditions) { throw new Error('Not Implemented') }
175
+ _execute(serializedConditions, options) { throw new Error('Not Implemented') }
176
+ _upsert(id, document) { throw new Error('Not Implemented') }
177
+ _append(document) { throw new Error('Not Implemented') }
178
+ _bulkUpsert(documents) { throw new Error('Not Implemented') }
179
+ _delete(id) { throw new Error('Not Implemented') }
180
+ _indexExists() { throw new Error('Not Implemented') }
181
+ _createIndex() { throw new Error('Not Implemented') }
182
+ _putMapping() { throw new Error('Not Implemented') }
183
+ }
184
+
185
+
186
+ module.exports = {
187
+ AbstractSearchClient
188
+ };
@@ -0,0 +1,31 @@
1
+ class SearchError extends Error {
2
+
3
+ constructor(message, options) {
4
+ super(message, options);
5
+ this.name = this.constructor.name;
6
+ }
7
+ }
8
+
9
+
10
+ class UnsupportedOperationError extends SearchError {}
11
+
12
+
13
+ class BulkWriteError extends SearchError {
14
+ #failures;
15
+
16
+
17
+ constructor(message, failures) {
18
+ super(message);
19
+ this.#failures = failures;
20
+ }
21
+
22
+
23
+ get failures() { return this.#failures; }
24
+ }
25
+
26
+
27
+ module.exports = {
28
+ BulkWriteError,
29
+ SearchError,
30
+ UnsupportedOperationError
31
+ };
@@ -0,0 +1,16 @@
1
+ const { AbstractSearchClient } = require('./abstractSearchClient');
2
+ const { OpenSearchClient } = require('./openSearchClient');
3
+ const { Query } = require('./query');
4
+ const { SearchError,
5
+ BulkWriteError,
6
+ UnsupportedOperationError } = require('./errors');
7
+
8
+
9
+ module.exports = {
10
+ AbstractSearchClient,
11
+ SearchError,
12
+ BulkWriteError,
13
+ OpenSearchClient,
14
+ Query,
15
+ UnsupportedOperationError
16
+ };
@@ -0,0 +1,319 @@
1
+ const { AbstractSearchClient } = require('./abstractSearchClient');
2
+ const { AwsSigv4Signer } = require('@opensearch-project/opensearch/aws');
3
+ const { BulkWriteError, UnsupportedOperationError } = require('./errors');
4
+ const { Client } = require('@opensearch-project/opensearch');
5
+
6
+
7
+ class OpenSearchClient extends AbstractSearchClient {
8
+ #_client;
9
+
10
+
11
+ static get SERVICE() { return 'aoss'; }
12
+
13
+
14
+ get wildcardIndex() { return `${this.index}-*`; }
15
+ get endpoint() { throw new Error('Not Implemented') }
16
+
17
+
18
+ get writeIndex() {
19
+ if (!this.indexSuffix) return this.index;
20
+ return `${this.index}-${this.indexSuffix}`;
21
+ }
22
+
23
+
24
+ /***********************************************************************************************
25
+ * READ
26
+ ***********************************************************************************************/
27
+ async _execute(serializedConditions, options) {
28
+ return await this.#client.search({
29
+ index: this.#readIndexForFetch(options),
30
+ body: serializedConditions
31
+ });
32
+ }
33
+
34
+
35
+ _hitsFromResponse(response) { return response.body.hits }
36
+ _totalFromResponse(response) { return response.body.hits.total?.value || 0 }
37
+ _idFromHit(hit) { return hit._id }
38
+ _scoreFromHit(hit) { return hit._score }
39
+ _documentFromHit(hit) { return hit._source }
40
+
41
+
42
+ #readIndexForFetch({indexWildcard}) {
43
+ return indexWildcard ? this.wildcardIndex : this.writeIndex;
44
+ }
45
+
46
+
47
+ /***********************************************************************************************
48
+ * SERIALIZATION
49
+ ***********************************************************************************************/
50
+ _serializeConditions(conditions) {
51
+ return {
52
+ query: this.#serializeTree(conditions),
53
+ track_total_hits: true,
54
+ ...this.#serializeOptions(conditions.options)
55
+ };
56
+ }
57
+
58
+
59
+ #serializeTree(conditions) {
60
+ const must = this.#serializeAll(conditions.and);
61
+ const mustNot = this.#serializeAll(conditions.not);
62
+ if (conditions.or.length) must.push(this.#serializeShouldGroup(conditions.or));
63
+
64
+ if (must.length === 1 && !mustNot.length) return must[0];
65
+ return {bool: {must, must_not: mustNot}};
66
+ }
67
+
68
+
69
+ #serializeAll(conditions) {
70
+ return conditions.map(condition => this.#serializeCondition(condition));
71
+ }
72
+
73
+
74
+ // `minimum_should_match` is not optional here: alongside a populated `must`, OpenSearch treats
75
+ // `should` clauses as score boosters rather than filters, so the group would stop filtering.
76
+ #serializeShouldGroup(conditions) {
77
+ return {bool: {should: this.#serializeAll(conditions), minimum_should_match: 1}};
78
+ }
79
+
80
+
81
+ #serializeCondition(condition) {
82
+ if (condition.operator) return this.#serializeLeaf(condition);
83
+ return this.#serializeTree(condition);
84
+ }
85
+
86
+
87
+ #serializeLeaf(leaf) {
88
+ const operator = `${leaf.operator[0].toUpperCase()}${leaf.operator.slice(1)}`;
89
+ return this[`_serialize${operator}`](leaf);
90
+ }
91
+
92
+
93
+ #serializeOptions({limit, minScore, offset, sort}) {
94
+ const body = {};
95
+
96
+ if (limit !== undefined) body.size = limit;
97
+ if (offset !== undefined) body.from = offset;
98
+ if (minScore !== undefined) body.min_score = minScore;
99
+ if (sort) body.sort = this.#serializeSort(sort);
100
+ return body;
101
+ }
102
+
103
+
104
+ #serializeSort(sortKeys) {
105
+ return sortKeys.map(({field, direction}) => ({[field]: {order: direction}}));
106
+ }
107
+
108
+
109
+ /***********************************************************************************************
110
+ * CONDITION SERIALIZERS
111
+ ***********************************************************************************************/
112
+ _serializeEquals({field, value}) {
113
+ return {term: {[field]: {value}}};
114
+ }
115
+
116
+
117
+ _serializeIn({field, value}) {
118
+ return {terms: {[field]: value}};
119
+ }
120
+
121
+
122
+ _serializeRange({field, value}) {
123
+ return {range: {[field]: value}};
124
+ }
125
+
126
+
127
+ _serializeExists({field}) {
128
+ return {exists: {field}};
129
+ }
130
+
131
+
132
+ _serializeMissing({field}) {
133
+ return {bool: {must_not: {exists: {field}}}};
134
+ }
135
+
136
+
137
+ _serializeMatches({field, value, options}) {
138
+ return {match: {[field]: {query: value, fuzziness: options.fuzziness}}};
139
+ }
140
+
141
+
142
+ _serializePhrase({field, value}) {
143
+ return {match_phrase: {[field]: {query: value}}};
144
+ }
145
+
146
+
147
+ _serializeFullText({field, value, options}) {
148
+ const query = options.wildcard ? `*${value}*` : value;
149
+ const fields = Object.entries(field).map(([name, boost]) => `${name}^${boost}`);
150
+
151
+ return {query_string: {query, fields}};
152
+ }
153
+
154
+
155
+ _serializeNested({field, value}) {
156
+ return {nested: {path: field, query: this.#serializeTree(value)}};
157
+ }
158
+
159
+
160
+ /***********************************************************************************************
161
+ * WRITE
162
+ ***********************************************************************************************/
163
+ async _upsert(id, document) {
164
+ return await this.#rethrowUnsupported('upsert', () => this.#client.update({
165
+ id,
166
+ index: this.writeIndex,
167
+ body: {doc: document, doc_as_upsert: true}
168
+ }));
169
+ }
170
+
171
+
172
+ async _append(document) {
173
+ return await this.#client.index({index: this.writeIndex, body: document});
174
+ }
175
+
176
+
177
+ async _delete(id) {
178
+ return await this.#client.delete({id, index: this.writeIndex});
179
+ }
180
+
181
+
182
+ async _bulkUpsert(documents) {
183
+ const body = this.#buildBulkBody(documents);
184
+ const response = await this.#rethrowUnsupported('bulkUpsert', () => this.#client.bulk({body}));
185
+ this.#assertBulkSucceeded(response);
186
+ return response;
187
+ }
188
+
189
+
190
+ #buildBulkBody(documents) {
191
+ const operations = [];
192
+
193
+ for (const { id, document } of documents) {
194
+ operations.push({update: {_id: id, _index: this.writeIndex}});
195
+ operations.push({doc: document, doc_as_upsert: true});
196
+ }
197
+ return operations;
198
+ }
199
+
200
+
201
+ #assertBulkSucceeded(response) {
202
+ if (!response.body.errors) return;
203
+
204
+ const failures = this.#collectBulkFailures(response.body.items);
205
+ throw new BulkWriteError(`${failures.length} documents could not be written`, failures);
206
+ }
207
+
208
+
209
+ #collectBulkFailures(items) {
210
+ const failures = [];
211
+
212
+ for (const item of items) {
213
+ const outcome = item.update;
214
+ if (outcome.error) failures.push({id: outcome._id, reason: outcome.error.reason});
215
+ }
216
+ return failures;
217
+ }
218
+
219
+
220
+ async #rethrowUnsupported(operationName, operation) {
221
+ try {
222
+ return await operation();
223
+ } catch (error) {
224
+ if (!this._isUnsupportedOperation(error)) throw error;
225
+ throw new UnsupportedOperationError(
226
+ `${operationName} is not supported by the collection behind ${this.constructor.name}`,
227
+ {cause: error}
228
+ );
229
+ }
230
+ }
231
+
232
+
233
+ // Serverless rejects every write addressed to a document id unless the collection is of type
234
+ // SEARCH. Nothing in the response names the collection type, so the refusal is read off the
235
+ // message. The regex here is a guess and should be fixed if it doesn't properly catch the error
236
+ _isUnsupportedOperation(error) {
237
+ const statusCode = error.statusCode || error.meta?.statusCode;
238
+ if (statusCode !== 400) return false;
239
+ return /not supported/i.test(error.message || '');
240
+ }
241
+
242
+
243
+ /***********************************************************************************************
244
+ * ADMIN
245
+ ***********************************************************************************************/
246
+ async _indexExists() {
247
+ const response = await this.#client.indices.exists({index: this.writeIndex});
248
+ return Boolean(response.body);
249
+ }
250
+
251
+
252
+ async _createIndex() {
253
+ return await this.#client.indices.create({index: this.writeIndex});
254
+ }
255
+
256
+
257
+ async _putMapping() {
258
+ return await this.#client.indices.putMapping({index: this.writeIndex, body: this.mapping});
259
+ }
260
+
261
+
262
+ /***********************************************************************************************
263
+ * RETRIES
264
+ ***********************************************************************************************/
265
+ shouldRetry(attemptCount, error) {
266
+ if (!super.shouldRetry(attemptCount, error)) return false;
267
+ return this.#isServerError(error);
268
+ }
269
+
270
+
271
+ #isServerError(error) {
272
+ const statusCode = error.statusCode || error.meta?.statusCode;
273
+ return statusCode >= 500 && statusCode < 600;
274
+ }
275
+
276
+
277
+ /***********************************************************************************************
278
+ * CLIENT CONFIG
279
+ ***********************************************************************************************/
280
+ get openSearchClientClass() { return Client; }
281
+ get awsSignerFactory() { return AwsSigv4Signer; }
282
+
283
+
284
+ get #client() {
285
+ return this.#_client ||= new this.openSearchClientClass(this.#config);
286
+ }
287
+
288
+
289
+ get #config() {
290
+ return {...this.awsSigner, node: this.endpoint};
291
+ }
292
+
293
+
294
+ get awsSigner() {
295
+ return this.awsSignerFactory({
296
+ region: this.region,
297
+ service: this.constructor.SERVICE,
298
+ getCredentials: this.getCredentials.bind(this)
299
+ });
300
+ }
301
+
302
+
303
+ get region() {
304
+ return process.env.OPENSEARCH_REGION || process.env.DEFAULT_AWS_REGION;
305
+ }
306
+
307
+
308
+ async getCredentials() {
309
+ return {
310
+ accessKeyId: process.env.OPENSEARCH_AWS_KEY,
311
+ secretAccessKey: process.env.OPENSEARCH_AWS_SECRET
312
+ };
313
+ }
314
+ }
315
+
316
+
317
+ module.exports = {
318
+ OpenSearchClient
319
+ };