heroku 11.3.1-beta.0 → 11.4.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/CHANGELOG.md CHANGED
@@ -4,17 +4,19 @@ All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
6
 
7
- ## [11.3.1-beta.0](https://github.com/heroku/cli/compare/v11.3.0...v11.3.1-beta.0) (2026-05-13)
7
+ ## [11.4.0](https://github.com/heroku/cli/compare/v11.3.0...v11.4.0) (2026-05-13)
8
8
 
9
9
 
10
10
  ### Features
11
11
 
12
+ * 'data:pg:migrate' command implementation (W-21303011) ([#3546](https://github.com/heroku/cli/issues/3546)) ([127e682](https://github.com/heroku/cli/commit/127e682e23c2798346372528f55397563ad3e505))
12
13
  * multi-factor attachment support for Advanced dbs (W-21632630) ([#3655](https://github.com/heroku/cli/issues/3655)) ([87785e8](https://github.com/heroku/cli/commit/87785e809fae29cc23a9c29b44b3a6f285961861))
13
14
 
14
15
 
15
16
  ### Bug Fixes
16
17
 
17
18
  * align ps:type with customer-facing private/shield dyno names ([#3705](https://github.com/heroku/cli/issues/3705)) ([0b36a1f](https://github.com/heroku/cli/commit/0b36a1f3c0c5ab21b3a3887787f290f62135957f))
19
+ * migrate release scripts from qqjs to script-exec helpers ([#3707](https://github.com/heroku/cli/issues/3707)) ([dcbaa1b](https://github.com/heroku/cli/commit/dcbaa1b153cab52b009811df30cac80aa93c98d9))
18
20
  * prevent unhandled promise rejection in apps:destroy ([#3679](https://github.com/heroku/cli/issues/3679)) ([3e4c838](https://github.com/heroku/cli/commit/3e4c838ec28276ed3b3e01dd76d5f6cb78c58f19)), closes [#3677](https://github.com/heroku/cli/issues/3677) [#3677](https://github.com/heroku/cli/issues/3677) [#3677](https://github.com/heroku/cli/issues/3677)
19
21
  * updates @heroku-cli/command to address 2FA token prompting bug ([#3690](https://github.com/heroku/cli/issues/3690)) ([eda4459](https://github.com/heroku/cli/commit/eda4459fb8b98442d0650c1612d215c74eb2250b))
20
22
  * validate HEROKU_HOST for container registry commands ([#3704](https://github.com/heroku/cli/issues/3704)) ([04df827](https://github.com/heroku/cli/commit/04df8275f933c2f055a384d7deaccd524bfa5220))
@@ -28,8 +28,5 @@ export default class DataPgCreate extends BaseCommand {
28
28
  run(): Promise<void>;
29
29
  runCommand(command: string, args: string[]): Promise<void>;
30
30
  private followerPoolConfigLoop;
31
- private highAvailabilityStep;
32
- private leaderConfirmationStep;
33
- private leaderLevelStep;
34
31
  private leaderPoolConfig;
35
32
  }
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-await-in-loop */
1
2
  import { flags as Flags } from '@heroku-cli/command';
2
3
  import { color, utils } from '@heroku/heroku-cli-util';
3
4
  import { ux } from '@oclif/core/ux';
@@ -8,10 +9,10 @@ import BaseCommand from '../../../lib/data/base-command.js';
8
9
  import createPool from '../../../lib/data/create-pool.js';
9
10
  import { parseProvisionOpts } from '../../../lib/data/parse-provision-opts.js';
10
11
  import PoolConfig from '../../../lib/data/pool-config.js';
11
- import { fetchLevelsAndPricing, renderPricingInfo } from '../../../lib/data/utils.js';
12
+ import { fetchLevelsAndPricing } from '../../../lib/data/utils.js';
12
13
  import notify from '../../../lib/notify.js';
13
14
  const heredoc = tsheredoc.default;
14
- const { prompt, Separator } = inquirer;
15
+ const { prompt } = inquirer;
15
16
  export default class DataPgCreate extends BaseCommand {
16
17
  static baseFlags = BaseCommand.baseFlagsWithoutPrompt();
17
18
  static description = 'create a Postgres Advanced database';
@@ -178,64 +179,8 @@ export default class DataPgCreate extends BaseCommand {
178
179
  }
179
180
  } while (oneMore);
180
181
  }
181
- async highAvailabilityStep() {
182
- process.stderr.write('The leader pool has high availability enabled and includes a standby instance for redundancy.\n'
183
- + 'If you disable high availability, you remove the standby and you won\'t have redundancy on your database.\n\n');
184
- const leaderPricing = this.extendedLevelsInfo.find(level => level.name === this.leaderLevel)?.pricing;
185
- const { action } = await this.prompt({
186
- choices: [
187
- { name: 'Keep high availability (HA)', value: 'keep' },
188
- {
189
- name: 'Remove high availability' + (renderPricingInfo(leaderPricing) === 'free'
190
- ? ''
191
- : ` ${color.info(`-${renderPricingInfo(leaderPricing).replace('~', '')}`)}`),
192
- value: 'remove',
193
- },
194
- new Separator(),
195
- { name: 'Go back', value: 'back' },
196
- ],
197
- message: 'Do you want to keep the high availability standby instance?',
198
- name: 'action',
199
- type: 'list',
200
- });
201
- process.stderr.write('\n');
202
- return action;
203
- }
204
- async leaderConfirmationStep() {
205
- const leaderLevelInfo = this.extendedLevelsInfo.find(level => level.name === this.leaderLevel);
206
- const totalPrice = this.highAvailability
207
- ? renderPricingInfo(leaderLevelInfo?.pricing, 2)
208
- : renderPricingInfo(leaderLevelInfo?.pricing);
209
- const instancePrice = renderPricingInfo(leaderLevelInfo?.pricing);
210
- process.stderr.write(heredoc `
211
- ${`${color.green('✓ Configure Leader Pool')} ${totalPrice}`}
212
- ${color.gray(`${this.leaderLevel} ${leaderLevelInfo?.vcpu} ${color.ansis.inverse('vCPU')} `
213
- + `${leaderLevelInfo?.memory_in_gb} GB ${color.ansis.inverse('MEM')} `
214
- + instancePrice)}
215
- `);
216
- if (this.highAvailability) {
217
- process.stderr.write(color.gray(` Standby (High Availability) ${instancePrice}\n`));
218
- }
219
- process.stderr.write('\n');
220
- const { action } = await this.prompt({
221
- choices: [
222
- { name: 'Confirm', value: 'confirm' },
223
- { name: 'Go back', value: 'back' },
224
- ],
225
- message: 'Confirm provisioning?',
226
- name: 'action',
227
- type: 'list',
228
- });
229
- process.stderr.write('\n');
230
- return action;
231
- }
232
- async leaderLevelStep() {
233
- const poolConfig = new PoolConfig(this.extendedLevelsInfo, this.followerInstanceCount);
234
- const level = await poolConfig.levelStep('Leader');
235
- process.stderr.write('\n');
236
- this.leaderLevel = level;
237
- }
238
182
  async leaderPoolConfig() {
183
+ const poolConfig = new PoolConfig(this.extendedLevelsInfo, this.followerInstanceCount);
239
184
  process.stderr.write(heredoc `
240
185
 
241
186
  Create a Heroku Postgres Advanced database
@@ -246,48 +191,8 @@ export default class DataPgCreate extends BaseCommand {
246
191
  → Configure Leader Pool
247
192
  ${color.gray(' Configure Follower Pool(s)')}\n
248
193
  `);
249
- let configReady = false;
250
- let currentStep = 'leaderLevel';
251
- while (!configReady) {
252
- switch (currentStep) {
253
- case 'confirmation': {
254
- switch (await this.leaderConfirmationStep()) {
255
- case 'back': {
256
- currentStep = 'highAvailability';
257
- break;
258
- }
259
- case 'confirm': {
260
- configReady = true;
261
- break;
262
- }
263
- }
264
- break;
265
- }
266
- case 'highAvailability': {
267
- switch (await this.highAvailabilityStep()) {
268
- case 'back': {
269
- currentStep = 'leaderLevel';
270
- break;
271
- }
272
- case 'keep': {
273
- this.highAvailability = true;
274
- currentStep = 'confirmation';
275
- break;
276
- }
277
- case 'remove': {
278
- this.highAvailability = false;
279
- currentStep = 'confirmation';
280
- break;
281
- }
282
- }
283
- break;
284
- }
285
- case 'leaderLevel': {
286
- await this.leaderLevelStep();
287
- currentStep = 'highAvailability';
288
- break;
289
- }
290
- }
291
- }
194
+ const { highAvailability, level } = await poolConfig.leaderInteractiveConfig();
195
+ this.leaderLevel = level;
196
+ this.highAvailability = highAvailability;
292
197
  }
293
198
  }
@@ -0,0 +1,26 @@
1
+ import * as Heroku from '@heroku-cli/schema';
2
+ import inquirer from 'inquirer';
3
+ import createAddon from '../../../lib/addons/create-addon.js';
4
+ import BaseCommand from '../../../lib/data/base-command.js';
5
+ export default class DataPgMigrate extends BaseCommand {
6
+ static description: string;
7
+ static flags: {
8
+ app: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ remote: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ };
11
+ private advancedDatabases;
12
+ private appName;
13
+ private classicDatabases;
14
+ private extendedLevelsInfo;
15
+ private migrationTargets;
16
+ createAddon(...args: Parameters<typeof createAddon>): Promise<Heroku.AddOn>;
17
+ prompt<T extends inquirer.Answers>(...args: Parameters<typeof inquirer.prompt<T>>): Promise<T>;
18
+ run(): Promise<void>;
19
+ private actOnReadyMigration;
20
+ private configureMigration;
21
+ private createTargetDatabase;
22
+ private getAppDatabases;
23
+ private getMigrationTargetsAndInfo;
24
+ private isActiveMigration;
25
+ private loopMainMenu;
26
+ }
@@ -0,0 +1,440 @@
1
+ /* eslint-disable no-await-in-loop */
2
+ import { flags as Flags, HerokuAPIError } from '@heroku-cli/command';
3
+ import { color, hux, utils, } from '@heroku/heroku-cli-util';
4
+ import { ux } from '@oclif/core';
5
+ import inquirer from 'inquirer';
6
+ import tsheredoc from 'tsheredoc';
7
+ import createAddon from '../../../lib/addons/create-addon.js';
8
+ import BaseCommand from '../../../lib/data/base-command.js';
9
+ import PoolConfig from '../../../lib/data/pool-config.js';
10
+ import { DatabaseStatus, MigrationStatus, } from '../../../lib/data/types.js';
11
+ import { fetchLevelsAndPricing } from '../../../lib/data/utils.js';
12
+ import { getAttachmentNamesByAddon } from '../../../lib/pg/util.js';
13
+ const heredoc = tsheredoc.default;
14
+ const { prompt, Separator } = inquirer;
15
+ export default class DataPgMigrate extends BaseCommand {
16
+ static description = 'migrate an existing classic Postgres database to an Advanced database';
17
+ static flags = {
18
+ app: Flags.app({ required: true }),
19
+ remote: Flags.remote(),
20
+ };
21
+ advancedDatabases = [];
22
+ appName;
23
+ classicDatabases = [];
24
+ extendedLevelsInfo;
25
+ migrationTargets = [];
26
+ async createAddon(...args) {
27
+ return createAddon(...args);
28
+ }
29
+ async prompt(...args) {
30
+ return prompt(...args);
31
+ }
32
+ async run() {
33
+ const { flags } = await this.parse(DataPgMigrate);
34
+ const { app } = flags;
35
+ this.appName = app;
36
+ ux.stdout(heredoc `
37
+
38
+ Migrate existing classic Heroku Postgres databases to Advanced databases
39
+ ${color.gray('Press Ctrl+C to cancel')}
40
+ `);
41
+ let action;
42
+ do {
43
+ action = await this.loopMainMenu(app);
44
+ switch (action) {
45
+ case '__cancel_migration': {
46
+ await this.actOnReadyMigration('cancel');
47
+ break;
48
+ }
49
+ case '__configure_migration': {
50
+ await this.configureMigration();
51
+ break;
52
+ }
53
+ case '__exit': {
54
+ break;
55
+ }
56
+ case '__start_migration': {
57
+ await this.actOnReadyMigration('start');
58
+ break;
59
+ }
60
+ }
61
+ } while (action !== '__exit');
62
+ }
63
+ // eslint-disable-next-line complexity
64
+ async actOnReadyMigration(migrationAction) {
65
+ const readyMigrations = this.migrationTargets.filter(migration => migration.status === MigrationStatus.READY);
66
+ let currentStep = '__select_migration';
67
+ let selectedMigrationId;
68
+ while (currentStep !== '__exit') {
69
+ switch (currentStep) {
70
+ case '__confirm_action': {
71
+ const selectedMigration = readyMigrations.find(migration => migration.id === selectedMigrationId);
72
+ const sourceDatabase = this.classicDatabases.find(db => db.id === selectedMigration.source_id);
73
+ const targetDatabase = this.advancedDatabases.find(db => db.id === selectedMigration.target_id);
74
+ if (migrationAction === 'start') {
75
+ ux.stdout(color.info(heredoc `
76
+
77
+ Your database ${color.datastore(sourceDatabase?.name ?? color.gray('unknown'))} will be unavailable after starting the migration until the migration is complete.
78
+ If there are any issues during the migration, we end the migration and make the source database available again.
79
+ The database ${color.datastore(sourceDatabase?.name ?? color.gray('unknown'))} can be offline for several hours during the migration.
80
+ You'll receive an email when the migration is complete.
81
+ You can't cancel the migration after starting it.
82
+
83
+ `));
84
+ }
85
+ else {
86
+ ux.stdout(color.info(heredoc `
87
+
88
+ After canceling, you must create a new migration configuration and wait for the migration tooling to finish preparing to
89
+ migrate ${color.datastore(sourceDatabase?.name ?? color.gray('unknown'))} again.
90
+
91
+ `));
92
+ }
93
+ const { action } = await this.prompt({
94
+ choices: [
95
+ { name: 'Confirm', value: '__confirm' },
96
+ { name: 'Go back', value: '__go_back' },
97
+ ],
98
+ message: `Confirm to ${migrationAction} migration:`,
99
+ name: 'action',
100
+ type: 'list',
101
+ });
102
+ if (action === '__go_back') {
103
+ currentStep = '__select_migration';
104
+ }
105
+ else if (action === '__confirm') {
106
+ ux.stdout();
107
+ ux.action.start(`${migrationAction === 'start' ? 'Starting' : 'Canceling'} migration of ${color.datastore(sourceDatabase?.name ?? color.gray('unknown'))} `
108
+ + `to ${color.datastore(targetDatabase?.name ?? color.gray('unknown'))}`);
109
+ await this.dataApi.post(`/data/postgres/v1/${selectedMigration.target_id}/migrations/${migrationAction === 'start' ? 'run' : 'cancel'}`);
110
+ ux.action.stop();
111
+ currentStep = '__exit';
112
+ }
113
+ break;
114
+ }
115
+ case '__select_migration': {
116
+ const choices = [];
117
+ for (const migration of readyMigrations) {
118
+ const sourceDatabase = this.classicDatabases.find(db => db.id === migration.source_id);
119
+ const targetDatabase = this.advancedDatabases.find(db => db.id === migration.target_id);
120
+ const name = `From ${color.datastore(sourceDatabase?.name ?? color.gray('unknown'))} to ${color.datastore(targetDatabase?.name ?? color.gray('unknown'))}`;
121
+ choices.push({
122
+ name,
123
+ value: migration.id,
124
+ });
125
+ }
126
+ choices.push(new Separator(), { name: 'Go back', value: '__go_back' });
127
+ selectedMigrationId = (await this.prompt({
128
+ choices,
129
+ message: `Select the migration to ${migrationAction}:`,
130
+ name: 'migration',
131
+ type: 'list',
132
+ })).migration;
133
+ currentStep = selectedMigrationId === '__go_back' ? '__exit' : '__confirm_action';
134
+ break;
135
+ }
136
+ }
137
+ }
138
+ }
139
+ async configureMigration() {
140
+ let currentStep = '__select_source';
141
+ let sourceDatabaseId;
142
+ let targetDatabaseId;
143
+ while (currentStep !== '__exit') {
144
+ switch (currentStep) {
145
+ case '__confirm_migration': {
146
+ ux.stdout(color.info(heredoc `
147
+
148
+ By continuing, we prepare the necessary steps for the migration.
149
+ Your source database is available while we prepare the migration.
150
+ You'll receive an email when the preparation is complete or if there's an error.
151
+ You have 24 hours to begin migration after the preparation is complete.
152
+ Your source database will be unavailable during the migration.
153
+
154
+ `));
155
+ const { action } = await this.prompt({
156
+ choices: [
157
+ { name: 'Confirm', value: '__confirm' },
158
+ { name: 'Go back', value: '__go_back' },
159
+ ],
160
+ message: 'Confirm migration configuration:',
161
+ name: 'action',
162
+ type: 'list',
163
+ });
164
+ if (action === '__go_back') {
165
+ currentStep = '__select_target';
166
+ }
167
+ else if (action === '__confirm') {
168
+ ux.stdout('');
169
+ ux.action.start('Configuring migration');
170
+ await this.dataApi.post(`/data/postgres/v1/${targetDatabaseId}/migrations`, {
171
+ body: { source_id: sourceDatabaseId },
172
+ });
173
+ ux.action.stop();
174
+ currentStep = '__exit';
175
+ }
176
+ break;
177
+ }
178
+ case '__select_source': {
179
+ const choices = [];
180
+ for (const database of this.classicDatabases) {
181
+ const name = `${color.datastore(database.name)} as ${database.attachment_names.map(name => color.attachment(name)).join(', ')}`;
182
+ if (this.migrationTargets.some(migration => migration.source_id === database.id && this.isActiveMigration(migration))) {
183
+ choices.push({
184
+ disabled: 'already a source database for an active migration',
185
+ name: color.gray(name),
186
+ value: database.id,
187
+ });
188
+ }
189
+ else {
190
+ choices.push({
191
+ name,
192
+ value: database.id,
193
+ });
194
+ }
195
+ }
196
+ choices.push(new Separator(), { name: 'Go back', value: '__go_back' });
197
+ sourceDatabaseId = (await this.prompt({
198
+ choices,
199
+ message: 'Select the source database:',
200
+ name: 'database',
201
+ type: 'list',
202
+ })).database;
203
+ currentStep = sourceDatabaseId === '__go_back' ? '__exit' : '__select_target';
204
+ break;
205
+ }
206
+ case '__select_target': {
207
+ const choices = [];
208
+ for (const database of this.advancedDatabases) {
209
+ const name = `${color.datastore(database.name)} as ${database.attachment_names.map(name => color.attachment(name)).join(', ')}`;
210
+ if (this.migrationTargets.some(migration => migration.target_id === database.id && this.isActiveMigration(migration))) {
211
+ choices.push({
212
+ disabled: 'already a destination database for an active migration',
213
+ name: color.gray(name),
214
+ value: database.id,
215
+ });
216
+ }
217
+ else if (database.info?.status === DatabaseStatus.AVAILABLE) {
218
+ choices.push({
219
+ name,
220
+ value: database.id,
221
+ });
222
+ }
223
+ else {
224
+ choices.push({
225
+ disabled: 'database isn\'t available',
226
+ name: color.gray(name),
227
+ value: database.id,
228
+ });
229
+ }
230
+ }
231
+ if (this.advancedDatabases.length === 0) {
232
+ choices.push({
233
+ disabled: true,
234
+ name: color.gray(`No Heroku Postgres Advanced databases available for migration on ${color.app(this.appName)}`),
235
+ value: '__no_advanced_databases',
236
+ });
237
+ }
238
+ choices.push(new Separator(), { name: 'Create a new Advanced database', value: '__create_database' }, { name: 'Go back', value: '__go_back' });
239
+ targetDatabaseId = (await this.prompt({
240
+ choices,
241
+ message: 'Select the destination database:',
242
+ name: 'database',
243
+ type: 'list',
244
+ })).database;
245
+ if (targetDatabaseId === '__go_back') {
246
+ currentStep = '__select_source';
247
+ }
248
+ else if (targetDatabaseId === '__create_database') {
249
+ const addon = await this.createTargetDatabase(sourceDatabaseId);
250
+ if (addon) {
251
+ targetDatabaseId = addon.id;
252
+ currentStep = '__confirm_migration';
253
+ }
254
+ else {
255
+ currentStep = '__select_target';
256
+ }
257
+ }
258
+ else {
259
+ currentStep = '__confirm_migration';
260
+ }
261
+ break;
262
+ }
263
+ }
264
+ }
265
+ }
266
+ async createTargetDatabase(sourceDatabaseId) {
267
+ let networking;
268
+ const sourceDatabase = this.classicDatabases.find(db => db.id === sourceDatabaseId);
269
+ if (sourceDatabase.plan.name.split(':')[1].startsWith('private')) {
270
+ networking = 'private';
271
+ }
272
+ else if (sourceDatabase.plan.name.split(':')[1].startsWith('shield')) {
273
+ networking = 'shield';
274
+ }
275
+ const plan = `advanced${networking ? `-${networking}` : ''}`;
276
+ const service = utils.pg.addonService();
277
+ const servicePlan = `${service}:${plan}`;
278
+ const { extendedLevelsInfo } = await fetchLevelsAndPricing(plan, this.dataApi);
279
+ this.extendedLevelsInfo = extendedLevelsInfo;
280
+ const poolConfig = new PoolConfig(this.extendedLevelsInfo, 0);
281
+ ux.stdout(heredoc `
282
+
283
+ → Configure Leader Pool
284
+
285
+ `);
286
+ const { action, highAvailability, level: leaderLevel } = await poolConfig.leaderInteractiveConfig(true);
287
+ if (action === '__go_back') {
288
+ return undefined;
289
+ }
290
+ // Database cluster provisioning (leader pool)
291
+ const config = {
292
+ from: sourceDatabaseId,
293
+ 'high-availability': highAvailability,
294
+ level: leaderLevel,
295
+ };
296
+ let addon;
297
+ try {
298
+ addon = await this.createAddon(this.heroku, sourceDatabase.app.name, servicePlan, undefined, false, {
299
+ actionStartMessage: `Creating a ${color.info(leaderLevel)} database on ${color.app(sourceDatabase.app.name)}`,
300
+ actionStopMessage: 'done',
301
+ config,
302
+ });
303
+ }
304
+ catch (error) {
305
+ ux.action.stop();
306
+ throw error;
307
+ }
308
+ return addon;
309
+ }
310
+ async getAppDatabases(app) {
311
+ const { body: appAttachments } = await this.heroku.get(`/apps/${app}/addon-attachments`, {
312
+ headers: {
313
+ Accept: 'application/vnd.heroku+json; version=3.sdk',
314
+ 'Accept-Inclusion': 'addon:plan,config_vars',
315
+ },
316
+ });
317
+ const ownedDatabaseAttachments = appAttachments.filter(a => utils.pg.isPostgresAddon(a.addon) && a.addon.app.name === app);
318
+ const ownedDatabaseAddons = [];
319
+ for (const attachment of ownedDatabaseAttachments) {
320
+ if (!ownedDatabaseAddons.some(a => a.id === attachment.addon.id)) {
321
+ ownedDatabaseAddons.push(attachment.addon);
322
+ }
323
+ }
324
+ const attachmentNamesByAddon = getAttachmentNamesByAddon(ownedDatabaseAttachments);
325
+ for (const addon of ownedDatabaseAddons) {
326
+ addon.attachment_names = attachmentNamesByAddon[addon.id];
327
+ }
328
+ this.classicDatabases = ownedDatabaseAddons.filter(db => utils.pg.isLegacyDatabase(db) && !utils.pg.isEssentialDatabase(db));
329
+ this.advancedDatabases = ownedDatabaseAddons.filter(db => utils.pg.isAdvancedDatabase(db));
330
+ }
331
+ async getMigrationTargetsAndInfo() {
332
+ const migrationPromises = Promise.allSettled(this.advancedDatabases.map(db => this.dataApi.get(`/data/postgres/v1/${db.id}/migrations`)));
333
+ const infoPromises = Promise.allSettled(this.advancedDatabases.map(db => this.dataApi.get(`/data/postgres/v1/${db.id}/info`)));
334
+ const [migrationResults, infoResults] = await Promise.all([migrationPromises, infoPromises]);
335
+ // 404 errors are expected for Advanced databases that are not a migration target (at least not yet)
336
+ const unexpectedError = [...migrationResults, ...infoResults]
337
+ .filter(queryResult => queryResult.status === 'rejected')
338
+ .find(queryResult => {
339
+ const error = queryResult.reason;
340
+ if (error instanceof HerokuAPIError) {
341
+ return error.http.statusCode !== 404;
342
+ }
343
+ return true;
344
+ });
345
+ if (unexpectedError) {
346
+ ux.error(unexpectedError.reason);
347
+ }
348
+ for (const infoResult of infoResults) {
349
+ if (infoResult.status === 'fulfilled') {
350
+ const db = this.advancedDatabases.find(db => db.id === infoResult.value.body.addon.id);
351
+ if (db) {
352
+ db.info = infoResult.value.body;
353
+ }
354
+ }
355
+ }
356
+ this.migrationTargets = migrationResults
357
+ .filter(queryResult => queryResult.status === 'fulfilled')
358
+ .map(queryResult => queryResult.value.body);
359
+ }
360
+ isActiveMigration(migration) {
361
+ return migration.status === MigrationStatus.CREATING_TARGET
362
+ || migration.status === MigrationStatus.PREPARING
363
+ || migration.status === MigrationStatus.MIGRATING
364
+ || migration.status === MigrationStatus.PROMOTING
365
+ || migration.status === MigrationStatus.READY;
366
+ }
367
+ async loopMainMenu(app) {
368
+ // Update our database lists
369
+ await this.getAppDatabases(app);
370
+ await this.getMigrationTargetsAndInfo();
371
+ const pendingMigrations = this.classicDatabases.filter(db => !this.migrationTargets.some(migration => migration.source_id === db.id && this.isActiveMigration(migration)));
372
+ hux.styledHeader('Configured Migrations');
373
+ if (this.migrationTargets.length > 0) {
374
+ /* eslint-disable perfectionist/sort-objects */
375
+ hux.table(this.migrationTargets, {
376
+ source: {
377
+ get: (migration) => color.datastore(this.classicDatabases.find(db => db.id === migration.source_id)?.name ?? color.gray('unknown')),
378
+ header: 'Source Database',
379
+ },
380
+ destination: {
381
+ get: (migration) => color.datastore(this.advancedDatabases.find(db => db.id === migration.target_id)?.name ?? color.gray('unknown')),
382
+ header: 'Destination Database',
383
+ },
384
+ status: {
385
+ get: (migration) => (migration.status === MigrationStatus.MIGRATING && migration.status_description)
386
+ ? color.info(migration.status_description)
387
+ : color.info(migration.status === MigrationStatus.CANCELLED ? 'Canceled' : hux.toTitleCase(migration.status)),
388
+ header: 'Status',
389
+ },
390
+ });
391
+ /* eslint-enable perfectionist/sort-objects */
392
+ }
393
+ else {
394
+ ux.stdout(`You haven't configured any migrations for ${color.app(app)} yet.\n`);
395
+ }
396
+ const choices = [];
397
+ if (pendingMigrations.length > 0) {
398
+ choices.push({
399
+ name: 'Configure a database migration',
400
+ value: '__configure_migration',
401
+ });
402
+ }
403
+ else {
404
+ choices.push({
405
+ disabled: `no classic Postgres databases pending migration on ${color.app(app)}`,
406
+ name: color.gray('Configure a database migration'),
407
+ value: '__configure_migration',
408
+ });
409
+ }
410
+ if (this.migrationTargets.some(migration => migration.status === 'ready')) {
411
+ choices.push({
412
+ name: 'Start a migration',
413
+ value: '__start_migration',
414
+ }, {
415
+ name: 'Cancel a migration',
416
+ value: '__cancel_migration',
417
+ });
418
+ }
419
+ else {
420
+ const disabledReason = `no ready migrations on ${color.app(app)}`;
421
+ choices.push({
422
+ disabled: disabledReason,
423
+ name: color.gray('Start a migration'),
424
+ value: '__start_migration',
425
+ }, {
426
+ disabled: disabledReason,
427
+ name: color.gray('Cancel a migration'),
428
+ value: '__cancel_migration',
429
+ });
430
+ }
431
+ choices.push(new Separator(), { name: 'Exit', value: '__exit' });
432
+ const { action } = await this.prompt({
433
+ choices,
434
+ message: 'What do you want to do?:',
435
+ name: 'action',
436
+ type: 'list',
437
+ });
438
+ return action;
439
+ }
440
+ }
@@ -25,28 +25,6 @@ export default class DataPgUpdate extends BaseCommand {
25
25
  prompt<T extends Answers>(...args: Parameters<typeof prompt<T>>): Promise<T>;
26
26
  run(): Promise<void>;
27
27
  private addFollowerPoolStage;
28
- /**
29
- * Helper function that attempts to find all Heroku Postgres Advanced-tier attachments on a given app.
30
- *
31
- * @param app - The name of the app to get the attachments for
32
- * @returns Promise resolving to an array of all Heroku Postgres Advanced-tier attachments on the app
33
- */
34
- private allAdvancedDatabaseAttachments;
35
- /**
36
- * Return all Heroku Postgres databases on the Advanced-tier for a given app.
37
- *
38
- * @param app - The name of the app to get the databases for
39
- * @returns Promise resolving to all Heroku Postgres databases
40
- * @throws {Error} When no legacy database add-on exists on the app
41
- */
42
- private getAllAdvancedDatabases;
43
- /**
44
- * Helper function that groups attachment names by addon.
45
- *
46
- * @param attachments - The attachments to group by addon
47
- * @returns A record of addon IDs with their attachment names
48
- */
49
- private getAttachmentNamesByAddon;
50
28
  private poolSelectionLoopStage;
51
29
  private poolSelectionStep;
52
30
  private renderDatabaseChoices;