@vida-global/core 2.3.4 → 2.3.6
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/lib/activeRecord/README.md +8 -0
- package/lib/activeRecord/db/migrator.js +18 -3
- package/lib/server/openApi/apiDocGenerator.js +1 -1
- package/lib/utils/envFile.js +14 -0
- package/package.json +2 -1
- package/scripts/activeRecord/migrate.js +17 -4
- package/test/activeRecord/db/migrator.test.js +89 -2
- package/test/activeRecord/helpers/migrator.js +5 -0
- package/test/server/apiDocGenerator.test.js +39 -0
- package/test/utils/envFile.test.js +46 -0
- package/test/utils/helpers/envFile.fixture +2 -0
|
@@ -72,6 +72,14 @@ Wire up the migration CLI by adding scripts to `package.json`:
|
|
|
72
72
|
|
|
73
73
|
Generate a migration with `npm run db:create_migration createUsers`. Pass `-- --databaseId metrics` to scope the migration to a non-default database. Run `npm run db:migrate` to apply migrations or `npm run db:rollback` to undo the most recent one.
|
|
74
74
|
|
|
75
|
+
`db:migrate` and `db:rollback` take an optional `--envFile <path>`. Both open a database connection, and the CLI runs as a bare node process, so nothing has loaded the environment that `config/db/config.js` reads from. Point the flag at a `.env` file and it is loaded before the command runs:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
npm run db:migrate -- --envFile .env.staging
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A variable already set in the environment wins over the file, so an inline override on the command line still takes effect. Leave the flag off and nothing is loaded, which is what an application that gets its environment some other way wants. `db:create_migration` only writes a file, so it does not accept the flag.
|
|
82
|
+
|
|
75
83
|
Migration files are named `<timestamp>_<description>.js`, with the database id appended before the extension when one is given — for example `1754500000000_createUsers.js` or `1754500000000_createUsers.metrics.js`. The leading timestamp is the version recorded in `migration_versions` and the order migrations run in, so files sort chronologically by name. Files that don't match this pattern are ignored.
|
|
76
84
|
|
|
77
85
|
```js
|
|
@@ -143,9 +143,10 @@ class Migrator {
|
|
|
143
143
|
get uuidIdColumn() {
|
|
144
144
|
return {
|
|
145
145
|
id: {
|
|
146
|
-
allowNull:
|
|
147
|
-
primaryKey:
|
|
148
|
-
type:
|
|
146
|
+
allowNull: false,
|
|
147
|
+
primaryKey: true,
|
|
148
|
+
type: this.DataTypes.UUID,
|
|
149
|
+
defaultValue: Sequelize.literal('gen_random_uuid()'), }
|
|
149
150
|
};
|
|
150
151
|
}
|
|
151
152
|
|
|
@@ -187,6 +188,20 @@ class Migrator {
|
|
|
187
188
|
}
|
|
188
189
|
|
|
189
190
|
|
|
191
|
+
async tableExists(tableName) {
|
|
192
|
+
const tables = await this.#sequelizeQueryInterface.showAllTables(this.queryOptions);
|
|
193
|
+
return tables.some(table => (table?.tableName ?? table) == tableName);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
// Escape hatch for DDL the typed methods cannot express — CHECK constraints, sequences shared
|
|
198
|
+
// between tables. Runs inside the migration transaction, so it rolls back with everything else.
|
|
199
|
+
// Prefer the typed methods; reach for this only when there is no other way.
|
|
200
|
+
async execute(sql, replacements) {
|
|
201
|
+
return await this.#connection._sequelize.query(sql, {...this.queryOptions, replacements});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
190
205
|
async addColumn(tableName, columnName, columnDetails) {
|
|
191
206
|
await this.#sequelizeQueryInterface.addColumn(tableName, columnName, columnDetails, this.queryOptions);
|
|
192
207
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const dotenv = require('dotenv');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
function loadEnvFile(filePath) {
|
|
6
|
+
if (!filePath) return false;
|
|
7
|
+
if (!fs.existsSync(filePath)) throw new Error(`Environment file ${filePath} does not exist`);
|
|
8
|
+
|
|
9
|
+
dotenv.config({ path: filePath, quiet: true });
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
module.exports = { loadEnvFile };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vida-global/core",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.6",
|
|
4
4
|
"description": "Core libraries for supporting Vida development",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "ISC",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"bullmq": "^5.0.0",
|
|
25
25
|
"commander": "^13.1.0",
|
|
26
26
|
"cookie-parser": "^1.4.7",
|
|
27
|
+
"dotenv": "^16.6.1",
|
|
27
28
|
"express": "^4.21.2",
|
|
28
29
|
"express-request-id": "1.4.1",
|
|
29
30
|
"express-winston": "^4.0.0",
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
const
|
|
2
|
-
const
|
|
1
|
+
const { loadEnvFile } = require('../../lib/utils/envFile');
|
|
2
|
+
const migration = require('../../lib/activeRecord/db/migration');
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
// Declared on the commands that touch the database rather than on the program, so it can be passed
|
|
7
|
+
// after the command name, which is where npm puts it: `npm run db:migrate -- --envFile .env.staging`.
|
|
8
|
+
function withEnvFileOption(command) {
|
|
9
|
+
return command.option('--envFile <path>', 'a .env file to load before the command runs');
|
|
10
|
+
}
|
|
3
11
|
|
|
4
12
|
|
|
5
13
|
const program = new Command();
|
|
@@ -7,6 +15,10 @@ program.name('Active Record Migration')
|
|
|
7
15
|
.description('A CLI for generating and running Active Record migrations')
|
|
8
16
|
.version('1.0.0');
|
|
9
17
|
|
|
18
|
+
program.hook('preAction', (_program, command) => {
|
|
19
|
+
loadEnvFile(command.opts().envFile);
|
|
20
|
+
});
|
|
21
|
+
|
|
10
22
|
|
|
11
23
|
program.command('create_migration')
|
|
12
24
|
.argument('<Description>', 'Description of your migration')
|
|
@@ -16,12 +28,13 @@ program.command('create_migration')
|
|
|
16
28
|
});
|
|
17
29
|
|
|
18
30
|
|
|
19
|
-
program.command('migrate')
|
|
31
|
+
withEnvFileOption(program.command('migrate'))
|
|
20
32
|
.action(async () => {
|
|
21
33
|
await migration.runMigrations();
|
|
22
34
|
});
|
|
23
35
|
|
|
24
|
-
|
|
36
|
+
|
|
37
|
+
withEnvFileOption(program.command('rollback'))
|
|
25
38
|
.action(async () => {
|
|
26
39
|
await migration.rollbackMigration();
|
|
27
40
|
});
|
|
@@ -161,13 +161,28 @@ describe('Migrator', () => {
|
|
|
161
161
|
expect(columns.id).toEqual({ allowNull: false, autoIncrement: true, primaryKey: true, type: 'INTEGER' });
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
-
it ('gives the table a uuid id
|
|
164
|
+
it ('gives the table a uuid id the database generates', async () => {
|
|
165
165
|
const tableName = Helpers.randomString();
|
|
166
166
|
|
|
167
167
|
await Helpers.migrator.createTable(tableName, {}, { id: 'uuid' });
|
|
168
168
|
|
|
169
169
|
const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
|
|
170
|
-
expect(columns.id).toEqual({
|
|
170
|
+
expect(columns.id).toEqual({
|
|
171
|
+
allowNull: false,
|
|
172
|
+
primaryKey: true,
|
|
173
|
+
type: 'UUID',
|
|
174
|
+
defaultValue: { literal: 'gen_random_uuid()' },
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
it ('does not give an auto-increment id a database default', async () => {
|
|
180
|
+
const tableName = Helpers.randomString();
|
|
181
|
+
|
|
182
|
+
await Helpers.migrator.createTable(tableName, {}, { id: 'bigint' });
|
|
183
|
+
|
|
184
|
+
const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
|
|
185
|
+
expect(columns.id).not.toHaveProperty('defaultValue');
|
|
171
186
|
});
|
|
172
187
|
|
|
173
188
|
it ('refuses an id type it does not recognize', async () => {
|
|
@@ -297,6 +312,78 @@ describe('Migrator', () => {
|
|
|
297
312
|
});
|
|
298
313
|
|
|
299
314
|
|
|
315
|
+
describe('Migrator#tableExists', () => {
|
|
316
|
+
it ('reports a table that is present', async () => {
|
|
317
|
+
const tableName = Helpers.randomString();
|
|
318
|
+
Helpers.mockQueryInterface.showAllTables.mockResolvedValue([Helpers.randomString(), tableName]);
|
|
319
|
+
|
|
320
|
+
await expect(Helpers.migrator.tableExists(tableName)).resolves.toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it ('reports a table that is absent', async () => {
|
|
324
|
+
Helpers.mockQueryInterface.showAllTables.mockResolvedValue([Helpers.randomString()]);
|
|
325
|
+
|
|
326
|
+
await expect(Helpers.migrator.tableExists(Helpers.randomString())).resolves.toBe(false);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Some dialects return objects rather than bare names.
|
|
330
|
+
it ('reads a name off an object entry', async () => {
|
|
331
|
+
const tableName = Helpers.randomString();
|
|
332
|
+
Helpers.mockQueryInterface.showAllTables.mockResolvedValue([{ tableName }]);
|
|
333
|
+
|
|
334
|
+
await expect(Helpers.migrator.tableExists(tableName)).resolves.toBe(true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it ('runs inside the open transaction', async () => {
|
|
338
|
+
Helpers.mockQueryInterface.showAllTables.mockResolvedValue([]);
|
|
339
|
+
|
|
340
|
+
await Helpers.runInMigration(async function() {
|
|
341
|
+
await this.tableExists(Helpers.randomString());
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(Helpers.mockQueryInterface.showAllTables).toHaveBeenCalledWith(Helpers.inTransaction);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
describe('Migrator#execute', () => {
|
|
350
|
+
it ('runs the statement and returns its result', async () => {
|
|
351
|
+
const sql = `CREATE SEQUENCE ${Helpers.randomString()}`;
|
|
352
|
+
const result = {};
|
|
353
|
+
Helpers.mockQuery.mockResolvedValue(result);
|
|
354
|
+
|
|
355
|
+
await expect(Helpers.migrator.execute(sql)).resolves.toBe(result);
|
|
356
|
+
|
|
357
|
+
expect(Helpers.mockQuery).toHaveBeenCalledWith(sql, {
|
|
358
|
+
...Helpers.noTransaction,
|
|
359
|
+
replacements: undefined,
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it ('passes replacements through for binding', async () => {
|
|
364
|
+
const sql = 'SELECT :value';
|
|
365
|
+
const replacements = { value: Helpers.randomString() };
|
|
366
|
+
Helpers.mockQuery.mockResolvedValue(undefined);
|
|
367
|
+
|
|
368
|
+
await Helpers.migrator.execute(sql, replacements);
|
|
369
|
+
|
|
370
|
+
expect(Helpers.mockQuery).toHaveBeenCalledWith(sql, expect.objectContaining({ replacements }));
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Raw statements have to roll back with the rest of the migration.
|
|
374
|
+
it ('runs inside the open transaction', async () => {
|
|
375
|
+
const sql = `CREATE SEQUENCE ${Helpers.randomString()}`;
|
|
376
|
+
Helpers.mockQuery.mockResolvedValue(undefined);
|
|
377
|
+
|
|
378
|
+
await Helpers.runInMigration(async function() {
|
|
379
|
+
await this.execute(sql);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(Helpers.mockQuery).toHaveBeenCalledWith(sql, expect.objectContaining(Helpers.inTransaction));
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
|
|
300
387
|
describe('Migrator#renameColumn', () => {
|
|
301
388
|
it ('snake_cases renameColumn target', async () => {
|
|
302
389
|
const tableName = Helpers.randomString();
|
|
@@ -16,6 +16,7 @@ const randomString = TestHelpers.Faker.Text.randomString;
|
|
|
16
16
|
const transaction = { id: randomString() };
|
|
17
17
|
const noTransaction = { transaction: null };
|
|
18
18
|
const inTransaction = { transaction };
|
|
19
|
+
const mockQuery = jest.fn();
|
|
19
20
|
const mockQueryInterface = {
|
|
20
21
|
createTable: jest.fn(),
|
|
21
22
|
dropTable: jest.fn(),
|
|
@@ -25,6 +26,7 @@ const mockQueryInterface = {
|
|
|
25
26
|
removeIndex: jest.fn(),
|
|
26
27
|
renameColumn: jest.fn(),
|
|
27
28
|
changeColumn: jest.fn(),
|
|
29
|
+
showAllTables: jest.fn(),
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
|
|
@@ -39,10 +41,12 @@ function randomColumnDetails() {
|
|
|
39
41
|
jest.mock('sequelize', () => {
|
|
40
42
|
class MockSequelize {
|
|
41
43
|
static DataTypes = { BIGINT: 'BIGINT', DATE: 'DATE', INTEGER: 'INTEGER', UUID: 'UUID' };
|
|
44
|
+
static literal(sql) { return { literal: sql }; }
|
|
42
45
|
}
|
|
43
46
|
return { Sequelize: MockSequelize };
|
|
44
47
|
});
|
|
45
48
|
Sequelize.prototype.getQueryInterface = () => mockQueryInterface;
|
|
49
|
+
Sequelize.prototype.query = (...args) => mockQuery(...args);
|
|
46
50
|
|
|
47
51
|
|
|
48
52
|
jest.spyOn(ConnectionConfiguration, '_fetchAllConfigs').mockImplementation(() => ({
|
|
@@ -81,6 +85,7 @@ module.exports = {
|
|
|
81
85
|
inTransaction,
|
|
82
86
|
migrationModule,
|
|
83
87
|
migrator,
|
|
88
|
+
mockQuery,
|
|
84
89
|
mockQueryInterface,
|
|
85
90
|
noTransaction,
|
|
86
91
|
randomColumnDetails,
|
|
@@ -70,6 +70,15 @@ describe('ApiDocGenerator', () => {
|
|
|
70
70
|
expect(generator.endpoint).toEqual(formattedUrl);
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
it ('removes the Express optional marker from a path parameter', () => {
|
|
74
|
+
const requiredName = Faker.Text.randomString();
|
|
75
|
+
const optionalName = Faker.Text.randomString();
|
|
76
|
+
const action = { path: `/things/:${requiredName}/:${optionalName}?` };
|
|
77
|
+
const generator = new ApiDocGenerator(action, ControllerClass);
|
|
78
|
+
|
|
79
|
+
expect(generator.endpoint).toEqual(`/things/{${requiredName}}/{${optionalName}}`);
|
|
80
|
+
});
|
|
81
|
+
|
|
73
82
|
it ('returns the path unchanged when there are no params', () => {
|
|
74
83
|
const path = `/${Faker.Text.randomString()}/${Faker.Text.randomString()}`;
|
|
75
84
|
const action = { path };
|
|
@@ -157,6 +166,25 @@ describe('ApiDocGenerator', () => {
|
|
|
157
166
|
expect(generator.pathParams.id.required).toBe(true);
|
|
158
167
|
});
|
|
159
168
|
|
|
169
|
+
it ('uses the validator definition for an optional Express path parameter', () => {
|
|
170
|
+
const parameterName = Faker.Text.randomString();
|
|
171
|
+
const description = Faker.Text.randomString();
|
|
172
|
+
const action = { action: 'getRecord', method: 'GET', path: `/things/:${parameterName}?` };
|
|
173
|
+
ControllerClass.parametersForAction.mockReturnValue({
|
|
174
|
+
[parameterName]: { isString: true, optional: true, description },
|
|
175
|
+
});
|
|
176
|
+
const generator = new ApiDocGenerator(action, ControllerClass);
|
|
177
|
+
|
|
178
|
+
expect(generator.pathParams[parameterName]).toEqual({
|
|
179
|
+
name: parameterName,
|
|
180
|
+
in: 'path',
|
|
181
|
+
required: true,
|
|
182
|
+
description,
|
|
183
|
+
schema: { type: 'string' },
|
|
184
|
+
});
|
|
185
|
+
expect(generator.pathParams[`${parameterName}?`]).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
160
188
|
it ('excludes parameters that are not in the path', () => {
|
|
161
189
|
const action = { action: 'getRecord', method: 'GET', path: '/user/:id' };
|
|
162
190
|
ControllerClass.parametersForAction.mockReturnValue({
|
|
@@ -214,6 +242,17 @@ describe('ApiDocGenerator', () => {
|
|
|
214
242
|
expect(Object.keys(generator.queryParams)).toEqual(['detail']);
|
|
215
243
|
});
|
|
216
244
|
|
|
245
|
+
it ('does not duplicate an optional Express path parameter as a query parameter', () => {
|
|
246
|
+
const parameterName = Faker.Text.randomString();
|
|
247
|
+
const action = { action: 'getRecord', method: 'GET', path: `/things/:${parameterName}?` };
|
|
248
|
+
ControllerClass.parametersForAction.mockReturnValue({
|
|
249
|
+
[parameterName]: { isString: true, optional: true },
|
|
250
|
+
});
|
|
251
|
+
const generator = new ApiDocGenerator(action, ControllerClass);
|
|
252
|
+
|
|
253
|
+
expect(generator.queryParams[parameterName]).toBeUndefined();
|
|
254
|
+
});
|
|
255
|
+
|
|
217
256
|
});
|
|
218
257
|
|
|
219
258
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const { loadEnvFile } = require('../../lib/utils/envFile');
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
const fixturePath = `${__dirname}/helpers/envFile.fixture`;
|
|
5
|
+
const fileKeys = ['CORE_TEST_ENV_FILE_VALUE', 'CORE_TEST_ENV_FILE_PRESET'];
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
fileKeys.forEach(key => delete process.env[key]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
fileKeys.forEach(key => delete process.env[key]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
describe('utils/envFile', () => {
|
|
19
|
+
describe('loadEnvFile', () => {
|
|
20
|
+
it ('does nothing and reports false when given no path', () => {
|
|
21
|
+
expect(loadEnvFile(undefined)).toBe(false);
|
|
22
|
+
expect(process.env.CORE_TEST_ENV_FILE_VALUE).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
it ('throws when the file does not exist', () => {
|
|
27
|
+
expect(() => loadEnvFile(`${fixturePath}.missing`))
|
|
28
|
+
.toThrow(`Environment file ${fixturePath}.missing does not exist`);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
it ('loads the variables in the file into the environment', () => {
|
|
33
|
+
expect(loadEnvFile(fixturePath)).toBe(true);
|
|
34
|
+
expect(process.env.CORE_TEST_ENV_FILE_VALUE).toEqual('from_file');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
it ('leaves a variable already set in the environment alone', () => {
|
|
39
|
+
process.env.CORE_TEST_ENV_FILE_PRESET = 'from_environment';
|
|
40
|
+
|
|
41
|
+
loadEnvFile(fixturePath);
|
|
42
|
+
|
|
43
|
+
expect(process.env.CORE_TEST_ENV_FILE_PRESET).toEqual('from_environment');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
});
|