@vida-global/core 2.2.0 → 2.2.1

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.
@@ -72,6 +72,8 @@ 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
+ 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
+
75
77
  ```js
76
78
  module.exports = {
77
79
  up: async function() {
@@ -102,7 +104,7 @@ createTable(tableName, details, { timestamps, paranoid })
102
104
  dropTable(tableName)
103
105
  addColumn(tableName, columnName, columnDetails)
104
106
  removeColumn(tableName, columnName)
105
- addIndex(tableName, fields, { concurrently, unique, name, where })
107
+ addIndex(tableName, fields, { concurrently, unique, name, using, where })
106
108
  removeIndex(tableName, indexNameOrAttributes, concurrently=false)
107
109
  renameColumn(tableName, oldName, newName)
108
110
  changeColumn(tableName, columnName, dataTypeOrOptions)
@@ -111,9 +113,30 @@ changeColumn(tableName, columnName, dataTypeOrOptions)
111
113
  **Conventions:**
112
114
 
113
115
  - `createTable` adds an auto-increment `id` primary key and `created_at` / `updated_at` columns by default. Pass `{ timestamps: false }` to skip the timestamps.
114
- - Pass `{ paranoid: true }` to add a nullable `deleted_at` column. Models over that table soft delete: `initializationOptions` turns on `paranoid` for any table whose schema has `deleted_at`, so `destroy()` sets the column and ordinary queries skip the row. Unique indexes on a paranoid table should be partial — `where: { deleted_at: null }` — or a deleted row will still block re-creating the same one.
116
+ - Pass `{ paranoid: true }` to `createTable` to add a nullable `deleted_at` column for soft deletes.
117
+ - Models over that table soft delete without any extra configuration: `initializationOptions` turns on Sequelize's `paranoid` for any table whose schema has `deleted_at` alongside the timestamp columns, so `destroy()` sets `deleted_at` and ordinary queries skip the row. A table created with `{ paranoid: true, timestamps: false }` has the column but does not qualify.
118
+ - Unique indexes on a paranoid table should be partial — `where: { deleted_at: null }` — or a soft-deleted row will still block re-creating the same one.
119
+ - An index created without a `name` gets `idx_<table>_<columns>`, so `addIndex('users', ['team_id', 'email'])` creates `idx_users_team_id_email`. `removeIndex` applies the same default, which means an index added by its columns can be dropped by its columns. Postgres truncates identifiers at 63 characters, so name long multi-column indexes explicitly.
115
120
  - Column names are automatically converted to snake_case, so `teamId` in the details object becomes `team_id` in the database.
116
- - Each migration runs inside a transaction.
121
+ - Each migration runs inside a transaction, so a migration that throws part way through leaves the database untouched.
122
+
123
+ Postgres refuses a few statements inside a transaction block — `CREATE INDEX CONCURRENTLY` and `DROP INDEX CONCURRENTLY` most of all. A migration that needs one of those opts out of the transaction entirely:
124
+
125
+ ```js
126
+ module.exports = {
127
+ transactional: false,
128
+
129
+ up: async function() {
130
+ await this.addIndex('users', ['team_id'], { concurrently: true });
131
+ },
132
+
133
+ down: async function() {
134
+ await this.removeIndex('users', ['team_id'], true);
135
+ }
136
+ }
137
+ ```
138
+
139
+ 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.
117
140
 
118
141
 
119
142
  ## Models
@@ -101,7 +101,7 @@ function generateMigrationFile(description, databaseId) {
101
101
 
102
102
 
103
103
  function fileNameForMigration(description, databaseId) {
104
- const name = `${description}_${new Date().getTime()}`;
104
+ const name = `${new Date().getTime()}_${description}`;
105
105
  const dbSuffix = databaseId ? `.${databaseId}` : '';
106
106
  return `${name}${dbSuffix}.js`;
107
107
  }
@@ -113,7 +113,7 @@ function fileNameForMigration(description, databaseId) {
113
113
  function getMigrations() {
114
114
  const files = fs.readdirSync(utils.migrationsDirectory);
115
115
  const migrations = files.map(f => {
116
- const m = f.match(/_(?<version>\d+)(\.(?<databaseId>\w+))?\.js$/);
116
+ const m = f.match(/^(?<version>\d+)_[^.]+(\.(?<databaseId>\w+))?\.js$/);
117
117
  if (!m) return;
118
118
  return {fileName: f, databaseId: m.groups.databaseId, version: m.groups.version};
119
119
  });
@@ -8,6 +8,7 @@ class Migrator {
8
8
  #connection;
9
9
  #fileName;
10
10
  #migrationModule;
11
+ #transaction = null;
11
12
 
12
13
  DataTypes = Sequelize.DataTypes;
13
14
  Operators = Op;
@@ -22,19 +23,32 @@ class Migrator {
22
23
  async run() {
23
24
  if (!this.migrationModule.up) throw new Error('Missing migration `up`');
24
25
 
25
- const migrator = this;
26
- await this.#connection.queryInterface.transaction(async () => {
27
- await migrator.migrationModule.up.call(migrator);
28
- });
26
+ await this.perform(() => this.migrationModule.up.call(this));
29
27
  }
30
28
 
31
29
 
32
30
  async rollback() {
33
31
  if (!this.migrationModule.down) return;
34
32
 
35
- const migrator = this;
36
- await this.#connection.queryInterface.transaction(async () => {
37
- await migrator.migrationModule.down.call(migrator);
33
+ await this.perform(() => this.migrationModule.down.call(this));
34
+ }
35
+
36
+
37
+ async perform(callback) {
38
+ if (!this.transactional) return await callback();
39
+
40
+ await this.performInTransaction(callback);
41
+ }
42
+
43
+
44
+ async performInTransaction(callback) {
45
+ await this.#connection.queryInterface.transaction(async transaction => {
46
+ this.#transaction = transaction;
47
+ try {
48
+ await callback();
49
+ } finally {
50
+ this.#transaction = null;
51
+ }
38
52
  });
39
53
  }
40
54
 
@@ -58,6 +72,14 @@ class Migrator {
58
72
  }
59
73
 
60
74
 
75
+ // Migrations opt out with `transactional: false`. Needed for statements Postgres refuses to
76
+ // run inside a transaction block, most notably CREATE/DROP INDEX CONCURRENTLY. An opted-out
77
+ // migration is not rolled back when it fails.
78
+ get transactional() {
79
+ return this.migrationModule.transactional !== false;
80
+ }
81
+
82
+
61
83
  importMigrationModule() {
62
84
  const filePath = `${utils.migrationsDirectory}/${this.#fileName}`;
63
85
  return require(filePath);
@@ -77,7 +99,7 @@ class Migrator {
77
99
  }
78
100
 
79
101
  details = this.normalizeTableDetails(details);
80
- await this.#sequelizeQueryInterface.createTable(tableName, details);
102
+ await this.#sequelizeQueryInterface.createTable(tableName, details, this.queryOptions);
81
103
  }
