@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
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=false)
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});
@@ -0,0 +1,6 @@
1
+ class MigrationError extends Error {}
2
+
3
+
4
+ module.exports = {
5
+ MigrationError
6
+ };
@@ -1,7 +1,8 @@
1
- const { Connection } = require('./connection');
2
- const utils = require('../utils');
3
- const { Sequelize, Op } = require('sequelize');
4
- const { underscore } = require('inflection');
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 Error('Missing migration `up`');
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 Error(`Unknown id type "${idType}". Use one of: ${ID_TYPES.join(', ')}, or `
126
- + `false for a table that declares its own primary key.`);
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 = name || this.defaultIndexName(tableName, fields);
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=false) {
228
- const options = this.queryOptions;
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
  ***********************************************************************************************/
@@ -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:
@@ -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
 
@@ -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
@@ -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`.