@vida-global/core 2.4.4 → 2.5.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/index.js CHANGED
@@ -1,7 +1,7 @@
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');
@@ -29,6 +29,7 @@ module.exports = {
29
29
  ...serverErrorsToExport,
30
30
  ApiDocsGenerator,
31
31
  AppDependencies,
32
+ DependencyRegistry,
32
33
  VidaServer,
33
34
  VidaServerController,
34
35
  };
@@ -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
 
@@ -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
  ***********************************************************************************************/
@@ -1,4 +1,4 @@
1
- class AppDependencies {
1
+ class DependencyRegistry {
2
2
  #dependencies = {};
3
3
  #controllerClass = null;
4
4
 
@@ -56,5 +56,6 @@ class AppDependencies {
56
56
 
57
57
 
58
58
  module.exports = {
59
- AppDependencies: new AppDependencies()
59
+ AppDependencies: new DependencyRegistry(),
60
+ DependencyRegistry
60
61
  };
@@ -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
  ***********************************************************************************************/
@@ -38,6 +38,7 @@ const InstanceMethods = {
38
38
  const errors = [];
39
39
 
40
40
  if (parameterValidations?.optional && value === undefined) return errors;
41
+ if (parameterValidations?.nullable === true && value === null) return errors;
41
42
 
42
43
  for (const [validationType, validationOptions] of Object.entries(parameterValidations)) {
43
44
  const validationMethod = `validate${camelize(validationType)}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "2.4.4",
3
+ "version": "2.5.0",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -1,4 +1,5 @@
1
- const Helpers = require('../helpers/migrator');
1
+ const Helpers = require('../helpers/migrator');
2
+ const { MigrationError } = require('../../../lib/activeRecord/db/migrationErrors');
2
3
 
3
4
 
4
5
  afterEach(() => {
@@ -422,13 +423,13 @@ describe('Migrator', () => {
422
423
 
423
424
  describe('Migrator#removeIndex', () => {
424
425
  it.each([
425
- ['default options', false, { transaction: null }],
426
- ['concurrently', true, { concurrently: true, transaction: null }],
427
- ])('passes removeIndex options for %s', async (_label, concurrently, expectedOptions) => {
426
+ ['default options', {}, { transaction: null }],
427
+ ['concurrently', { concurrently: true }, { concurrently: true, transaction: null }],
428
+ ])('passes removeIndex options for %s', async (_label, indexOptions, expectedOptions) => {
428
429
  const tableName = Helpers.randomString();
429
430
  const fields = [Helpers.randomString(), Helpers.randomString()];
430
431
 
431
- await Helpers.migrator.removeIndex(tableName, fields, concurrently);
432
+ await Helpers.migrator.removeIndex(tableName, fields, indexOptions);
432
433
 
433
434
  expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledTimes(1);
434
435
  expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledWith(
@@ -453,6 +454,17 @@ describe('Migrator', () => {
453
454
  });
454
455
 
455
456
 
457
+ describe('Migrator concurrent indexes inside a transaction', () => {
458
+ it.each([
459
+ ['addIndex', async function() { await this.addIndex('users', ['team_id'], { concurrently: true }); }],
460
+ ['removeIndex', async function() { await this.removeIndex('users', ['team_id'], { concurrently: true }); }],
461
+ ])('throws when %s asks for concurrently', async (methodName, operation) => {
462
+ await expect(Helpers.runInMigration(operation)).rejects.toThrow(MigrationError);
463
+ expect(Helpers.mockQueryInterface[methodName]).not.toHaveBeenCalled();
464
+ });
465
+ });
466
+
467
+
456
468
  describe('Migrator#defaultIndexName', () => {
457
469
  it ('builds idx_<table>_<columns> from a single column', () => {
458
470
  expect(Helpers.migrator.defaultIndexName('users', ['team_id'])).toBe('idx_users_team_id');
@@ -1,4 +1,4 @@
1
- const { AppDependencies } = require('../../lib/appDependencies');
1
+ const { AppDependencies, DependencyRegistry } = require('../../lib/appDependencies');
2
2
 
3
3
 
4
4
  class FooController {}
@@ -129,3 +129,40 @@ describe('AppDependencies', () => {
129
129
  });
130
130
  });
131
131
  });
132
+
133
+
134
+ describe('DependencyRegistry', () => {
135
+ it ('builds registries that are independent of the AppDependencies singleton', () => {
136
+ const registry = new DependencyRegistry();
137
+
138
+ registry.setDependency('scopedHelper', 'scoped');
139
+
140
+ expect(registry.get('scopedHelper')).toBe('scoped');
141
+ expect(AppDependencies.get('scopedHelper')).toBeNull();
142
+ });
143
+
144
+
145
+ it ('builds registries that are independent of each other', () => {
146
+ const first = new DependencyRegistry();
147
+ const second = new DependencyRegistry();
148
+
149
+ first.setDependency('myHelper', 'one');
150
+ second.setDependency('myHelper', 'two');
151
+
152
+ expect(first.myHelper).toBe('one');
153
+ expect(second.myHelper).toBe('two');
154
+ });
155
+
156
+
157
+ it ('resets one registry without touching another', () => {
158
+ const first = new DependencyRegistry();
159
+ const second = new DependencyRegistry();
160
+ first.setDependency('myHelper', 'one');
161
+ second.setDependency('myHelper', 'two');
162
+
163
+ first.reset();
164
+
165
+ expect(first.get('myHelper')).toBeNull();
166
+ expect(second.get('myHelper')).toBe('two');
167
+ });
168
+ });
@@ -181,6 +181,48 @@ describe('HttpClient', () => {
181
181
  });
182
182
 
183
183
 
184
+ describe('binary responses', () => {
185
+ const client = new HttpClient();
186
+
187
+
188
+ beforeEach(() => Helpers.mockFetchBinary());
189
+
190
+
191
+ it ('returns the exact bytes as a Buffer', async () => {
192
+ const result = await client.get(Helpers.apiUrl, { binaryResponse: true });
193
+
194
+ expect(result.status).toBe(200);
195
+ expect(Buffer.isBuffer(result.data)).toBe(true);
196
+ expect(result.data).toEqual(Helpers.binaryPayload);
197
+ });
198
+
199
+
200
+ // The point of the option: a text read replaces every byte that is not valid UTF-8.
201
+ it ('never reads the body as text', async () => {
202
+ await client.get(Helpers.apiUrl, { binaryResponse: true });
203
+
204
+ const response = await fetch.mock.results[0].value;
205
+ expect(response.text).not.toHaveBeenCalled();
206
+ });
207
+
208
+
209
+ it ('reads the body as text without the option, losing the bytes', async () => {
210
+ const result = await client.get(Helpers.apiUrl);
211
+
212
+ expect(typeof result.data).toBe('string');
213
+ expect(Buffer.from(result.data)).not.toEqual(Helpers.binaryPayload);
214
+ });
215
+
216
+
217
+ const verbs = [['post'], ['put'], ['delete']];
218
+ it.each(verbs)('applies to %s as well', async (methodName) => {
219
+ const result = await client[methodName](Helpers.apiUrl, { binaryResponse: true });
220
+
221
+ expect(result.data).toEqual(Helpers.binaryPayload);
222
+ });
223
+ });
224
+
225
+
184
226
  describe('abort/timeout support', () => {
185
227
  const client = new HttpClient();
186
228
 
@@ -18,6 +18,10 @@ const apiUrl = `${baseUrl}${endpoint}`;
18
18
  const responsePayload = randomPayload();
19
19
  const responseBody = JSON.stringify(responsePayload);
20
20
 
21
+ // A RIFF header followed by byte sequences that are not valid UTF-8, so a text read cannot
22
+ // reproduce them.
23
+ const binaryPayload = Buffer.from([0x52, 0x49, 0x46, 0x46, 0xff, 0xfe, 0x00, 0x80]);
24
+
21
25
 
22
26
  class ClientWithUrlRoot extends HttpClient {
23
27
  get urlRoot() {
@@ -67,11 +71,19 @@ const mockFetchResponse = ({ body, contentType, status = 200 }) => {
67
71
  get: jest.fn((headerName) => headerName == 'content-type' ? contentType : undefined),
68
72
  },
69
73
  status,
70
- text: jest.fn().mockResolvedValue(body),
74
+ // Interpolated rather than passed through, so a Buffer body decodes the way a real text
75
+ // read would.
76
+ text: jest.fn().mockResolvedValue(`${body ?? ''}`),
77
+ arrayBuffer: jest.fn().mockResolvedValue(Buffer.from(body ?? '')),
71
78
  });
72
79
  }
73
80
 
74
81
 
82
+ const mockFetchBinary = () => {
83
+ mockFetchResponse({ body: binaryPayload, contentType: 'audio/wav' });
84
+ }
85
+
86
+
75
87
  /***************************************************************************************************
76
88
  * ASSERTIONS
77
89
  ***************************************************************************************************/
@@ -109,11 +121,13 @@ module.exports = {
109
121
  apiUrl,
110
122
  basicAuthCredentials,
111
123
  bearerToken,
124
+ binaryPayload,
112
125
  ClientWithBasicAuth,
113
126
  ClientWithBearerToken,
114
127
  ClientWithUrlRoot,
115
128
  endpoint,
116
129
  expectRequest,
130
+ mockFetchBinary,
117
131
  mockFetchResponse,
118
132
  mockFetchSuccess,
119
133
  randomPayload,
@@ -16,6 +16,7 @@ class MockBullQueue {
16
16
  this.close = jest.fn();
17
17
  this.pause = jest.fn();
18
18
  this.resume = jest.fn();
19
+ this.obliterate = jest.fn();
19
20
  this.getJobCounts = jest.fn();
20
21
  this.getJobs = jest.fn();
21
22
  this.getActive = jest.fn();
@@ -218,6 +218,17 @@ describe('Queue', () => {
218
218
  });
219
219
 
220
220
 
221
+ describe('#obliterate', () => {
222
+ it ('calls bullmq obliterate() with force so active jobs do not block it', async () => {
223
+ await queue.obliterate();
224
+
225
+ const instance = bullmqMock.instances.queues[0];
226
+ expect(instance.obliterate).toHaveBeenCalledTimes(1);
227
+ expect(instance.obliterate).toHaveBeenCalledWith({ force: true });
228
+ });
229
+ });
230
+
231
+
221
232
  describe('maximum concurrency', () => {
222
233
  it ('sets the queue maximum', async () => {
223
234
  const maximum = TestHelpers.Faker.Math.randomNumber(20);