82
104
 
83
105
 
@@ -116,8 +138,6 @@ class Migrator {
116
138
  }
117
139
 
118
140
 
119
- // Records use soft deletes when the table has this column: initializationOptions turns paranoid
120
- // on for any model whose schema includes it.
121
141
  get deletedAtColumn() {
122
142
  return {
123
143
  deleted_at: {
@@ -128,25 +148,25 @@ class Migrator {
128
148
 
129
149
 
130
150
  async dropTable(tableName) {
131
- await this.#sequelizeQueryInterface.dropTable(tableName);
151
+ await this.#sequelizeQueryInterface.dropTable(tableName, this.queryOptions);
132
152
  }
133
153
 
134
154
 
135
155
  async addColumn(tableName, columnName, columnDetails) {
136
- await this.#sequelizeQueryInterface.addColumn(tableName, columnName, columnDetails);
156
+ await this.#sequelizeQueryInterface.addColumn(tableName, columnName, columnDetails, this.queryOptions);
137
157
  }
138
158
 
139
159
 
140
160
  async removeColumn(tableName, columnName) {
141
- await this.#sequelizeQueryInterface.removeColumn(tableName, columnName);
161
+ await this.#sequelizeQueryInterface.removeColumn(tableName, columnName, this.queryOptions);
142
162
  }
143
163
 
144
164
 
145
- async addIndex(tableName, fields, { concurrently, unique, name, using, where }) {
146
- const options = {fields: fields};
165
+ async addIndex(tableName, fields, { concurrently, unique, name, using, where }={}) {
166
+ const options = {fields: fields, ...this.queryOptions};
167
+ options.name = name || this.defaultIndexName(tableName, fields);
147
168
  if (concurrently) options.concurrently = true;
148
169
  if (unique) options.unique = true;
149
- if (name) options.name = name;
150
170
  if (using) options.using = using;
151
171
  if (where) options.where = where;
152
172
 
@@ -155,20 +175,53 @@ class Migrator {
155
175
 
156
176
 
157
177
  async removeIndex(tableName, indexNameOrAttributes, concurrently=false) {
158
- const options = {};
178
+ const options = this.queryOptions;
159
179
  if (concurrently) options.concurrently = true;
160
- await this.#sequelizeQueryInterface.removeIndex(tableName, indexNameOrAttributes, options);
180
+
181
+ const indexName = this.indexNameFor(tableName, indexNameOrAttributes);
182
+ await this.#sequelizeQueryInterface.removeIndex(tableName, indexName, options);
183
+ }
184
+
185
+
186
+ /***********************************************************************************************
187
+ * INDEX NAMING
188
+ ***********************************************************************************************/
189
+ // Indexes created without an explicit name get `idx_<table>_<columns>`. removeIndex applies the
190
+ // same default so an index can be dropped by its columns, the way it was added.
191
+ indexNameFor(tableName, indexNameOrAttributes) {
192
+ if (typeof indexNameOrAttributes === 'string') return indexNameOrAttributes;
193
+ return this.defaultIndexName(tableName, indexNameOrAttributes);
194
+ }
195
+
196
+
197
+ defaultIndexName(tableName, fields) {
198
+ const columnNames = fields.map(field => this.indexColumnName(field));
199
+ return `idx_${tableName}_${columnNames.join('_')}`;
200
+ }
201
+
202
+
203
+ indexColumnName(field) {
204
+ if (typeof field === 'string') return field;
205
+ return field.name;
161
206
  }
162
207
 
163
208
 
164
209
  async renameColumn(tableName, oldName, newName) {
165
210
  newName = underscore(newName);
166
- await this.#sequelizeQueryInterface.renameColumn(tableName, oldName, newName);
211
+ await this.#sequelizeQueryInterface.renameColumn(tableName, oldName, newName, this.queryOptions);
167
212
  }
168
213
 
169
214
 
170
215
  async changeColumn(tableName, columnName, dataTypeOrOptions) {
171
- await this.#sequelizeQueryInterface.changeColumn(tableName, columnName, dataTypeOrOptions);
216
+ await this.#sequelizeQueryInterface.changeColumn(tableName, columnName, dataTypeOrOptions, this.queryOptions);
217
+ }
218
+
219
+
220
+ /***********************************************************************************************
221
+ * QUERY OPTIONS
222
+ ***********************************************************************************************/
223
+ get queryOptions() {
224
+ return { transaction: this.#transaction };
172
225
  }
173
226
 
174
227
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -45,6 +45,97 @@ describe('Migrator', () => {
45
45
  });
46
46
 
47
47
 
48
+ describe('Migrator transaction threading', () => {
49
+ it ('passes the open transaction to operations run inside `up`', async () => {
50
+ const tableName = Helpers.randomString();
51
+
52
+ await Helpers.runInMigration(async function() {
53
+ await this.createTable(tableName, {});
54
+ });
55
+
56
+ expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(
57
+ tableName,
58
+ expect.any(Object),
59
+ Helpers.inTransaction,
60
+ );
61
+ });
62
+
63
+ it ('passes the open transaction to operations run inside `down`', async () => {
64
+ const tableName = Helpers.randomString();
65
+ Helpers.migrationModule.down.mockImplementationOnce(async function() {
66
+ await this.dropTable(tableName);
67
+ });
68
+
69
+ await Helpers.migrator.rollback();
70
+
71
+ expect(Helpers.mockQueryInterface.dropTable).toHaveBeenCalledWith(tableName, Helpers.inTransaction);
72
+ });
73
+
74
+ it ('clears the transaction once the migration finishes', async () => {
75
+ const tableName = Helpers.randomString();
76
+
77
+ await Helpers.runInMigration(async function() {});
78
+ await Helpers.migrator.dropTable(tableName);
79
+
80
+ expect(Helpers.mockQueryInterface.dropTable).toHaveBeenCalledWith(tableName, Helpers.noTransaction);
81
+ });
82
+
83
+ it ('clears the transaction when the migration throws', async () => {
84
+ const tableName = Helpers.randomString();
85
+
86
+ await expect(Helpers.runInMigration(async function() {
87
+ throw new Error('migration blew up');
88
+ })).rejects.toThrow('migration blew up');
89
+ await Helpers.migrator.dropTable(tableName);
90
+
91
+ expect(Helpers.mockQueryInterface.dropTable).toHaveBeenCalledWith(tableName, Helpers.noTransaction);
92
+ });
93
+ });
94
+
95
+
96
+ describe('Migrator opting out of the transaction', () => {
97
+ afterEach(() => {
98
+ delete Helpers.migrationModule.transactional;
99
+ });
100
+
101
+ it ('runs `up` outside a transaction when the migration sets transactional to false', async () => {
102
+ const tableName = Helpers.randomString();
103
+ Helpers.migrationModule.transactional = false;
104
+
105
+ await Helpers.runInMigration(async function() {
106
+ await this.addIndex(tableName, ['name'], { concurrently: true });
107
+ });
108
+
109
+ expect(Helpers.transactionSpy).not.toHaveBeenCalled();
110
+ expect(Helpers.mockQueryInterface.addIndex).toHaveBeenCalledWith(tableName, {
111
+ fields: ['name'],
112
+ name: `idx_${tableName}_name`,
113
+ concurrently: true,
114
+ ...Helpers.noTransaction,
115
+ });
116
+ });
117
+
118
+ it ('runs `down` outside a transaction when the migration sets transactional to false', async () => {
119
+ const tableName = Helpers.randomString();
120
+ Helpers.migrationModule.transactional = false;
121
+ Helpers.migrationModule.down.mockImplementationOnce(async function() {
122
+ await this.dropTable(tableName);
123
+ });
124
+
125
+ await Helpers.migrator.rollback();
126
+
127
+ expect(Helpers.transactionSpy).not.toHaveBeenCalled();
128
+ expect(Helpers.mockQueryInterface.dropTable).toHaveBeenCalledWith(tableName, Helpers.noTransaction);
129
+ });
130
+
131
+ it ('still uses a transaction when transactional is unset', async () => {
132
+ await Helpers.migrator.run();
133
+
134
+ expect(Helpers.transactionSpy).toHaveBeenCalledTimes(1);
135
+ });
136
+ });
137
+
138
+
48
139
  describe('Migrator#createTable', () => {
49
140
  const defaultColumns = {
50
141
  id: { allowNull: false, autoIncrement: true, primaryKey: true, type: 'INTEGER' },
@@ -58,10 +149,10 @@ describe('Migrator', () => {
58
149
 
59
150
  await Helpers.migrator.createTable(tableName, details);
60
151
 
61
- expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, details);
152
+ expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, details, Helpers.noTransaction);
62
153
  });
63
154
 
64
- it ('adds a deleted_at column for paranoid tables', async () => {
155
+ it ('adds a nullable deleted_at column when paranoid is set', async () => {
65
156
  const tableName = Helpers.randomString();
66
157
  const details = { ...defaultColumns };
67
158
 
@@ -70,16 +161,27 @@ describe('Migrator', () => {
70
161
  expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, {
71
162
  ...defaultColumns,
72
163
  deleted_at: { allowNull: true, type: 'DATE' },
73
- });
164
+ }, Helpers.noTransaction);
74
165
  });
75
166
 
76
- it ('leaves deleted_at off tables that are not paranoid', async () => {
167
+ it ('omits deleted_at when paranoid is not set', async () => {
77
168
  const tableName = Helpers.randomString();
78
- const details = { ...defaultColumns };
79
169
 
80
- await Helpers.migrator.createTable(tableName, details);
170
+ await Helpers.migrator.createTable(tableName, { ...defaultColumns });
81
171
 
82
- expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, defaultColumns);
172
+ const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
173
+ expect(columns).not.toHaveProperty('deleted_at');
174
+ });
175
+
176
+ it ('adds deleted_at alongside timestamps: false', async () => {
177
+ const tableName = Helpers.randomString();
178
+
179
+ await Helpers.migrator.createTable(tableName, {}, { paranoid: true, timestamps: false });
180
+
181
+ const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
182
+ expect(columns).toHaveProperty('deleted_at');
183
+ expect(columns).not.toHaveProperty('created_at');
184
+ expect(columns).not.toHaveProperty('updated_at');
83
185
  });
84
186
 
85
187
  it ('snake_cases created table columns', async () => {
@@ -91,7 +193,7 @@ describe('Migrator', () => {
91
193
  expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(tableName, {
92
194
  ...defaultColumns,
93
195
  my_column: 1,
94
- });
196
+ }, Helpers.noTransaction);
95
197
  });
96
198
  });
97
199
 
@@ -116,7 +218,7 @@ describe('Migrator', () => {
116
218
 
117
219
  await Helpers.migrator.renameColumn(tableName, oldName, 'myColumn');
118
220
 
119
- expect(Helpers.mockQueryInterface.renameColumn).toHaveBeenCalledWith(tableName, oldName, 'my_column');
221
+ expect(Helpers.mockQueryInterface.renameColumn).toHaveBeenCalledWith(tableName, oldName, 'my_column', Helpers.noTransaction);
120
222
  });
121
223
  });
122
224
 
@@ -129,12 +231,12 @@ describe('Migrator', () => {
129
231
  ])('builds addIndex options for %s', async (_label, options) => {
130
232
  const tableName = Helpers.randomString();
131
233
  const fields = [Helpers.randomString(), Helpers.randomString()];
132
- const expectedOptions = { fields };
234
+ const expectedOptions = { fields, ...Helpers.noTransaction };
133
235
 
134
236
  if (options.concurrently) expectedOptions.concurrently = true;
135
237
  if (options.unique) expectedOptions.unique = true;
136
238
  if (options.where) expectedOptions.where = options.where;
137
- if (options.name) expectedOptions.name = options.name;
239
+ expectedOptions.name = options.name || `idx_${tableName}_${fields.join('_')}`;
138
240
  if (options.using) expectedOptions.using = options.using;
139
241
 
140
242
  await Helpers.migrator.addIndex(tableName, fields, options);
@@ -147,8 +249,8 @@ describe('Migrator', () => {
147
249
 
148
250
  describe('Migrator#removeIndex', () => {
149
251
  it.each([
150
- ['default options', false, {}],
151
- ['concurrently', true, { concurrently: true }],
252
+ ['default options', false, { transaction: null }],
253
+ ['concurrently', true, { concurrently: true, transaction: null }],
152
254
  ])('passes removeIndex options for %s', async (_label, concurrently, expectedOptions) => {
153
255
  const tableName = Helpers.randomString();
154
256
  const fields = [Helpers.randomString(), Helpers.randomString()];
@@ -158,9 +260,54 @@ describe('Migrator', () => {
158
260
  expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledTimes(1);
159
261
  expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledWith(
160
262
  tableName,
161
- fields,
263
+ `idx_${tableName}_${fields.join('_')}`,
162
264
  expectedOptions,
163
265
  );
164
266
  });
267
+
268
+ it ('leaves an explicit index name alone', async () => {
269
+ const tableName = Helpers.randomString();
270
+ const indexName = Helpers.randomString();
271
+
272
+ await Helpers.migrator.removeIndex(tableName, indexName);
273
+
274
+ expect(Helpers.mockQueryInterface.removeIndex).toHaveBeenCalledWith(
275
+ tableName,
276
+ indexName,
277
+ Helpers.noTransaction,
278
+ );
279
+ });
280
+ });
281
+
282
+
283
+ describe('Migrator#defaultIndexName', () => {
284
+ it ('builds idx_<table>_<columns> from a single column', () => {
285
+ expect(Helpers.migrator.defaultIndexName('users', ['team_id'])).toBe('idx_users_team_id');
286
+ });
287
+
288
+ it ('joins multiple columns with underscores', () => {
289
+ expect(Helpers.migrator.defaultIndexName('users', ['team_id', 'email'])).toBe('idx_users_team_id_email');
290
+ });
291
+
292
+ it ('reads the column name off object-style fields', () => {
293
+ const fields = ['team_id', { name: 'email', order: 'DESC' }];
294
+
295
+ expect(Helpers.migrator.defaultIndexName('users', fields)).toBe('idx_users_team_id_email');
296
+ });
297
+ });
298
+
299
+
300
+ describe('Migrator#addIndex default naming', () => {
301
+ it ('names an index when no options are passed at all', async () => {
302
+ const tableName = Helpers.randomString();
303
+
304
+ await Helpers.migrator.addIndex(tableName, ['team_id']);
305
+
306
+ expect(Helpers.mockQueryInterface.addIndex).toHaveBeenCalledWith(tableName, {
307
+ fields: ['team_id'],
308
+ name: `idx_${tableName}_team_id`,
309
+ ...Helpers.noTransaction,
310
+ });
311
+ });
165
312
  });
166
313
  });
@@ -13,6 +13,9 @@ const connectionConfig = { database: ' ', host: ' ', password: ' ', username:
13
13
  const migrationModule = { up: jest.fn(), down: jest.fn() };
14
14
  const randomNumber = TestHelpers.Faker.Math.randomNumber
15
15
  const randomString = TestHelpers.Faker.Text.randomString;
16
+ const transaction = { id: randomString() };
17
+ const noTransaction = { transaction: null };
18
+ const inTransaction = { transaction };
16
19
  const mockQueryInterface = {
17
20
  createTable: jest.fn(),
18
21
  dropTable: jest.fn(),
@@ -52,7 +55,7 @@ jest.spyOn(migrator, 'migrationModule', 'get').mockImplementation(() => migratio
52
55
 
53
56
 
54
57
  const transactionSpy = jest.spyOn(QueryInterface.prototype, 'transaction');
55
- transactionSpy.mockImplementation(callback => callback());
58
+ transactionSpy.mockImplementation(callback => callback(transaction));
56
59
 
57
60
 
58
61
  /***************************************************************************************************
@@ -61,18 +64,29 @@ transactionSpy.mockImplementation(callback => callback());
61
64
  async function expectQueryInterfaceCall(methodName, args) {
62
65
  await migrator[methodName](...args);
63
66
  expect(mockQueryInterface[methodName]).toHaveBeenCalledTimes(1);
64
- expect(mockQueryInterface[methodName]).toHaveBeenCalledWith(...args);
67
+ expect(mockQueryInterface[methodName]).toHaveBeenCalledWith(...args, noTransaction);
68
+ }
69
+
70
+
71
+ // Runs `operation` from inside a migration's `up` so it sees the open transaction.
72
+ async function runInMigration(operation) {
73
+ migrationModule.up.mockImplementationOnce(operation);
74
+ await migrator.run();
65
75
  }
66
76
 
67
77
 
68
78
  module.exports = {
69
79
  expectQueryInterfaceCall,
70
80
  ...Fixtures,
81
+ inTransaction,
71
82
  migrationModule,
72
83
  migrator,
73
84
  mockQueryInterface,
85
+ noTransaction,
74
86
  randomColumnDetails,
75
87
  randomNumber,
76
88
  randomString,
89
+ runInMigration,
90
+ transaction,
77
91
  transactionSpy,
78
92
  };