@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.
- package/README.md +1 -0
- package/index.js +6 -1
- package/lib/activeRecord/README.md +4 -2
- package/lib/activeRecord/baseRecord.js +2 -2
- package/lib/activeRecord/db/migrationErrors.js +6 -0
- package/lib/activeRecord/db/migrator.js +27 -10
- package/lib/http/README.md +18 -4
- package/lib/http/client.js +13 -9
- package/lib/jobQueue/README.md +4 -0
- package/lib/jobQueue/queue.js +7 -0
- package/lib/search/README.md +232 -0
- package/lib/search/abstractSearchClient.js +188 -0
- package/lib/search/errors.js +31 -0
- package/lib/search/index.js +16 -0
- package/lib/search/openSearchClient.js +319 -0
- package/lib/search/query.js +163 -0
- package/lib/server/authorizer.js +42 -0
- package/lib/server/controllerMixins/validations.js +1 -0
- package/lib/server/index.js +3 -2
- package/lib/server/serverController.js +37 -3
- package/package.json +2 -1
- package/test/activeRecord/baseRecord.test.js +22 -0
- package/test/activeRecord/db/migrator.test.js +17 -5
- package/test/activeRecord/db/schema.test.js +15 -0
- package/test/http/client.test.js +42 -0
- package/test/http/helpers/client.js +15 -1
- package/test/jobQueue/helpers/bullmqMock.js +1 -0
- package/test/jobQueue/queue.test.js +11 -0
- package/test/search/abstractSearchClient.test.js +354 -0
- package/test/search/errors.test.js +41 -0
- package/test/search/helpers/searchClient.js +137 -0
- package/test/search/openSearchClient.test.js +514 -0
- package/test/search/query.test.js +286 -0
- package/test/search/serializer.test.js +202 -0
- package/test/server/authorizer.test.js +105 -0
- package/test/server/helpers/authorizer.js +19 -0
- package/test/server/serverController.test.js +77 -0
package/README.md
CHANGED
|
@@ -9,3 +9,4 @@ This package contains core elements used across all Vida Apps. It is included in
|
|
|
9
9
|
- [Logger](lib/logger) — scoped, child-aware logger with environment-driven levels.
|
|
10
10
|
- [VidaServer](lib/server) — Express-based server with auto-loaded controllers, validation, rendering, callbacks, cookies, and SSE streaming.
|
|
11
11
|
- [JobQueue](lib/jobQueue) — BullMQ-backed queue with workers, retries/backoff, progress reporting, and queue inspection helpers.
|
|
12
|
+
- [Search](lib/search) — backend-agnostic search client with a chainable query vocabulary, an OpenSearch Serverless implementation, and index mapping management.
|
package/index.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
const ActiveRecord = require('./lib/activeRecord');
|
|
2
2
|
const httpLibs = require('./lib/http/client');
|
|
3
3
|
const APM = require('./lib/apm');
|
|
4
|
-
const { AppDependencies } = require('./lib/appDependencies');
|
|
4
|
+
const { AppDependencies, DependencyRegistry } = require('./lib/appDependencies');
|
|
5
5
|
const JobQueue = require('./lib/jobQueue');
|
|
6
6
|
const { logger } = require('./lib/logger');
|
|
7
7
|
const redisLibs = require('./lib/redis');
|
|
8
8
|
const cacheLibs = require('./lib/cache');
|
|
9
|
+
const searchLibs = require('./lib/search');
|
|
9
10
|
const serverLibs = require('./lib/server');
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
const {
|
|
13
14
|
AbstractServerError,
|
|
14
15
|
ApiDocsGenerator,
|
|
16
|
+
Authorizer,
|
|
15
17
|
VidaServer,
|
|
16
18
|
VidaServerController,
|
|
17
19
|
} = serverLibs;
|
|
@@ -21,14 +23,17 @@ const serverErrorsToExport = Object.values(serverLibs).filter(obj => obj.prototy
|
|
|
21
23
|
module.exports = {
|
|
22
24
|
ActiveRecord,
|
|
23
25
|
APM,
|
|
26
|
+
Authorizer,
|
|
24
27
|
...httpLibs,
|
|
25
28
|
JobQueue,
|
|
26
29
|
logger,
|
|
27
30
|
...cacheLibs,
|
|
28
31
|
...redisLibs,
|
|
32
|
+
...searchLibs,
|
|
29
33
|
...serverErrorsToExport,
|
|
30
34
|
ApiDocsGenerator,
|
|
31
35
|
AppDependencies,
|
|
36
|
+
DependencyRegistry,
|
|
32
37
|
VidaServer,
|
|
33
38
|
VidaServerController,
|
|
34
39
|
};
|
|
@@ -113,7 +113,7 @@ dropTable(tableName)
|
|
|
113
113
|
addColumn(tableName, columnName, columnDetails)
|
|
114
114
|
removeColumn(tableName, columnName)
|
|
115
115
|
addIndex(tableName, fields, { concurrently, unique, name, using, where })
|
|
116
|
-
removeIndex(tableName, indexNameOrAttributes, concurrently
|
|
116
|
+
removeIndex(tableName, indexNameOrAttributes, { concurrently })
|
|
117
117
|
renameColumn(tableName, oldName, newName)
|
|
118
118
|
changeColumn(tableName, columnName, dataTypeOrOptions)
|
|
119
119
|
```
|
|
@@ -141,13 +141,15 @@ module.exports = {
|
|
|
141
141
|
},
|
|
142
142
|
|
|
143
143
|
down: async function() {
|
|
144
|
-
await this.removeIndex('users', ['team_id'], true);
|
|
144
|
+
await this.removeIndex('users', ['team_id'], { concurrently: true });
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
```
|
|
148
148
|
|
|
149
149
|
An opted-out migration is **not** rolled back if it fails, so keep it to the single statement that needs it and put everything else in its own migration.
|
|
150
150
|
|
|
151
|
+
`concurrently` is off by default on both methods, because the default transaction rules it out. Asking for it from inside a transaction throws a `MigrationError` before the statement reaches the database, and the message tells you to set `transactional: false`.
|
|
152
|
+
|
|
151
153
|
|
|
152
154
|
## Models
|
|
153
155
|
|
|
@@ -379,7 +379,7 @@ class BaseRecord extends Model {
|
|
|
379
379
|
/***********************************************************************************************
|
|
380
380
|
* CACHING
|
|
381
381
|
***********************************************************************************************/
|
|
382
|
-
static async _cachedFind(ids, { clear=false }={}) {
|
|
382
|
+
static async _cachedFind(ids, { clear=false, expireSeconds=null }={}) {
|
|
383
383
|
const multiFind = Array.isArray(ids)
|
|
384
384
|
const idsToFind = multiFind ? ids : [ids];
|
|
385
385
|
const keyPairs = idsToFind.map(id => [this._recordCacheKey(id), id]);
|
|
@@ -387,7 +387,7 @@ class BaseRecord extends Model {
|
|
|
387
387
|
const idsToKeys = Object.fromEntries(keyPairs.map(p => p.reverse()));
|
|
388
388
|
const keys = Object.keys(keysToIds);
|
|
389
389
|
|
|
390
|
-
let records = await this.cachedFetch(keys, { clear }, async (missedKeys) => {
|
|
390
|
+
let records = await this.cachedFetch(keys, { clear, expireSeconds }, async (missedKeys) => {
|
|
391
391
|
const idsToFind = missedKeys.map(key => keysToIds[key]);
|
|
392
392
|
const pk = this.primaryKeyAttribute
|
|
393
393
|
const records = await this.where({[pk]: idsToFind});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
const { Connection }
|
|
2
|
-
const
|
|
3
|
-
const
|
|
4
|
-
const {
|
|
1
|
+
const { Connection } = require('./connection');
|
|
2
|
+
const { MigrationError } = require('./migrationErrors');
|
|
3
|
+
const utils = require('../utils');
|
|
4
|
+
const { Sequelize, Op } = require('sequelize');
|
|
5
|
+
const { underscore } = require('inflection');
|
|
5
6
|
|
|
6
7
|
const ID_TYPES = ['bigint', 'int', 'uuid'];
|
|
7
8
|
const DEFAULT_ID_TYPE = 'bigint';
|
|
@@ -24,7 +25,7 @@ class Migrator {
|
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
async run() {
|
|
27
|
-
if (!this.migrationModule.up) throw new
|
|
28
|
+
if (!this.migrationModule.up) throw new MigrationError('Missing migration `up`');
|
|
28
29
|
|
|
29
30
|
await this.perform(() => this.migrationModule.up.call(this));
|
|
30
31
|
}
|
|
@@ -122,8 +123,8 @@ class Migrator {
|
|
|
122
123
|
if (idType === false) return {};
|
|
123
124
|
|
|
124
125
|
if (!ID_TYPES.includes(idType)) {
|
|
125
|
-
throw new
|
|
126
|
-
|
|
126
|
+
throw new MigrationError(`Unknown id type "${idType}". Use one of: ${ID_TYPES.join(', ')}, `
|
|
127
|
+
+ `or false for a table that declares its own primary key.`);
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
return this[`${idType}IdColumn`];
|
|
@@ -213,8 +214,10 @@ class Migrator {
|
|
|
213
214
|
|
|
214
215
|
|
|
215
216
|
async addIndex(tableName, fields, { concurrently, unique, name, using, where }={}) {
|
|
217
|
+
if (concurrently) this.assertConcurrentIndexAllowed('addIndex');
|
|
218
|
+
|
|
216
219
|
const options = {fields: fields, ...this.queryOptions};
|
|
217
|
-
options.name
|
|
220
|
+
options.name = name || this.defaultIndexName(tableName, fields);
|
|
218
221
|
if (concurrently) options.concurrently = true;
|
|
219
222
|
if (unique) options.unique = true;
|
|
220
223
|
if (using) options.using = using;
|
|
@@ -224,8 +227,10 @@ class Migrator {
|
|
|
224
227
|
}
|
|
225
228
|
|
|
226
229
|
|
|
227
|
-
async removeIndex(tableName, indexNameOrAttributes, concurrently=
|
|
228
|
-
|
|
230
|
+
async removeIndex(tableName, indexNameOrAttributes, { concurrently }={}) {
|
|
231
|
+
if (concurrently) this.assertConcurrentIndexAllowed('removeIndex');
|
|
232
|
+
|
|
233
|
+
const options = this.queryOptions;
|
|
229
234
|
if (concurrently) options.concurrently = true;
|
|
230
235
|
|
|
231
236
|
const indexName = this.indexNameFor(tableName, indexNameOrAttributes);
|
|
@@ -233,6 +238,18 @@ class Migrator {
|
|
|
233
238
|
}
|
|
234
239
|
|
|
235
240
|
|
|
241
|
+
/***********************************************************************************************
|
|
242
|
+
* INDEX GUARDS
|
|
243
|
+
***********************************************************************************************/
|
|
244
|
+
assertConcurrentIndexAllowed(methodName) {
|
|
245
|
+
if (!this.#transaction) return;
|
|
246
|
+
|
|
247
|
+
throw new MigrationError(`${methodName} cannot use \`concurrently\` inside a transaction. `
|
|
248
|
+
+ 'Add `transactional: false` to the migration module, and keep that '
|
|
249
|
+
+ 'migration to the statements that need it.');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
236
253
|
/***********************************************************************************************
|
|
237
254
|
* INDEX NAMING
|
|
238
255
|
***********************************************************************************************/
|
package/lib/http/README.md
CHANGED
|
@@ -49,14 +49,28 @@ const response = await client.getTickets({ page: 1, pageSize: 25 });
|
|
|
49
49
|
|
|
50
50
|
| Method | Options |
|
|
51
51
|
|---|---|
|
|
52
|
-
| `get(endpoint, opts)` | `requestParams`, `headers`, `timeout`, `signal` |
|
|
53
|
-
| `post(endpoint, opts)` | `body`, `headers`, `timeout`, `signal` |
|
|
54
|
-
| `put(endpoint, opts)` | `body`, `headers`, `timeout`, `signal` |
|
|
55
|
-
| `delete(endpoint, opts)` | `requestParams`, `headers`, `timeout`, `signal` |
|
|
52
|
+
| `get(endpoint, opts)` | `requestParams`, `headers`, `timeout`, `signal`, `binaryResponse` |
|
|
53
|
+
| `post(endpoint, opts)` | `body`, `headers`, `timeout`, `signal`, `binaryResponse` |
|
|
54
|
+
| `put(endpoint, opts)` | `body`, `headers`, `timeout`, `signal`, `binaryResponse` |
|
|
55
|
+
| `delete(endpoint, opts)` | `requestParams`, `headers`, `timeout`, `signal`, `binaryResponse` |
|
|
56
56
|
|
|
57
57
|
For `GET`/`DELETE`, `requestParams` becomes the query string. For `POST`/`PUT`, `body` becomes the request body. Object bodies are serialized as JSON; string bodies are sent unchanged.
|
|
58
58
|
|
|
59
59
|
|
|
60
|
+
### Binary responses
|
|
61
|
+
|
|
62
|
+
By default a response body is read as text and parsed as JSON when the `Content-Type` says so. That is wrong for media: decoding bytes as UTF-8 replaces every sequence that is not valid UTF-8, so the content arrives corrupted.
|
|
63
|
+
|
|
64
|
+
Pass `binaryResponse: true` and `data` is a `Buffer` of the exact bytes instead. It is per call, so one client can fetch JSON metadata and binary media.
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
async fetchRecording(path) {
|
|
68
|
+
const response = await this.get(path, { binaryResponse: true, headers: { Accept: 'audio/*' } });
|
|
69
|
+
return response.data; // Buffer
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
|
|
60
74
|
### Query string encoding
|
|
61
75
|
|
|
62
76
|
For `GET`/`DELETE`, the way `requestParams` is appended to the URL depends on the `Content-Type` header:
|
package/lib/http/client.js
CHANGED
|
@@ -7,22 +7,22 @@ logger.addScope('http');
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class HttpClient {
|
|
10
|
-
async get(endpoint, { requestParams, headers, timeout, signal }={}) {
|
|
10
|
+
async get(endpoint, { requestParams, headers, timeout, signal, binaryResponse }={}) {
|
|
11
11
|
return await this.#makeRequest(endpoint, "GET", arguments[1]);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
async post(endpoint, { body, headers, timeout, signal }={}) {
|
|
15
|
+
async post(endpoint, { body, headers, timeout, signal, binaryResponse }={}) {
|
|
16
16
|
return await this.#makeRequest(endpoint, "POST", arguments[1]);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
async put(endpoint, { body, headers, timeout, signal }={}) {
|
|
20
|
+
async put(endpoint, { body, headers, timeout, signal, binaryResponse }={}) {
|
|
21
21
|
return await this.#makeRequest(endpoint, "PUT", arguments[1]);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
async delete(endpoint, { requestParams, headers, timeout, signal }={}) {
|
|
25
|
+
async delete(endpoint, { requestParams, headers, timeout, signal, binaryResponse }={}) {
|
|
26
26
|
return await this.#makeRequest(endpoint, "DELETE", arguments[1]);
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -32,7 +32,7 @@ class HttpClient {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
async #_makeRequest(endpoint, method, { requestParams, body, headers, timeout, signal }={}) {
|
|
35
|
+
async #_makeRequest(endpoint, method, { requestParams, body, headers, timeout, signal, binaryResponse }={}) {
|
|
36
36
|
this.#logRequest(method, endpoint);
|
|
37
37
|
|
|
38
38
|
endpoint = `${this.urlRoot}${endpoint}`;
|
|
@@ -42,7 +42,7 @@ class HttpClient {
|
|
|
42
42
|
|
|
43
43
|
try {
|
|
44
44
|
const res = await fetch(url.toString(), payload);
|
|
45
|
-
return await this.#handleRequestResponse(res, payload);
|
|
45
|
+
return await this.#handleRequestResponse(res, payload, binaryResponse);
|
|
46
46
|
} catch (err) {
|
|
47
47
|
this.#handleRequestError(err, payload, timeout, signal);
|
|
48
48
|
}
|
|
@@ -70,9 +70,9 @@ class HttpClient {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
|
|
73
|
-
async #handleRequestResponse(res, payload) {
|
|
73
|
+
async #handleRequestResponse(res, payload, binaryResponse) {
|
|
74
74
|
if (res.status && res.status < 300) {
|
|
75
|
-
const data = await this.#parseResponseData(res);
|
|
75
|
+
const data = await this.#parseResponseData(res, binaryResponse);
|
|
76
76
|
return {data, status: res.status};
|
|
77
77
|
} else {
|
|
78
78
|
throw new HttpError(res, payload);
|
|
@@ -80,7 +80,11 @@ class HttpClient {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
|
|
83
|
-
async #parseResponseData(res) {
|
|
83
|
+
async #parseResponseData(res, binaryResponse) {
|
|
84
|
+
// Reading bytes as text decodes them as UTF-8, which replaces every sequence that is not
|
|
85
|
+
// valid UTF-8. Media has to be taken as bytes or it arrives corrupted.
|
|
86
|
+
if (binaryResponse) return Buffer.from(await res.arrayBuffer());
|
|
87
|
+
|
|
84
88
|
const body = await res.text();
|
|
85
89
|
if (!this.#isJsonResponse(res)) return body;
|
|
86
90
|
|
package/lib/jobQueue/README.md
CHANGED
|
@@ -179,6 +179,7 @@ await queue.getJob(id); // one job, or null
|
|
|
179
179
|
|
|
180
180
|
await queue.clean(grace, limit, type); // remove finished jobs older than `grace` ms
|
|
181
181
|
await queue.clearQueuedJobs(); // drain all queued jobs
|
|
182
|
+
await queue.obliterate(); // delete the queue and every key it owns in Redis
|
|
182
183
|
|
|
183
184
|
await queue.setMaximumConcurrency(8); // usually left to the Worker option above
|
|
184
185
|
await queue.maximumConcurrency();
|
|
@@ -188,6 +189,9 @@ await queue.removeMaximumConcurrency();
|
|
|
188
189
|
`clean` takes BullMQ's argument order, which is `(grace, limit, type)`. Bull's was
|
|
189
190
|
`(grace, type, limit)` — worth checking when porting a call.
|
|
190
191
|
|
|
192
|
+
`obliterate` forces, so it ends jobs that are still running rather than refusing. It leaves nothing
|
|
193
|
+
behind for the queue to be rebuilt from — use it to retire a queue for good, not to empty one.
|
|
194
|
+
|
|
191
195
|
Each `get*Jobs` and `getJob` result has this shape:
|
|
192
196
|
|
|
193
197
|
```js
|
package/lib/jobQueue/queue.js
CHANGED
|
@@ -64,6 +64,13 @@ class Queue extends AbstractJobComponent {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
|
|
67
|
+
// Forced because bullmq refuses to obliterate a queue that still has active jobs. A caller
|
|
68
|
+
// tearing a queue down wants those gone too.
|
|
69
|
+
async obliterate() {
|
|
70
|
+
await this.#queue.obliterate({ force: true });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
67
74
|
/***********************************************************************************************
|
|
68
75
|
* LIMITS
|
|
69
76
|
***********************************************************************************************/
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Search
|
|
2
|
+
`Search` is a backend-agnostic search client. A caller chains a query in plain vocabulary
|
|
3
|
+
(`equals`, `range`, `search`) and a backend subclass turns it into whatever its engine speaks.
|
|
4
|
+
`OpenSearchClient` is the only backend shipped today; the vocabulary carries no OpenSearch syntax,
|
|
5
|
+
so another engine can be added without changing a single call site.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Setup
|
|
9
|
+
|
|
10
|
+
An OpenSearch client signs its requests with AWS SigV4 and reads four environment variables:
|
|
11
|
+
|
|
12
|
+
| Variable | Purpose |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `OPENSEARCH_REGION` | AWS region of the collection. Falls back to `DEFAULT_AWS_REGION`. |
|
|
15
|
+
| `DEFAULT_AWS_REGION` | Region used when `OPENSEARCH_REGION` is unset. |
|
|
16
|
+
| `OPENSEARCH_AWS_KEY` | Access key id. |
|
|
17
|
+
| `OPENSEARCH_AWS_SECRET` | Secret access key. |
|
|
18
|
+
|
|
19
|
+
The collection endpoint is not an environment variable the library reads. Each subclass declares
|
|
20
|
+
its own, so one application can talk to several collections. Environments are kept apart by
|
|
21
|
+
pointing that endpoint at a different collection per deploy — index names are not namespaced.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## Defining a client
|
|
25
|
+
|
|
26
|
+
One subclass per index. It declares where the index lives, what it is called, and what its mapping
|
|
27
|
+
should be.
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
const { OpenSearchClient } = require('@vida-global/core');
|
|
31
|
+
|
|
32
|
+
class MessageSearchClient extends OpenSearchClient {
|
|
33
|
+
get endpoint() { return process.env.OPENSEARCH_MESSAGE_NODE; }
|
|
34
|
+
get index() { return 'messages'; }
|
|
35
|
+
|
|
36
|
+
get mapping() {
|
|
37
|
+
return {
|
|
38
|
+
properties: {
|
|
39
|
+
roomId: { type: 'keyword' },
|
|
40
|
+
message: { type: 'text' },
|
|
41
|
+
timestamp: { type: 'date' }
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
## Searching
|
|
50
|
+
|
|
51
|
+
`query()` returns a `Query`. Every method on it returns a **new** `Query`, so a part-built query is
|
|
52
|
+
safe to hold onto and reuse. `fetch()` runs it.
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
const client = new MessageSearchClient();
|
|
56
|
+
|
|
57
|
+
const { total, results } = await client.query()
|
|
58
|
+
.equals('status', 'success')
|
|
59
|
+
.range('timestamp', { gte: startAt, lte: endAt })
|
|
60
|
+
.sort('timestamp', 'desc')
|
|
61
|
+
.limit(50)
|
|
62
|
+
.fetch();
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`fetch` resolves to `{ total, results }`. `total` is the exact number of matching documents, not
|
|
66
|
+
the number on this page. Each entry in `results` is `{ id, score, document }`, where `document` is
|
|
67
|
+
what was stored. Override `_buildResult(hit)` on a subclass to reshape a single result.
|
|
68
|
+
|
|
69
|
+
`AbstractSearchClient` assembles that envelope from five small hooks the backend implements:
|
|
70
|
+
`_hitsFromResponse`, `_totalFromResponse`, `_idFromHit`, `_scoreFromHit`, and `_documentFromHit`.
|
|
71
|
+
Override whichever one differs rather than rewriting the whole response loop.
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
### Conditions
|
|
75
|
+
|
|
76
|
+
| Method | Matches |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `equals(field, value)` | The field holds exactly this value |
|
|
79
|
+
| `in(field, values)` | The field holds any one of these values |
|
|
80
|
+
| `range(field, bounds)` | `bounds` is any of `gte`, `gt`, `lte`, `lt` |
|
|
81
|
+
| `exists(field)` | The field is present |
|
|
82
|
+
| `missing(field)` | The field is absent |
|
|
83
|
+
| `matches(field, text, options)` | Text match, fuzzy by default. `options.fuzziness` overrides it |
|
|
84
|
+
| `phrase(field, text)` | The words appear together, in order |
|
|
85
|
+
| `fullText(text, options)` | Text match across several fields at once, with per-field weights |
|
|
86
|
+
| `nested(path, query)` | A sub-query against an array of objects |
|
|
87
|
+
|
|
88
|
+
Field names are passed through untouched. OpenSearch stores a string field twice — analyzed for
|
|
89
|
+
text search, and exact under a `.keyword` suffix — so an exact match on a text field asks for it
|
|
90
|
+
by name:
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
client.query().equals('roomId.keyword', roomId)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
### Weighting fields in a text search
|
|
98
|
+
|
|
99
|
+
`fullText` looks in several fields at once. `boost` makes a hit in one field count for more than a
|
|
100
|
+
hit in another. Anything left out of `boost` carries a weight of 1.
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
client.query().fullText(queryText, {
|
|
104
|
+
fields: ['message', 'fromUser', 'summary'],
|
|
105
|
+
boost: { fromUser: 5, summary: 5 }
|
|
106
|
+
})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Pass `wildcard: true` to match partial words. It finds `invoice` when the user types `voic`, but it
|
|
110
|
+
is slow on a large index and it weakens relevance scoring, so leave it off unless partial matching
|
|
111
|
+
is what you want.
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
### Combining conditions
|
|
115
|
+
|
|
116
|
+
Chained conditions are combined with AND. `or` and `not` take either a condition or a whole
|
|
117
|
+
sub-query, and both accept several at once:
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
client.query()
|
|
121
|
+
.equals('status', 'success')
|
|
122
|
+
.or(
|
|
123
|
+
client.query().equals('from', userId),
|
|
124
|
+
client.query().equals('to', userId)
|
|
125
|
+
)
|
|
126
|
+
.not(client.query().exists('deletedAt'))
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
One `or(a, b)` call builds one group: the record must match `a` or `b`. Calling `or` twice adds to
|
|
130
|
+
that same group. For two independent groups, nest a sub-query in each.
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
### Paging and ordering
|
|
134
|
+
|
|
135
|
+
`limit`, `offset`, `sort`, and `minScore` chain like conditions and travel with the query.
|
|
136
|
+
|
|
137
|
+
`minScore` drops weak matches. The engine applies it, so `total` counts only what survived.
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
### Reading across date-partitioned indexes
|
|
141
|
+
|
|
142
|
+
Pass a suffix to the constructor to write into a dated index. Reads then target that one index
|
|
143
|
+
unless you ask for the wildcard.
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
const client = new ApiLogSearchClient('2026-10');
|
|
147
|
+
|
|
148
|
+
// writes api-logs-2026-10
|
|
149
|
+
await client.append(logObject);
|
|
150
|
+
|
|
151
|
+
// reads api-logs-2026-10
|
|
152
|
+
await client.query().equals('accountId', id).fetch();
|
|
153
|
+
|
|
154
|
+
// reads api-logs-*
|
|
155
|
+
await client.query().equals('accountId', id).fetch({ indexWildcard: true });
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
## Writing
|
|
160
|
+
|
|
161
|
+
| Method | What it does |
|
|
162
|
+
|---|---|
|
|
163
|
+
| `upsert(id, document)` | Creates the document, or merges into it if the id already exists |
|
|
164
|
+
| `append(document)` | Writes a new document and lets the engine assign the id |
|
|
165
|
+
| `bulkUpsert(documents)` | One request for many. Each entry is `{ id, document }` |
|
|
166
|
+
| `delete(id)` | Removes one document |
|
|
167
|
+
| `deleteWhere(query)` | Removes everything the query matches. Returns how many |
|
|
168
|
+
|
|
169
|
+
Every write throws on failure. `bulkUpsert` throws `BulkWriteError`, which carries a `failures`
|
|
170
|
+
list of `{ id, reason }` — a bulk request answers `200` even when individual documents fail, so
|
|
171
|
+
the error is built from the response body, not the status code.
|
|
172
|
+
|
|
173
|
+
`deleteWhere` searches a page, deletes each id, and repeats until nothing matches. OpenSearch
|
|
174
|
+
Serverless has no delete-by-query API, so there is no single-request version. A failure stops the
|
|
175
|
+
loop and throws; what was already deleted stays deleted.
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
### Collection types
|
|
179
|
+
|
|
180
|
+
An OpenSearch Serverless collection has a type, and only a `SEARCH` collection accepts a write
|
|
181
|
+
addressed to a specific id. Nothing is declared up front: when the collection refuses such a
|
|
182
|
+
write, `upsert` and `bulkUpsert` catch the refusal and raise `UnsupportedOperationError` with the
|
|
183
|
+
original error attached as `cause`.
|
|
184
|
+
|
|
185
|
+
`_isUnsupportedOperation(error)` is what reads a refusal out of an OpenSearch error. Override it
|
|
186
|
+
on a subclass if the message you see does not match.
|
|
187
|
+
|
|
188
|
+
`append` works on every collection type.
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
## Index mappings
|
|
192
|
+
|
|
193
|
+
A mapping is the index's schema. `syncIndex` makes the live index match what the subclass
|
|
194
|
+
declares: it creates the index if it is missing, then pushes the mapping.
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
await MessageSearchClient.syncIndex();
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
OpenSearch accepts a new field on an existing index but rejects a changed type on an existing
|
|
201
|
+
field. `syncIndex` does not diff anything — it sends the mapping and lets a conflict throw.
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
## Retries
|
|
205
|
+
|
|
206
|
+
Nothing retries by default. A subclass opts in by raising `numRetries`:
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
class MessageSearchClient extends OpenSearchClient {
|
|
210
|
+
get numRetries() { return 3; }
|
|
211
|
+
get retryBackoffDelay() { return 1000; }
|
|
212
|
+
get retryMaxDelay() { return 8000; }
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`OpenSearchClient` only retries server errors — status 500 through 599. Override `shouldRetry`
|
|
217
|
+
to change that.
|
|
218
|
+
|
|
219
|
+
Retries cover `fetch`, `upsert`, `delete`, and `syncIndex`. They never cover `append` or
|
|
220
|
+
`bulkUpsert`: those carry no caller-supplied id, so re-sending a request that actually succeeded
|
|
221
|
+
would write the document twice.
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
## Adding a backend
|
|
225
|
+
|
|
226
|
+
Subclass `AbstractSearchClient` and implement the hooks it declares: `_serializeConditions`,
|
|
227
|
+
`_execute`, the five response hooks, the four write hooks, and the three index hooks.
|
|
228
|
+
`AbstractSearchClient` owns the parts that are not engine-specific — the retry loop, the
|
|
229
|
+
`deleteWhere` loop, the `syncIndex` order, and the result envelope.
|
|
230
|
+
|
|
231
|
+
Collaborators are reached through seams, so tests inject doubles by subclassing and overriding
|
|
232
|
+
`openSearchClientClass`, `awsSignerFactory`, or `loggerLibrary`.
|